Skip to content

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":

// modules/hub/src/Hub.Domain/Base/IGuidEntity.cs
public interface IGuidEntity : IEntity<Guid> { }

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:

  • Discriminator is a real CLR property (with a private setter) that EF uses as the TPH discriminator column. HubDbContext configures HasDiscriminator(s => s.Discriminator) with the column name "Discriminator". This lets queries and projections read the concrete subtype as data.
  • Documents is implemented as the concrete ICollection<ShipmentBaseDocument>; the IDocumentParent contract's Documents is funnelled through an explicit interface implementation.
  • GetExternalIdentifiers() is abstract — each subtype returns its own typed identifier collection (Shipment returns ExternalShipmentIdentifiers).
  • 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 at src/Cargonerds.Domain/ApiKeys/ApiKey.cs: protected internal setters with Check.* guards (using the C# 13 field keyword), a protected ctor 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] on HubBaseGuidEntity (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's CommentStatusChangedEventHandler). 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