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:
ShipmentBaseis abstract and declares anabstract IEnumerable<ExternalIdentifier> GetExternalIdentifiers(). Each concrete root supplies its own typed external-identifier collection:ShipmentreturnsExternalShipmentIdentifiers,ConsolreturnsExternalConsolIdentifiers. This is how a shipment exposes its CW1/Magaya/etc. reference number (ReferenceNumberDisplay) withoutShipmentBaseknowing the concrete identifier types.Discriminatoris a real (private-set) property, not just an EF shadow column — it is the TPH discriminator that separatesShipmentrows fromConsolrows 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 smallGetCW1OrgCode()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 throughIOrganizationRepository.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.Lengthguard helpers, with the C#fieldkeyword and length limits fromApiKeyConsts(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
ApiKeycannot 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/AddDistributedEventare never called. Reactions are handled outside the aggregate (application layer / distributed event handlers). - Aggregates are anemic (except
ApiKey).Shipment,Consol,Organization,OrderandQuotationexpose public setters on essentially all state, includingStateand child collections. Invariants live in app services / validators, not on the entity.ApiKeyis the lone strict-DDD aggregate. ShipmentBaseis the real aggregate boundary for shipments.ShipmentandConsolshare one table (TPH) and one repository (IShipmentBaseRepository); theDiscriminatoris an exposed (private-set) property, not just a shadow column.- Cross-aggregate references are by id.
Order.ShipmentIdreferencesShipmentrather than nesting it; theShipment.Orderscollection is still under construction (TODO: #5801). - Pricing aggregate lives in Hub.
Quotation(and its children/value objects) are undermodules/hub/src/Hub.Domain/Entities/Pricing/; only the validator and event handler are in the Pricing module. - Empty error-code classes. All three
*ErrorCodesclasses are placeholders; errors use ad-hoc string codes orUserFriendlyExceptionmessages. - Heavy code generation on the base class.
[AutoGenerateDto]/[ProvideEdm]onHubBaseGuidEntitymean 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¶
- DDD Overview — bounded contexts, module graph, where this codebase departs from textbook DDD
- Entities — the full base-class spine, marker interfaces, and the
CodeEntity/ master-data family - Value Objects —
Coordinates,Dimension<TUnit>andUnit - Domain Services —
ApiKeyManagerand the Hub service helpers - Repositories —
IHubRepository,IShipmentBaseRepository,IOrganizationRepository - Shipment Service · Organization Service
- ABP: Entities & aggregate roots · Domain services · Distributed event bus