Skip to content

Aggregate Roots

An aggregate is a cluster of entities and value objects that is treated as a single consistency boundary. The aggregate root is the only member of that cluster the outside world references directly — it owns its children, and all changes to the cluster go through it. In ABP, aggregate roots derive from one of the AggregateRoot<TKey> base classes (usually an audited variant), which add audit metadata, an ExtraProperties bag and a ConcurrencyStamp, and which can carry domain events.

For the framework reference, see ABP's Entities & aggregate roots and the DDD building blocks overview. This page covers the concrete aggregates in Cargonerds — Shipment, Consol, Organization, Order, Quotation, and the host-side ApiKey — how they are composed, what invariants (if any) they enforce, and the project-specific fact that entities here raise no domain events.

Where to look first

For the entity base-class spine and the marker-interface taxonomy (ICustomerOwned, IGuidEntity, IMasterData, the CodeEntity family, …) see Entities. For the bounded-context split and the project's deliberate departures from textbook DDD, see the DDD Overview. This page focuses specifically on the aggregate roots.

How aggregate roots are declared

There is no single shared base for all aggregates — the choice of base class depends on the auditing, soft-delete and tenancy needs of the entity:

Base class Used by Provides
AuditedAggregateRoot<Guid> (via Hub.Base.HubBaseGuidEntity) all Hub aggregates (Shipment, Consol, Organization, Order, Quotation, …) creation + modification audit, ExtraProperties, ConcurrencyStamp
FullAuditedAggregateRoot<Guid> Cargonerds.ApiKeys.ApiKey full audit incl. soft-delete + IMultiTenant
CreationAuditedAggregateRoot<Guid> Hub.Entities.Notifications.Notification creation audit + multi-tenancy

Every Hub aggregate ultimately inherits AuditedAggregateRoot<Guid> through the Hub base spine:

classDiagram
    class AuditedAggregateRoot~Guid~ {
        <<ABP>>
        +Guid Id
        +DateTime CreationTime
        +DateTime? LastModificationTime
        +ExtraProperties
        +ConcurrencyStamp
    }
    class HubBaseGuidEntity {
        [AutoGenerateDto]
        [ProvideEdm]
        SetIdInternal(Guid)
    }
    class CustomerOwnedGuidEntity {
        +Guid? CargonerdsCustomerId
    }
    class ShipmentBase {
        <<abstract, TPH base>>
        +string Discriminator
        +ShipmentState State
        #GetExternalIdentifiers()
    }
    AuditedAggregateRoot~Guid~ <|-- HubBaseGuidEntity
    HubBaseGuidEntity <|-- CustomerOwnedGuidEntity
    CustomerOwnedGuidEntity <|-- ShipmentBase
    CustomerOwnedGuidEntity <|-- Organization
    CustomerOwnedGuidEntity <|-- Order
    CustomerOwnedGuidEntity <|-- Quotation
    ShipmentBase <|-- Shipment
    ShipmentBase <|-- Consol

HubBaseGuidEntity is the root of nearly every Hub entity. It is more than a thin alias for AuditedAggregateRoot<Guid>: it carries the big code-generation attributes that drive the DTO and OData EDM surface for the whole hierarchy.

// 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),
    ],
    GenerateMapping = false,
    // …
)]
[ProvideEdm(ProvideInherits = true)]
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity
{
    internal void SetIdInternal(Guid id) => Id = id;
}

CustomerOwnedGuidEntity sits one level down and adds the CargonerdsCustomerId tenancy-style discriminator most business aggregates carry:

// 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; }
}

DTOs, OData and mapping are generated

Because [AutoGenerateDto(AutoGenerateDerived = true)] and [ProvideEdm(ProvideInherits = true)] sit on the base class, every aggregate below it gets a generated DTO/interface and an OData EDM entry. Much of the DTO/EDM surface therefore does not exist as hand-written source — see DTOs & mapping and Code generation for the implications. Object-to-object mapping uses Riok.Mapperly, not AutoMapper.

The Shipment aggregate

Shipment is the central aggregate of the Hub module. It does not inherit ShipmentBase only for convenience — ShipmentBase is an abstract aggregate root that is shared with Consol through EF Core Table-Per-Hierarchy (TPH) inheritance.

