Skip to content

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&lt;Guid&gt;<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

  • SettingsHubSettings (GroupName = "Hub"; Hub.CustomScript.Enabled / .Value) and CargonerdsSettings are defined via SettingDefinitionProvider classes, several with isVisibleToClients: true. Pricing settings are stubs.
  • Features — feature gating lives in Hub.Domain.Shared/Features/HubFeatures.cs and ties into ABP Features; see Authorization.
  • Multi-tenancyApiKey implements IMultiTenant; the broader tenancy model is described under ABP Multi-tenancy.

Explore the domain

  • Entities — the HubBaseGuidEntity hierarchy and marker interfaces
  • Aggregate RootsShipmentBase/Shipment/Consol, ApiKey
  • Value ObjectsCoordinates, Dimension<TUnit>, Unit, code/master data
  • Domain ServicesApiKeyManager and the infrastructure helpers
  • RepositoriesIHubRepository<,> and the specialized repos