Domain-Driven Design Overview¶
Cargonerds is built on the ABP Framework as a .NET 10 modular monolith
(TargetFramework = net10.0, AbpVersion = 10.1.1 in
common.props) and follows Domain-Driven Design (DDD). This page
explains how DDD is applied here: the bounded contexts, the DDD building blocks as they appear in
this codebase, and where this solution deliberately departs from textbook ABP DDD.
For the ABP reference on the concepts below, start with the framework's DDD building blocks overview.
Bounded contexts¶
The solution has three independent domain layers. Each is a separate
ABP module and follows the
standard ABP split into a *.Domain project (entities, domain services, repository interfaces) and a
*.Domain.Shared project (enums, consts, error codes, localization, attributes).
| Bounded context | Project roots | Root namespace | Scope |
|---|---|---|---|
| Host app | src/Cargonerds.Domain + src/Cargonerds.Domain.Shared |
Cargonerds |
Framework plumbing: Identity extensions, API keys, OpenIddict/SaaS data seeding, settings, OrganizationUnit ownership, comment contracts |
| Hub module | modules/hub/src/Hub.Domain + modules/hub/src/Hub.Domain.Shared |
Hub |
The real logistics domain: shipments, consols, containers, organizations, addresses, tracking, and the pricing entities, plus code/master data |
| Pricing module | modules/pricing/src/Pricing.Domain + modules/pricing/src/Pricing.Domain.Shared |
Pricing |
Thin: settings/error-code stubs, one comment validator, one distributed-event handler over Hub's Quotation |
The Hub module is where the domain actually lives
Despite the names, essentially all domain modeling is in the Hub module (~190 entity files,
the base-class hierarchy, value objects, code/master data, query-filter abstractions). The host
Cargonerds.Domain is mostly framework plumbing (data seeders, identity extensions, the API-key
domain). Pricing.Domain is almost empty — see Where this codebase departs from textbook DDD.
Module dependency graph¶
The contexts are wired together with [DependsOn(...)]. Pricing depends on Hub (so it can reference
Hub.Entities.Pricing.Quotation); both depend on ABP's DDD and Volo commercial modules.
graph TD
Pricing[PricingDomainModule] --> Hub[HubDomainModule]
Pricing --> AbpDdd[AbpDddDomainModule]
Pricing --> Suite[VoloAbpCommercialSuiteTemplatesModule]
Pricing --> PricingShared[PricingDomainSharedModule]
Hub --> AbpDdd
Hub --> Identity[AbpIdentityDomainModule]
Hub --> Suite
Hub --> HubShared[HubDomainSharedModule]
Hub --> FileMgmt[FileManagementDomainModule]
Cargonerds[CargonerdsDomainModule]
// modules/hub/src/Hub.Domain/HubDomainModule.cs
[DependsOn(
typeof(AbpDddDomainModule),
typeof(AbpIdentityDomainModule),
typeof(VoloAbpCommercialSuiteTemplatesModule),
typeof(HubDomainSharedModule),
typeof(FileManagementDomainModule)
)]
public class HubDomainModule : AbpModule { }
// modules/pricing/src/Pricing.Domain/PricingDomainModule.cs
[DependsOn(
typeof(HubDomainModule),
typeof(AbpDddDomainModule),
typeof(VoloAbpCommercialSuiteTemplatesModule),
typeof(PricingDomainSharedModule)
)]
public class PricingDomainModule : AbpModule { }
See Solution structure and Module structure for the full project layout, and the Modules overview for what each module owns.
Layering¶
Each bounded context repeats the standard ABP layer split. The Domain layer is the innermost ring; everything else depends inward on it.
*.Domain.Shared // enums, consts, error codes, localization, attributes
*.Domain // entities, aggregate roots, value objects, domain services, repository interfaces
*.Application.Contracts // DTOs, app-service interfaces, permission definitions
*.Application // application service implementations
*.EntityFrameworkCore // repository implementations, DbContext
*.HttpApi / *.HttpApi.Client
The Layered architecture page covers the dependency rules between these in depth.
DDD building blocks in this codebase¶
The table maps each DDD building block to the concrete base type and a representative example used here. Each row links to the dedicated page and the corresponding ABP reference.
| Building block | Base type used here | Representative example | Pages |
|---|---|---|---|
| Aggregate root | AuditedAggregateRoot<Guid> via Hub.Base.HubBaseGuidEntity |
Hub.Entities.Shipment |
Aggregate roots · ABP |
| Aggregate root (full audit / multi-tenant) | FullAuditedAggregateRoot<Guid> |
Cargonerds.ApiKeys.ApiKey |
Aggregate roots |
| Entity | HubBaseGuidEntity / CustomerOwnedGuidEntity |
Hub.Entities.Organization, Hub.Entities.Container |
Entities · ABP |
| Value object (data holder) | plain class with [AutoGenerateDto], mapped as an EF owned type |
Hub.Coordinates, Hub.Base.Dimension<TUnit> |
Value objects · ABP |
| Master / reference data | Hub.Base.CodeEntity |
Hub.Entities.ContainerType, IncoTerm, TransportMode |
Value objects |
| Domain service | Volo.Abp.Domain.Services.DomainService |
Cargonerds.ApiKeys.ApiKeyManager |
Domain services · ABP |
| Repository | IRepository<TEntity, Guid> / custom IHubRepository<,> |
Hub.Repositories.IShipmentBaseRepository |
Repositories · ABP |
The entity inheritance spine (Hub)¶
Almost every Hub entity derives from a single root, HubBaseGuidEntity, which is an ABP
AuditedAggregateRoot<Guid>. The common chain is:
graph TD
AAR["AuditedAggregateRoot<Guid><br/>(ABP)"] --> HBGE[HubBaseGuidEntity]
HBGE --> COGE["CustomerOwnedGuidEntity<br/>(+ CargonerdsCustomerId)"]
HBGE --> CE["CodeEntity<br/>(master / reference data)"]
COGE --> SB["ShipmentBase<br/>(TPH base)"]
COGE --> Org[Organization]
COGE --> Container[Container]
COGE --> Quot["Quotation<br/>(Hub.Entities.Pricing)"]
SB --> Shipment
SB --> Consol
HubBaseGuidEntity itself is tiny — its weight is in the attributes that drive code generation
(see Code generation):
// modules/hub/src/Hub.Domain/Base/HubBaseGuidEntity.cs
[AutoGenerateDto(
AutoGenerateDerived = true,
BaseType = "Volo.Abp.Application.Dtos.IEntityDto<Guid>",
PropertiesToIgnore = [ /* ExtraProperties, ConcurrencyStamp, LastModifierId, CreatorId */ ],
GenerateMapping = false,
/* ... */
)]
[ProvideEdm(ProvideInherits = true)]
public class HubBaseGuidEntity : AuditedAggregateRoot<Guid>, IGuidEntity
{
internal void SetIdInternal(Guid id) => Id = id;
}
CustomerOwnedGuidEntity adds the per-customer scoping key used by the query filters:
// 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; }
}
Almost everything is Guid-keyed
The common ancestor interface is Hub.Base.IGuidEntity : IEntity<Guid>, and HubBaseGuidEntity
derives from AuditedAggregateRoot<Guid>. Because of that, every Hub entity carries
CreationTime, CreatorId, LastModificationTime, and LastModifierId for free.
The dedicated Entities and Aggregate roots pages walk this
hierarchy and the marker-interface taxonomy (ICustomerOwned, IMasterData, IFreezable,
ISearchable<T>, IDocumentParent, …) in detail.
Value objects are data holders, not ABP ValueObjects¶
Coordinates and the generic Dimension<TUnit> model the "value object" role, but they are plain
classes, not ABP's ValueObject base. They have no GetAtomicValues() and therefore no
value-equality; they are mapped as EF owned/complex types.
// modules/hub/src/Hub.Domain/Base/Dimension.cs
[AutoGenerateDto(GenerateMapping = false)]
public partial class Dimension<TUnit> : IDimension
where TUnit : Unit
{
public decimal? Value { get; set; }
public virtual TUnit? Unit { get; set; }
}
Dimension<TUnit> is generic over Unit so weight, volume, and temperature reuse one type while
keeping unit type-safety (Dimension<WeightUnit> vs Dimension<VolumeUnit>). Note that Unit
itself is a CodeEntity (a database row), so a Dimension references a persisted unit, not an inline
string. See Value objects for the full treatment and the SI base-unit conversion
(BaseUnitFactor / NullPointShift on Hub.Base.Unit).
Domain services¶
The single textbook ABP DomainService in the whole solution is ApiKeyManager (factory + hashing),
which uses the inherited GuidGenerator and CurrentTenant:
// 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, /* ... */)
{
var hashedKey = Hash(key);
return new ApiKey(GuidGenerator.Create(), userId, prefix, name, /* ... */, CurrentTenant.Id)
{
Hash = hashedKey,
};
}
}
Hub's Services/ folder holds infrastructure-flavoured helpers
(CurrentFilterContextProvider : ISingletonDependency, WhiteLabelingTemplateRenderer,
ParallelChunkProcessor) registered via ABP
DI lifetime markers rather
than DomainService. See Domain services.
Repositories¶
Repository interfaces live in the Domain layer; their implementations live in the
EntityFrameworkCore layer (see Entity Framework). The Hub
module extends ABP's IRepository<,> with IHubRepository<,>, adding audit-history queries and a
graph add/attach helper:
// modules/hub/src/Hub.Domain/Repositories/IHubRepository.cs
public interface IHubRepository<TEntity, TKey> : IRepository<TEntity, TKey>
where TEntity : class, IEntity<TKey>
{
Task<(int TotalCount, IReadOnlyList<EntityPropertyChange> Query)> GetPropertyChanges(/* ... */);
Task AddOrAttach(HubBaseGuidEntity entity, CancellationToken ct = default);
}
Specialized repos such as IShipmentBaseRepository and IOrganizationRepository add
...WithDetailsAsync loaders and association-tree loading. See Repositories.
Where this codebase departs from textbook DDD¶
These are the load-bearing deviations. Know them before you reason about invariants, equality, or where business rules are enforced.
Aggregates are anemic with public setters
Hub entities expose { get; set; } on virtually all properties — including state and navigation
collections (initialized to []). Invariants are not enforced at the entity boundary;
mutation happens in application services. The host
ApiKey aggregate is the counter-example that follows stricter DDD — protected internal set
with Check.NotNullOrWhiteSpace(...) 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
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) { /* ... */ }
}
No domain events are raised from entities
A repo-wide search for AddLocalEvent / AddDistributedEvent finds nothing in any Domain
project. Aggregates never raise local
or distributed
events; the Domain only handles them. The one custom event flow is a subscriber:
Pricing.Domain/EventHandler/CommentStatusChangedEventHandler reacts to
CommentExtraPropertiesUpdatedEto and recomputes Quotation.Status. Do not expect
AggregateRoot event collections to be populated here.
Pricing is hollow — the pricing domain lives in Hub
Quotation, Margin, Offer, Place, etc. are under
modules/hub/src/Hub.Domain/Entities/Pricing/, and pricing enums under
modules/hub/src/Hub.Domain.Shared/Enums/Pricing/. Pricing.Domain contains only settings/error
stubs, one validator (QuotationCommentValidator) and one event handler. Quotation itself is a
CustomerOwnedGuidEntity, IUserOwned, ISoftDelete, ISearchable<Quotation> — it lives in the
Hub.Entities.Pricing namespace, and pricing data is accessed through the generic
IHubRepository<Quotation, Guid> (the Pricing module defines no repository interface). See the
Pricing module page.
CodeEntity equality is mode-dependent
CodeEntity (master/reference data) uses reference equality for unfrozen instances but
Id equality once an instance is frozen (frozen instances typically come from the Hub
second-level cache). Meanwhile GetHashCode() always returns
RuntimeHelpers.GetHashCode(this), so two "equal" frozen entities can have different hash codes —
risky as dictionary/set keys.
// modules/hub/src/Hub.Domain/Base/CodeEntity.cs
public bool Equals(CodeEntity? other)
{
if (other is null) return false;
if (!IsFrozen && !other.IsFrozen) return ReferenceEquals(this, other);
// Only if one of the codes is frozen (ie coming from a cache), we treat objects the same if their ids match
return Id == other.Id;
}
public override int GetHashCode() => RuntimeHelpers.GetHashCode(this);
Setting Code / Description / CargonerdsCustomerId on a frozen CodeEntity throws
InvalidOperationException via ThrowIfFrozen(...). Code entities also seed themselves into the
DB on startup via ISuperType<T>.Initialize + CodeEntity.InitializeSuperType<T> (matching
existing rows by Code).
All three *ErrorCodes classes are empty
HubErrorCodes, CargonerdsDomainErrorCodes, and PricingErrorCodes are placeholder classes
with only comments. Business errors are thrown as BusinessException("Hub:UnauthorizedOrganizationUnit")
or UserFriendlyException(...) with ad-hoc string codes instead of a central catalog. See
Error codes.
Heavy reliance on code generation
Much of the DTO / OData EDM / static-enum surface does not exist as hand-written source.
[AutoGenerateDto] (on HubBaseGuidEntity and value objects), [ProvideEdm], and [StaticEnum]
drive generators. A notable trap: StaticServiceType is referenced everywhere as
StaticServiceType.CW1, but no class declaration exists in the Domain projects — it is
generated from the [StaticEnum] ServiceType enum:
// modules/hub/src/Hub.Domain/Enums/ServiceType.cs
[AutoGenerateDto(GenerateMapping = false)]
[StaticEnum]
public enum ServiceType { CW1 = 1, Magaya = 2, HapagLloyd = 4, Rohlig = 5, /* ... */ }
Grepping for generated members in source will mislead you. See Code generation.
Two overlapping org-filter mechanisms
A hand-rolled IQueryFilter / QueryFilterBase<T> abstraction
(modules/hub/src/Hub.Domain/Filters/) coexists with the newer ABP
Global Query Filters + IDataFilter
approach. The two paths both scope queries by organization; know which one a given query uses
before changing filtering behaviour.
Cross-cutting domain concerns¶
- Settings —
HubSettings(GroupName = "Hub";Hub.CustomScript.Enabled/.Value) andCargonerdsSettingsare defined viaSettingDefinitionProviderclasses, several withisVisibleToClients: true.Pricingsettings are stubs. - Features — feature gating lives in
Hub.Domain.Shared/Features/HubFeatures.csand ties into ABP Features; see Authorization. - Multi-tenancy —
ApiKeyimplementsIMultiTenant; the broader tenancy model is described under ABP Multi-tenancy.
Explore the domain¶
- Entities — the
HubBaseGuidEntityhierarchy and marker interfaces - Aggregate Roots —
ShipmentBase/Shipment/Consol,ApiKey - Value Objects —
Coordinates,Dimension<TUnit>,Unit, code/master data - Domain Services —
ApiKeyManagerand the infrastructure helpers - Repositories —
IHubRepository<,>and the specialized repos