// 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;

    [Facet(Order = 10)]
    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; } = [];          // TODO: #5801
    public virtual ICollection<TrackingContainer> TrackingContainers { get; set; } = [];
    public Guid? BookedInOrganizationUnitId { get; set; }
    public Guid? BookedByUserId { get; set; }
}

What the aggregate owns

The bulk of the shipment's state and its child collections live on ShipmentBase (modules/hub/src/Hub.Domain/Base/ShipmentBase.cs, ~80 properties). These collections are the aggregate's children — they are loaded and persisted as part of the shipment graph:

// modules/hub/src/Hub.Domain/Base/ShipmentBase.cs (excerpt)
public abstract class ShipmentBase : CustomerOwnedGuidEntity, IDocumentParent
{
    public string CargonerdsNumber { get; set; }
    public string Discriminator { get; private set; }   // EF TPH discriminator, exposed as a property

    [Facet(Order = 10)]
    public ShipmentState State { get; set; }

    [Facet(Type = FilterType.Search, GroupOperator = GroupOperator.And, Order = 30)]
    public string? HouseBill { get; set; }

    // navigation collections owned by the aggregate
    public virtual ICollection<ShipmentLeg> ShipmentLegs { get; set; } = [];
    public virtual ICollection<Event> Events { get; set; } = [];
    public virtual ICollection<Container> Containers { get; set; } = [];
    public virtual ICollection<PackLine> PackLines { get; set; } = [];
    public virtual ICollection<ShipmentAddress> Addresses { get; set; } = [];
    public virtual ICollection<ShipmentBaseDocument> Documents { get; set; } = [];
    public virtual ICollection<Invoice> Invoices { get; set; } = [];
    public virtual ICollection<Revenue> Revenues { get; set; } = [];
    public virtual ICollection<ShipmentNote> Notes { get; set; } = [];
    public virtual ICollection<Tag> Tags { get; set; } = [];

    // value objects (EF owned/complex types, see Value Objects page)
    public virtual Dimension<WeightUnit>? TotalWeight { get; set; }
    public virtual Dimension<VolumeUnit>? TotalVolume { get; set; }

    protected abstract IEnumerable<ExternalIdentifier> GetExternalIdentifiers();

    public bool IsActive { get; set; } = true;

    protected ShipmentBase() { }

    protected ShipmentBase(string cargonerdsNumber, ShipmentState state, bool isActive,
        bool isTransferredToReporting, bool isExportedToMagaya)
    {
        CargonerdsNumber = cargonerdsNumber;
        State = state;
        IsActive = isActive;
        // …
    }
}

A few structural points worth calling out:

  • ShipmentBase is abstract and declares an abstract IEnumerable<ExternalIdentifier> GetExternalIdentifiers(). Each concrete root supplies its own typed external-identifier collection: Shipment returns ExternalShipmentIdentifiers, Consol returns ExternalConsolIdentifiers. This is how a shipment exposes its CW1/Magaya/etc. reference number (ReferenceNumberDisplay) without ShipmentBase knowing the concrete identifier types.
  • Discriminator is a real (private-set) property, not just an EF shadow column — it is the TPH discriminator that separates Shipment rows from Consol rows in the shared table.
  • Dimension<WeightUnit> / Dimension<VolumeUnit> are value objects mapped as EF owned/complex types, not separate aggregates — see Value Objects.
  • The protected/parameterless constructor exists for EF Core materialization; the parameterized one is a convenience for seeding/imports.

Single-table inheritance: Shipment and Consol

Shipment and Consol both derive from ShipmentBase and share one table, distinguished by the Discriminator column. Consequently the aggregate-level repository is typed on the base: IShipmentBaseRepository : IHubRepository<ShipmentBase, Guid> exposes loaders such as FindByIdWithDetailsAsync, FindByHouseBillWithDetailsAsync and GetShipmentsWithLastEventsAsync. See Repositories.

Consol (the consolidation that groups multiple house shipments) is the sibling TPH type and is deliberately thin — it inherits all the shared state from ShipmentBase and only adds its back-reference collection and its own external identifiers:

// modules/hub/src/Hub.Domain/Entities/Consol.cs
public class Consol : ShipmentBase
{
    public virtual ICollection<Shipment> Shipments { get; set; } = [];
    public virtual ICollection<ExternalConsolIdentifier> ExternalIdentifiers { get; set; } = [];

    protected override IEnumerable<ExternalIdentifier> GetExternalIdentifiers() => ExternalIdentifiers;
}

