Entities¶
Entities are the persistent, identity-bearing objects of the domain. In Cargonerds every entity has
a Guid primary key and (with one host-side exception) derives from a small, opinionated hierarchy
of base classes defined in the Hub module under the Hub.Base namespace
(modules/hub/src/Hub.Domain/Base/). The Hub module holds essentially the entire business domain —
roughly 190 entity files — while the host (Cargonerds) and Pricing domain projects are thin.
This page covers the entity base-class spine, the marker (capability) interfaces entities opt into, the master-data ("code entity") family, and the conventions and gotchas specific to this codebase. For aggregate-root behaviour see Aggregate Roots; for value objects see Value Objects; for the broader picture see DDD Overview.
ABP background
These types build on ABP's DDD building blocks. The framework concepts are documented at Entities & aggregate roots and the DDD building-blocks overview.
The inheritance spine¶
Almost every Hub entity ultimately derives from AuditedAggregateRoot<Guid> via
HubBaseGuidEntity. There are two main branches: ordinary business entities flow through
CustomerOwnedGuidEntity, while master/reference data flows through CodeEntity.
classDiagram
class AuditedAggregateRoot~Guid~ {
<<ABP>>
+DateTime CreationTime
+Guid? CreatorId
+DateTime? LastModificationTime
+Guid? LastModifierId
}
class IGuidEntity {
<<interface>>
}
class HubBaseGuidEntity {
+SetIdInternal(Guid) internal
}
class CustomerOwnedGuidEntity {
+Guid? CargonerdsCustomerId
}
class CodeEntity {
+string Code
+string? Description
+bool IsFrozen
}
class ShipmentBase {
<<abstract>>
+string Discriminator
}
class Unit {
+decimal? BaseUnitFactor
+decimal? NullPointShift
}
class ExternalIdentifier {
<<abstract>>
}
AuditedAggregateRoot~Guid~ <|-- HubBaseGuidEntity
IGuidEntity <|.. HubBaseGuidEntity
HubBaseGuidEntity <|-- CustomerOwnedGuidEntity
HubBaseGuidEntity <|-- CodeEntity
CustomerOwnedGuidEntity <|-- ShipmentBase
CustomerOwnedGuidEntity <|-- ExternalIdentifier
CodeEntity <|-- Unit
ShipmentBase <|-- Shipment
ShipmentBase <|-- Consol
IGuidEntity and HubBaseGuidEntity¶
IGuidEntity is just an alias for ABP's IEntity<Guid>, used as a non-generic constraint across the
domain so helpers can accept "any Hub entity":
HubBaseGuidEntity is the root of nearly every Hub entity. It extends ABP's
AuditedAggregateRoot<Guid> and carries two source-generation attributes that drive a large amount
of generated code (DTOs, OData EDM):
// modules/hub/src/Hub.Domain/Base/HubBaseGuidEntity.cs
[AutoGenerateDto(
AutoGenerateDerived = true,
BaseType = "Volo.Abp.Application.Dtos.IEntityDto<Guid>",
PropertiesToIgnore = [
nameof(AuditedAggregateRoot.ExtraProperties),
nameof(AuditedAggregateRoot.ConcurrencyStamp),
nameof(AuditedAggregateRoot.LastModifierId),
nameof(AuditedAggregateRoot.CreatorId),
],
KeepAttributesOnGeneratedClass = false,
DefaultPropertyInterfaceAccess = InterfaceProperty.GetAndSet,
KeepPropertyAttributesOnGeneratedClass = false,
GenerateMapping = false,
KeepPropertyAttributesOnGeneratedInterface = false
)]
[ProvideEdm(ProvideInherits = true)]
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity
{
internal void SetIdInternal(Guid id) => Id = id;
}
Auditing comes for free
Because HubBaseGuidEntity extends AuditedAggregateRoot<Guid>, every Hub entity carries
CreationTime, CreatorId, LastModificationTime and LastModifierId. This is creation +
modification auditing, not full (soft-delete) auditing — see
Audit levels below.
SetIdInternal
Id on an ABP entity has a protected setter. SetIdInternal is an internal escape hatch so
seeding/import code in the same assembly (e.g. CodeEntity.InitializeSuperType, which copies an
existing row's Id onto a code-defined instance) can assign the key without exposing a public
setter.
CustomerOwnedGuidEntity — customer scoping¶
The most common branch. It adds a nullable CargonerdsCustomerId used to isolate data per customer:
// modules/hub/src/Hub.Domain/Base/CustomerOwnedGuidEntity.cs
public class CustomerOwnedGuidEntity : HubBaseGuidEntity, ICustomerOwned
{
// Needs to be public, because otherwise the serializer ignores it
[EditorBrowsable(EditorBrowsableState.Never)]
public Guid? CargonerdsCustomerId { get; set; }
}
The [EditorBrowsable(Never)] attribute hides the property from IntelliSense (it is infrastructure,
not something application code should set by hand), but it must stay public so the JSON
serializer round-trips it — see Gotchas. The value is populated automatically on save by
HubDbContext and enforced by the customer-owned query filter, so application code rarely touches it.
See Entity Framework and
OData & filtering for how the filter applies.
ShipmentBase — the TPH aggregate base¶
ShipmentBase is the abstract base for the two core logistics aggregates, Shipment and Consol,
mapped with EF Core Table-Per-Hierarchy (TPH) into a single Shipments table. It is a large
entity (~80 properties) holding state, dates, packing info, CO2 figures, and many navigation
collections:
// modules/hub/src/Hub.Domain/Base/ShipmentBase.cs
public abstract class ShipmentBase : CustomerOwnedGuidEntity, IDocumentParent
{
public string CargonerdsNumber { get; set; }
public string? ParentCargonerdsNumber { get; set; }
public string Discriminator { get; private set; } // EF TPH discriminator, exposed as a property
[Facet(Type = FilterType.Search, GroupOperator = GroupOperator.And, Order = 30)]
public string? HouseBill { get; set; }
[Facet(Order = 10)]
public ShipmentState State { get; set; }
public virtual Dimension<WeightUnit>? TotalWeight { get; set; }
public virtual Dimension<VolumeUnit>? TotalVolume { get; set; }
public virtual ICollection<ShipmentLeg> ShipmentLegs { get; set; } = [];
public virtual ICollection<Container> Containers { get; set; } = [];
public virtual ICollection<ShipmentBaseDocument> Documents { get; set; } = [];
IEnumerable<Document> IDocumentParent.Documents => Documents; // explicit interface impl
protected abstract IEnumerable<ExternalIdentifier> GetExternalIdentifiers();
// ...
}
Things to note:
Discriminatoris a real CLR property (with a private setter) that EF uses as the TPH discriminator column.HubDbContextconfiguresHasDiscriminator(s => s.Discriminator)with the column name"Discriminator". This lets queries and projections read the concrete subtype as data.Documentsis implemented as the concreteICollection<ShipmentBaseDocument>; theIDocumentParentcontract'sDocumentsis funnelled through an explicit interface implementation.GetExternalIdentifiers()is abstract — each subtype returns its own typed identifier collection (ShipmentreturnsExternalShipmentIdentifiers).- Many properties carry
[Facet(...)]attributes that drive the faceted search/filter UI (see Search & faceting).
The two concrete subtypes are thin:
// modules/hub/src/Hub.Domain/Entities/Shipment.cs
public class Shipment : ShipmentBase, ISearchable<Shipment>
{
public static Expression<Func<Shipment, string?>>[] SearchProperties =>
[s => s.HouseBill, s => s.TravelNumber, s => s.TransportationVehicle, s => s.QuoteNumber];
public Shipment(JobType? jobType = null) : base() => JobType = jobType;
public JobType? JobType { get; set; }
public virtual List<CustomsDeclaration> CustomsDeclarations { get; set; } = [];
public virtual ICollection<ExternalShipmentIdentifier> ExternalIdentifiers { get; set; } = [];
protected override IEnumerable<ExternalIdentifier> GetExternalIdentifiers() => ExternalIdentifiers;
public virtual ICollection<Consol> Consols { get; set; } = [];
public virtual ICollection<Order> Orders { get; set; } = [];
public virtual ICollection<TrackingContainer> TrackingContainers { get; set; } = [];
public Guid? BookedInOrganizationUnitId { get; set; }
public Guid? BookedByUserId { get; set; }
}
Consol (Entities/Consol.cs) is the sibling TPH type for consolidation shipments. See
Aggregate Roots for the aggregate-level discussion of these two types.
Representative entities¶
| Entity | File (under modules/hub/src/Hub.Domain/) |
Base class | Notes |
|---|---|---|---|
Shipment |
Entities/Shipment.cs |
ShipmentBase |
Core logistics aggregate; TPH Discriminator |
Consol |
Entities/Consol.cs |
ShipmentBase |
Consolidation shipment (sibling TPH type) |
Organization |
Entities/Organization.cs |
CustomerOwnedGuidEntity |
Trading partners / accounts; ~24 IsXxx role flags |
Container |
Entities/Container.cs |
CustomerOwnedGuidEntity |
Cargo containers; uses three Dimension<> value objects |
Document (abstract) |
Entities/Document.cs |
CustomerOwnedGuidEntity |
File metadata + non-persisted blob content |
Address |
Entities/Address.cs |
CustomerOwnedGuidEntity |
Uses Coordinates?; is IMasterData |
Quotation |
Entities/Pricing/Quotation.cs |
CustomerOwnedGuidEntity |
Pricing aggregate; IUserOwned, ISoftDelete |
ContainerType |
Entities/ContainerType.cs |
CodeEntity |
Master/reference "code entity" (static enum) |
Unit |
Base/Unit.cs |
CodeEntity |
Unit-of-measure code entity with conversion factors |
Pricing entities live in the Hub project
Despite the separate Pricing module, the pricing entities (Quotation, Offer, Margin,
Place, …) are defined under modules/hub/src/Hub.Domain/Entities/Pricing/ in the
Hub.Entities.Pricing namespace, and they are mapped/owned by HubDbContext. The
Pricing.Domain project itself contains only stubs, one validator and one event handler. See
Pricing module.
Example: Organization¶
// modules/hub/src/Hub.Domain/Entities/Organization.cs
public class Organization : CustomerOwnedGuidEntity,
IExternallyIdentifiableEntity<ExternalOrganizationIdentifier>
{
public string Name { get; set; }
public Guid? AssociationId { get; set; }
public bool IsForwarder { get; set; }
public bool IsShippingProvider { get; set; }
public bool IsBroker { get; set; }
public bool IsConsignee { get; set; }
public bool IsConsignor { get; set; }
public bool IsActive { get; set; }
// ~24 IsXxx role booleans in total
public virtual ICollection<ExternalOrganizationIdentifier> ExternalIdentifiers { get; set; } = [];
public virtual ICollection<Contact> Contacts { get; set; } = [];
public virtual ICollection<Invoice> Invoices { get; set; } = [];
}
Example: Document¶
Document is abstract — concrete documents (e.g. ShipmentBaseDocument) derive from it. It
stores file metadata plus blob content that is not persisted to the database; Content/
Base64Content are transport-only helpers used by data converters when exchanging files with
external systems. Setting Content also recomputes FileSize and the Sha256Sum.
// modules/hub/src/Hub.Domain/Entities/Document.cs
public abstract class Document : CustomerOwnedGuidEntity, ISearchable<Document>
{
public string FileName { get; set; }
public virtual FileType? FileType { get; set; }
public string? Description { get; set; }
public int FileSize { get; set; }
public string? MimeType { get; set; }
public string? Sha256Sum { get; set; }
public bool IsDeleted { get; set; } // a plain flag, NOT ABP ISoftDelete
public int VersionNumber { get; set; }
// Content / Base64Content are non-persisted transport helpers (see remarks in source)
public byte[]? Content { get; set; }
[IgnoreOnGeneration]
public abstract IDocumentParent Parent { get; }
public static Expression<Func<Document, string?>>[] SearchProperties =>
[s => s.FileName, s => s.Description];
}
Tip
Document.IsDeleted is a manual boolean, not ABP's ISoftDelete. ABP ISoftDelete is used
by the pricing entities (Quotation, Margin, Offer, ChargeLine) in the Hub domain, not by
Document. Don't assume soft-delete query filtering applies to documents.
Marker (capability) interfaces¶
Rather than deep inheritance, entities opt into behaviour by implementing small marker interfaces
(most in Hub.Base, a couple in Hub.Entities). The model-building conventions, query filters and
save-time logic in HubDbContext then react to those interfaces — for example
MultiTypeConfiguration<TBase> applies one EF configuration to every entity assignable to a base
interface. See Entity Framework.
| Interface | File | Purpose |
|---|---|---|
ICustomerOwned |
Base/ICustomerOwned.cs |
Guid? CargonerdsCustomerId — entity is hard-scoped to a customer |
IOptionallyCustomerOwned |
Entities/IOptionallyCustomerOwned.cs |
Same property, but customer scoping is optional (used by CodeEntity) |
IMasterData |
Base/IMasterData.cs |
Empty marker: "this is reference/master data" |
IFreezable |
Base/IFreezable.cs |
IsFreezable / IsFrozen / Freeze() — becomes immutable once frozen (cached/seeded) |
ISuperType<T> |
Base/ISuperType.cs |
static abstract List<T> Initialize(...) — code-defined static-enum seeding (implies IFreezable) |
IDocumentParent |
Base/IDocumentParent.cs |
Owns a collection of Documents (: IGuidEntity, ICustomerOwned) |
IExternallyIdentifiableEntity<T> |
Base/IExternallyIdentifiableEntity.cs |
Carries ICollection<T> external-system identifiers |
IUserOwned |
Base/IUserOwned.cs |
Guid UserId { get; } — entity is owned by a specific user |
ISearchable<TSelf> |
Hub.Domain.Shared/Search/ISearchable.cs |
static abstract SearchProperties for global text search |
IReadonlyBaseEntity |
Base/IReadonlyBaseEntity.cs |
Read-only entity marker |
ICodeIdentifier |
Base/ICodeIdentifier.cs |
Carries a code identifier |
These interfaces are deliberately tiny. For instance:
// modules/hub/src/Hub.Domain/Base/IUserOwned.cs
public interface IUserOwned { Guid UserId { get; } }
// modules/hub/src/Hub.Domain/Base/IMasterData.cs
public interface IMasterData { } // pure marker
Search and faceting¶
ISearchable<TSelf> uses a C# static abstract member: each entity declares a static array of
property expressions that the global text search feeds into WhereContains:
// modules/hub/src/Hub.Domain.Shared/Search/ISearchable.cs (excerpt)
public interface ISearchable<TSelf> where TSelf : class
{
static abstract Expression<Func<TSelf, string?>>[] SearchProperties { get; }
// helpers: AllOfString(), AllWithAttribute<TAttribute>(), Combine(...), GetSearchProperties()
}
Separately, [Facet(...)] attributes (Hub.Domain.Shared/Attributes/Facets/FacetAttribute.cs) on
entity properties drive the faceted filter UI — e.g. ValuePath/LabelPath/ValueType/FilterType/
Order as seen throughout ShipmentBase.
Master / reference data: CodeEntity¶
All reference/master-data ("code") entities derive from the abstract CodeEntity, which combines the
freeze, optional-customer-ownership and master-data markers and implements a static-enum seeding
protocol plus custom equality.
// modules/hub/src/Hub.Domain/Base/CodeEntity.cs (excerpt)
public abstract class CodeEntity : HubBaseGuidEntity, IOptionallyCustomerOwned, IFreezable, IMasterData
{
public string Code { get => _code; set { ThrowIfFrozen(); _code = value; } }
public string? Description { get => _description; set { ThrowIfFrozen(); _description = value; } }
public Guid? CargonerdsCustomerId { get; set; } // also guarded by ThrowIfFrozen
[NotMapped] public bool IsFrozen { get; private set; }
void IFreezable.Freeze() { /* sets IsFrozen = true (if IsFreezable) */ }
public bool Equals(CodeEntity? other)
{
if (other is null) return false;
if (!IsFrozen && !other.IsFrozen) return ReferenceEquals(this, other);
// Only if one is frozen (i.e. coming from a cache) do we treat them equal when Ids match
return Id == other.Id;
}
public override int GetHashCode() => RuntimeHelpers.GetHashCode(this); // always identity hash
}
A concrete code entity is tiny — it just wires up the static-enum seeding and external-code mapping:
// modules/hub/src/Hub.Domain/Entities/ContainerType.cs
public class ContainerType
: CodeEntity, ISuperType<ContainerType>,
IMapableTo<ContainerType, StaticServiceType.CW1>, IHubCacheableEntity
{
public static List<ContainerType> Initialize(List<ContainerType> existingItems) =>
InitializeSuperType(existingItems);
public static bool UseInternalCodeAsFallback => true;
}
InitializeSuperType<T> (on CodeEntity) reconciles the code-defined values against existing DB
rows by Code: matched rows get their Id copied onto the in-code instance and are updated in
place; unmatched (new) instances are returned for insertion. This runs at start-up via the
data-seeding pipeline. Many such code entities exist — ContainerMode, IncoTerm, TransportMode,
AddressType, EventCode, FileType, LengthUnit, NoteType, ReferenceType, etc.
Unit — code entity for units of measure¶
Unit is a CodeEntity adding conversion data, so a Dimension can reference a persisted unit row
rather than an inline string. The SI base units are documented in the source as C (temperature),
M (length), KG (mass), M3 (volume):
// modules/hub/src/Hub.Domain/Base/Unit.cs (excerpt)
public class Unit : CodeEntity
{
public decimal? BaseUnitFactor { get; set; } // factor to convert to the SI base unit
public decimal? NullPointShift { get; set; } // offset (temperature only, e.g. 0C = 32F)
}
Dimension<TUnit> (a value object) is generic over Unit so weight/volume/temperature reuse one
type with unit type-safety. See Value Objects for Dimension<TUnit> and
Coordinates.
Static enums and external-code mapping
Code entities double as "static enums" that map to external systems (e.g. CargoWise / CW1).
IMapableTo<TEntity, StaticServiceType.CW1> adds a static FromExternal(string) plus the
UseInternalCodeAsFallback flag; [Cw1CodesAttribute] and friends annotate the external codes.
Note that StaticServiceType itself has no hand-written source — it is produced by
Hub.SourceGenerators from the [StaticEnum] ServiceType enum, so searching for its members in
source will mislead. See Code generation.
Audit levels¶
The codebase mixes ABP entity bases depending on what auditing each entity needs:
| Base | Used by | Audit fields |
|---|---|---|
AuditedAggregateRoot<Guid> |
All Hub entities (via HubBaseGuidEntity) |
Creation + modification |
FullAuditedAggregateRoot<Guid> |
Host ApiKey (src/Cargonerds.Domain/ApiKeys/ApiKey.cs) |
Creation + modification + soft delete |
Entity with composite GetKeys() |
Host join entities (e.g. OrganizationUnitOwnerUser) |
None (plain entity) |
ISoftDelete (added on top) |
Pricing entities (Quotation, Margin, Offer, ChargeLine) |
Adds soft-delete flag |
Conventions in this codebase¶
- Public setters everywhere on Hub entities. Hub aggregates expose
{ get; set; }on virtually all properties (including state and navigation collections initialised to[]). Invariants are enforced in the application layer, not at the entity boundary — these are deliberately anemic aggregates. This is a conscious departure from textbook DDD and is partly driven by the[AutoGenerateDto]/ serializer pipeline. - The strict-DDD counter-example:
ApiKey. The one host aggregate that follows tighter DDD lives atsrc/Cargonerds.Domain/ApiKeys/ApiKey.cs:protected internalsetters withCheck.*guards (using the C# 13fieldkeyword), aprotectedctor for EF, and a public ctor taking all required values:
// src/Cargonerds.Domain/ApiKeys/ApiKey.cs (excerpt)
public class ApiKey : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
public string Name
{
get;
protected internal set =>
field = Check.NotNullOrWhiteSpace(value, nameof(value), ApiKeyConsts.MaxNameLength);
} = string.Empty;
protected ApiKey() { } // for EF
public ApiKey(Guid id, Guid userId, string prefix, string name, /* ... */) : base(id) { /* ... */ }
}
- Collections initialised inline. Navigation collections use C# collection expressions
(
= [];) so they are never null. - DTO / EDM generation is attribute-driven.
[AutoGenerateDto]onHubBaseGuidEntity(and value objects) emits DTO classes/interfaces;[ProvideEdm]feeds the OData EDM model;[IgnoreOnGeneration]/[GenerationPropertySetting]fine-tune output. See DTOs and Code generation. - No domain events raised by entities. Aggregates never call
AddLocalEvent/AddDistributedEvent. The domain only handles events (e.g. Pricing'sCommentStatusChangedEventHandler). See DDD Overview.
Gotchas¶
CargonerdsCustomerId must stay public
CustomerOwnedGuidEntity.CargonerdsCustomerId is public (with [EditorBrowsable(Never)])
only because the serializer would otherwise ignore it. Don't be tempted to tighten the
setter — the field needs to round-trip through JSON and is set automatically by HubDbContext.
CodeEntity equality is mode-dependent
Unfrozen code entities compare by reference; frozen ones (typically from the Hub second-level
cache) compare by Id. But GetHashCode() always returns the runtime identity hash. Two
"equal" frozen entities can therefore have different hash codes — they are risky as dictionary or
HashSet keys.
Frozen code entities throw on mutation
Once IFreezable.Freeze() has been called, setting Code / Description /
CargonerdsCustomerId throws InvalidOperationException via ThrowIfFrozen(). Frozen instances
usually originate from the cache, so a "random" InvalidOperationException on a setter is the
tell-tale sign you're mutating a cached singleton. See Caching.
Value objects are not ABP ValueObjects
Coordinates and Dimension<TUnit> are plain classes mapped as EF owned/complex types — they
have no value-equality (GetAtomicValues()). Treat them as data holders, not equality-comparable
VOs. Details on Value Objects.
TPH discriminator is a real property
ShipmentBase.Discriminator is a CLR property (private setter) used as the EF TPH discriminator.
It is data, not just schema — but it is EF-managed; do not assign it manually.
See also¶
- Aggregate Roots —
Shipment/Consoland aggregate behaviour - Value Objects —
Coordinates,Dimension<TUnit>,Unit - Repositories —
IHubRepository, specialized repos, custom registration - Domain Services —
ApiKeyManager, the canonicalDomainService - DDD Overview — the three domain layers and overall modelling choices
- DTOs and Code generation —
[AutoGenerateDto]/[ProvideEdm] - Entity Framework — TPH mapping, enum-as-string, owned types, save-time logic
- OData & filtering — customer/org scoping via query filters
- ABP reference: Entities & aggregate roots