Shipment lifecycle / state

A shipment's lifecycle is modeled by the ShipmentState flags enum (modules/hub/src/Hub.Domain/Enums/ShipmentState.cs). Note the non-contiguous integer values — the enum is [Flags] and the gaps leave room for additional intermediate states:

[AutoGenerateDto(GenerateMapping = false)]
[Flags]
public enum ShipmentState
{
    Draft = 0,
    Booked = 1,
    InTransit = 8,
    Delivered = 16,
    Completed = 128,
}

State transitions are not enforced on the aggregate

State is a plain { get; set; } property. There is no guard method on the entity (no MarkBooked(), MarkDelivered(), etc.) that validates a transition. Allowed transitions and the business rules around booking live in the application layer (see Shipment Service), not on the aggregate. Do not assume the entity protects its own state machine.

The Organization aggregate

Organization is the master record for every counterparty (forwarder, carrier, broker, consignee, …). Rather than a Type enum, an organization carries a wide set of role flags — an organization can simultaneously be a shipper, a consignee and a national account:

// 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 IsGlobalAccount { get; set; }
    public bool IsNationalAccount { get; set; }
    // … ~24 IsXxx role flags in total …

    public virtual ICollection<ExternalOrganizationIdentifier> ExternalIdentifiers { get; set; } = [];
    public virtual ICollection<Contact> Contacts { get; set; } = [];
    public virtual ICollection<Address> Addresses { get; set; } = [];
    public virtual Association? Association { get; set; }

    public string? GetCW1OrgCode() =>
        this.GetExternalIdentifiers(ServiceType.CW1)
            .OfType<ExternalCw1OrgCodeIdentifier>()
            .FirstOrDefault()
            ?.Identifier;
}
  • It implements IExternallyIdentifiableEntity<ExternalOrganizationIdentifier>, exposing its external-system codes the same way a shipment does. The small GetCW1OrgCode() helper is one of the few pieces of genuine behaviour on a Hub aggregate.
  • Organizations form an association tree (parent/child via Association/AssociationId), which is loaded through IOrganizationRepository.GetAssociationTreeAsync / LoadAssociationOrganizationsAsync — see Organization Service and Repositories.
  • Some child collections are excluded from DTO generation with [IgnoreOnGeneration] (e.g. Addresses, BlobStorageFileReferences).

The Order aggregate

Order (purchase order) is an independent aggregate that references a shipment by foreign key rather than being a child of it:

// modules/hub/src/Hub.Domain/Entities/Order.cs
public class Order : CustomerOwnedGuidEntity, ISearchable<Order>
{
    public static Expression<Func<Order, string?>>[] SearchProperties =>
        [o => o.OrderNumber, o => o.CommercialInvoiceNumber];

    public string OrderNumber { get; set; }
    public OrganizationAddressContactRelation Buyer { get; set; }
    public OrganizationAddressContactRelation? Supplier { get; set; }
    public OrganizationAddressContactRelation? ShipTo { get; set; }

    public virtual ICollection<OrderLine> Lines { get; set; } = [];
    public virtual ICollection<OrderNote> Notes { get; set; } = [];

    public virtual Shipment? Shipment { get; set; }
    public Guid? ShipmentId { get; set; }   // FK reference, not ownership

    public IEnumerable<ExternalIdentifier> GetExternalIdentifiers() => ExternalIdentifiers;
    public virtual ICollection<ExternalOrderIdentifier> ExternalIdentifiers { get; set; } = [];
}

Reference by id across aggregate boundaries

Order owns its own children (Lines, Notes) but points at Shipment only through ShipmentId (+ a navigation for convenience). Shipment likewise exposes a ICollection<Order> Orders — note the // TODO: #5801 on that collection, indicating the order↔shipment relationship is still being firmed up. Referencing other aggregate roots by id (rather than nesting them) is the recommended ABP/DDD practice.

The Quotation aggregate (pricing — lives in Hub)

Pricing's central aggregate is Quotation. Despite the existence of a Pricing module, the quotation entity lives in the Hub domain (Hub.Entities.Pricing namespace); the Pricing.Domain project only holds a validator and an event handler over it.

// modules/hub/src/Hub.Domain/Entities/Pricing/Quotation.cs
public class Quotation : CustomerOwnedGuidEntity, IUserOwned, ISoftDelete, ISearchable<Quotation>
{
    public static Expression<Func<Quotation, string?>>[] SearchProperties =>
        [q => q.QuoteNumber, q => q.LegacyQuoteNumber];

    public string QuoteNumber { get; set; } = string.Empty;
    public Guid OrganizationUnitId { get; set; }
    public Guid UserId { get; set; }
    public QuoteStatus Status { get; set; } = QuoteStatus.Undefined;

    public virtual List<Offer> Offers { get; set; } = [];
    public virtual List<PackLineRequest> Packages { get; set; } = [];
    public virtual List<ContainerRequest> Containers { get; set; } = [];

    public bool IsDeleted { get; set; }   // ISoftDelete
    // … many request/response flags …
}

This aggregate is the one place where the project's eventing actually touches domain state, via a distributed event handler (next section).

The ApiKey aggregate — the strict-DDD exception

In the application core, ApiKey is the counter-example to the anemic Hub style. It is a fully audited, multi-tenant aggregate whose setters are encapsulated and enforce invariants:

// src/Cargonerds.Domain/ApiKeys/ApiKey.cs
[Serializable]
public class ApiKey : FullAuditedAggregateRoot<Guid>, IMultiTenant
{
    public Guid UserId { get; protected internal set; }

    public string Name
    {
        get;
        protected internal set => field =
            Check.NotNullOrWhiteSpace(value, nameof(value), ApiKeyConsts.MaxNameLength);
    } = string.Empty;

    public string? Description
    {
        get;
        set => field = Check.Length(value, nameof(value), ApiKeyConsts.MaxDescriptionLength);
    }

    public bool IsActive { get; set; }
    public Guid? TenantId { get; protected internal set; }
    // Prefix / Hash also guarded with Check.NotNullOrWhiteSpace(...)

    protected ApiKey() { }                 // for EF

    public ApiKey(Guid id, Guid userId, string prefix, string name, /* … */)
        : base(id) { /* assign all required values */ }
}

What makes this a textbook aggregate root:

  • Encapsulated setters (protected internal set) so state cannot be mutated arbitrarily from outside the domain.
  • Invariants in the setters using ABP's Check.NotNullOrWhiteSpace / Check.Length guard helpers, with the C# field keyword and length limits from ApiKeyConsts (e.g. MaxNameLength = 64, src/Cargonerds.Domain.Shared/ApiKeys/ApiKeyConsts.cs).
  • A protected parameterless constructor for EF plus a public constructor that takes all required values, so an ApiKey cannot be constructed in an invalid state.

ApiKey instances are not created directly. They are produced by the ApiKeyManager domain service — the only true ABP DomainService in the codebase — which generates the id, hashes the key, and stamps the current tenant:

// src/Cargonerds.Domain/ApiKeys/ApiKeyManager.cs
public class ApiKeyManager(IPasswordHasher<object> passwordHasher, IOptions<ApiKeyCreateOption> createOptions)
    : DomainService
{
    public ApiKey Create(string key, string prefix, Guid userId, string name, /* … */)
    {
        if (prefix.Length != createOptions.Value.PrefixLength)
            throw new ArgumentException($"The prefix length must be {createOptions.Value.PrefixLength} characters.", nameof(prefix));

        return new ApiKey(GuidGenerator.Create(), userId, prefix, name, /* … */, CurrentTenant.Id)
        {
            Hash = Hash(key),
        };
    }
}

Invariants & validation

Cargonerds enforces invariants in three places, in decreasing proximity to the entity:

Where Mechanism Example
On the aggregate (rare) encapsulated setters + Check.* guards ApiKey.Name / ApiKey.Prefix length checks
Domain services / validators injected services throwing exceptions QuotationCommentValidator blocks comments on a booked quote
Application layer app-service rules, org/tenant scoping shipment state transitions, booking rules

A representative domain validator — note it throws a UserFriendlyException, not a typed error code:

// modules/pricing/src/Pricing.Domain/Validators/QuotationCommentValidator.cs
public async Task ValidateAsync(CommentValidationContext context, CancellationToken ct = default)
{
    if (context.EntityType != PricingConsts.QuotationRequest) return;
    if (!Guid.TryParse(context.EntityId, out var quotationId)) return;

    var quotation = await quotationRepository.FindAsync(quotationId, cancellationToken: ct);
    if (quotation?.Status == QuoteStatus.Booked)
        throw new UserFriendlyException("Comments cannot be added or modified on a booked quotation request.");
}

Most invariants are not on the entity boundary

Outside ApiKey, Hub aggregates expose { get; set; } on virtually everything (including State and navigation collections). They are largely anemic: there are no private set invariants and no factory methods on Shipment/Organization/Order. Treat them as data holders and look to the application services and domain validators for the actual business rules. The reasons (serializer + [AutoGenerateDto] interplay) are detailed in Entities and the DDD Overview.

Note also that the three *ErrorCodes classes (HubErrorCodes, CargonerdsDomainErrorCodes, PricingErrorCodes) are empty placeholders. Domain errors are thrown with ad-hoc string codes such as BusinessException("Hub:UnauthorizedOrganizationUnit") (raised in CurrentFilterContextProvider and OrganizationUnitValidationService) or as UserFriendlyException messages — there is no central error-code catalog. See Error codes.

Domain events

ABP aggregate roots can collect local and distributed domain events that ABP publishes when the unit of work completes (see ABP's Local Event Bus and Distributed Event Bus). Cargonerds does not use this capability.

Entities raise no domain events

A repository-wide search for AddLocalEvent / AddDistributedEvent returns zero matches in every *.Domain project. No aggregate calls these methods, so do not expect AggregateRoot event collections to be populated. Cross-entity reactions are wired up in the application layer or via the distributed event bus from outside the entity, not raised by the entity itself.

The domain participates in eventing only as a subscriber. The one in-domain handler recomputes a Quotation's status when a related comment's status changes:

// modules/pricing/src/Pricing.Domain/EventHandler/CommentStatusChangedEventHandler.cs
public class CommentStatusChangedEventHandler(
    ICommentRepository commentRepository,
    IHubRepository<Hub.Entities.Pricing.Quotation, Guid> quotationRepository)
    : IDistributedEventHandler<CommentExtraPropertiesUpdatedEto>, ITransientDependency
{
    public async Task HandleEventAsync(CommentExtraPropertiesUpdatedEto eventData)
    {
        if (eventData.Comment.EntityType != PricingConsts.QuotationRequest) return;
        if (!Guid.TryParse(eventData.Comment.EntityId, out var quotationId)) return;

        // … recompute status from all comment statuses …
        var quotation = await quotationRepository.FindAsync(quotationId);
        if (quotation == null || quotation.Status == newStatus) return;

        quotation.Status = newStatus;
        await quotationRepository.UpdateAsync(quotation, autoSave: true);
    }
}

The event-transfer object (ETO) is a plain DTO, not an ABP *Eto base type:

// src/Cargonerds.Domain.Shared/Comments/CommentExtraPropertiesUpdatedEto.cs
public class CommentExtraPropertiesUpdatedEto
{
    public CommentDto Comment { get; set; }
    public Guid CommentId { get; set; }
    public string? ReferenceNumber { get; set; }
    public CommentStatus? Status { get; set; }
}

Gotchas

  • No domain events from entities. AddLocalEvent/AddDistributedEvent are never called. Reactions are handled outside the aggregate (application layer / distributed event handlers).
  • Aggregates are anemic (except ApiKey). Shipment, Consol, Organization, Order and Quotation expose public setters on essentially all state, including State and child collections. Invariants live in app services / validators, not on the entity. ApiKey is the lone strict-DDD aggregate.
  • ShipmentBase is the real aggregate boundary for shipments. Shipment and Consol share one table (TPH) and one repository (IShipmentBaseRepository); the Discriminator is an exposed (private-set) property, not just a shadow column.
  • Cross-aggregate references are by id. Order.ShipmentId references Shipment rather than nesting it; the Shipment.Orders collection is still under construction (TODO: #5801).
  • Pricing aggregate lives in Hub. Quotation (and its children/value objects) are under modules/hub/src/Hub.Domain/Entities/Pricing/; only the validator and event handler are in the Pricing module.
  • Empty error-code classes. All three *ErrorCodes classes are placeholders; errors use ad-hoc string codes or UserFriendlyException messages.
  • Heavy code generation on the base class. [AutoGenerateDto] / [ProvideEdm] on HubBaseGuidEntity mean DTOs and the OData EDM for every aggregate are generated — changes to an aggregate ripple into generated code that will not appear in a plain text search. See DTOs & mapping.

See also