Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part IV: DDD DSL -- Domain Modeling

Overview

The DDD DSL is the backbone of the CMF. It provides attributes for declaring domain models following Domain-Driven Design patterns. From a handful of attributed partial classes, the source generator produces:

  • Entity implementations with backing fields and property accessors
  • Fluent builders with required property enforcement
  • Aggregate invariants via [Invariant] methods returning Result
  • EnsureInvariants() method injected into every command handler
  • EF Core DbContext, entity configurations, and repository implementations
  • CQRS command handlers, domain events, query projections, and sagas
  • REST API controllers and GraphQL schemaV

The developer writes the domain model. The compiler generates the infrastructure.


Core Attributes

AggregateRoot

[MetaConcept("AggregateRoot")]
[MetaInherits("Entity")]
[MetaConstraint("MustHaveId",
    "Properties.Any(p => p.IsAnnotatedWith('EntityId'))",
    Message = "Aggregate root must have an [EntityId] property")]
[MetaConstraint("MustHaveInvariant",
    "Methods.Any(m => m.IsAnnotatedWith('Invariant'))",
    Message = "Aggregate root should have at least one [Invariant] method",
    Severity = DiagnosticSeverity.Warning)]
public sealed class AggregateRootAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; set; }

    [MetaProperty("BoundedContext", "string")]
    public string? BoundedContext { get; set; }
}

Entity

[MetaConcept("Entity")]
[MetaConstraint("MustBeComposed",
    "IsReachableViaCompositionFrom('AggregateRoot')",
    Message = "Entity must be reachable via [Composition] from an aggregate root")]
public sealed class EntityAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; set; }
}

ValueObject

[MetaConcept("ValueObject")]
[MetaConstraint("MustBeOwned",
    "IsReferencedBy('Composition')",
    Message = "Value object must be owned via [Composition]")]
public sealed class ValueObjectAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; set; }
}

EntityId

[MetaConcept("EntityId")]
public sealed class EntityIdAttribute : Attribute { }

Property

[MetaConcept("Property")]
public sealed class PropertyAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; set; }

    [MetaProperty("Required", "bool")]
    public bool Required { get; set; } = false;

    [MetaProperty("MaxLength", "int")]
    public int? MaxLength { get; set; }
}

Relationship Semantics

The DDD DSL defines three relationship types, each with distinct semantics that drive EF Core generation:

Composition (Ownership)

[MetaConcept("Composition")]
[MetaReference("Target", "Entity", IsContainment = true)]
public sealed class CompositionAttribute : Attribute { }

Semantics: The parent owns the child. The child's lifecycle is controlled by the parent. Deleting the parent deletes the child. The child cannot exist without the parent.

Aggregate rule: Composition defines the aggregate boundary. All entities reachable via Composition from the AggregateRoot belong to that aggregate.

Association (Cross-Aggregate Reference)

[MetaConcept("Association")]
[MetaReference("Target", "Entity", IsContainment = false)]
public sealed class AssociationAttribute : Attribute { }

Semantics: The source references the target but does not own it. Deleting the source does not delete the target. Cross-aggregate references must use Association, not Composition.

Consistency rule: Cross-aggregate associations are eventually consistent -- changes are propagated via domain events, not within a single transaction.

Aggregation (Weak Ownership)

[MetaConcept("Aggregation")]
[MetaReference("Target", "Entity", IsContainment = false)]
public sealed class AggregationAttribute : Attribute { }

Semantics: The parent has a weak reference to the child. The child can exist independently. Deleting the parent sets the child's foreign key to null.

EF Core Mapping

Attribute On Type EF Core Generated Delete Behavior
[Composition] on T (ValueObject) Owned type OwnsOne<T>() Embedded (cascade)
[Composition] on IReadOnlyList<T> (VO) Owned collection OwnsMany<T>() Embedded (cascade)
[Composition] on T (Entity) Required 1:1 HasOne<T>().IsRequired().OnDelete(Cascade) Cascade
[Composition] on IReadOnlyList<T> (Entity) 1:N HasMany<T>().OnDelete(Cascade) Cascade
[Association] on T Optional 1:1 HasOne<T>().OnDelete(SetNull) SetNull
[Association] on IReadOnlyList<T> M:N Join table SetNull
[Aggregation] on T Optional 1:1 HasOne<T>().OnDelete(SetNull) SetNull
Diagram

Complete Example: The Order Aggregate

What the Developer Writes

namespace MyStore.Lib.Ordering;

[AggregateRoot("Order", BoundedContext = "Ordering")]
public partial class Order
{
    [EntityId]
    public partial OrderId Id { get; }

    [Property("OrderDate", Required = true)]
    public partial DateTime OrderDate { get; }

    [Property("Status", Required = true)]
    public partial OrderStatus Status { get; }

    [Composition]
    public partial IReadOnlyList<OrderLine> Lines { get; }

    [Composition]
    public partial PaymentDetails Payment { get; }

    [Composition]
    public partial ShippingAddress ShippingAddress { get; }

    [Association]
    public partial CustomerId CustomerId { get; }

    // ── Invariants ──────────────────────────────────

    [Invariant("Order must have at least one line item")]
    private Result HasLines()
        => Lines.Count > 0
            ? Result.Success()
            : Result.Failure("Order must have at least one line item");

    [Invariant("Order total must match sum of line totals")]
    private Result TotalMatchesLines()
    {
        var expected = Lines.Sum(l => l.UnitPrice.Amount * l.Quantity);
        var actual = Lines.Sum(l => l.LineTotal.Amount);
        return actual == expected
            ? Result.Success()
            : Result.Failure($"Line totals ({actual}) do not match computed sum ({expected})");
    }

    [Invariant("Order total must be positive")]
    private Result TotalIsPositive()
    {
        var total = Lines.Sum(l => l.LineTotal.Amount);
        return total > 0
            ? Result.Success()
            : Result.Failure($"Order total ({total}) is not positive");
    }

    [Invariant("Shipped orders must have a shipping address")]
    private Result ShippedOrdersHaveAddress()
    {
        if (Status != OrderStatus.Shipped)
            return Result.Success(); // Not applicable for non-shipped orders

        return ShippingAddress is not null
            ? Result.Success()
            : Result.Failure("Shipped order must have a shipping address");
    }
}

[Entity("OrderLine")]
public partial class OrderLine
{
    [EntityId]
    public partial OrderLineId Id { get; }

    [Property("ProductName", Required = true, MaxLength = 200)]
    public partial string ProductName { get; }

    [Property("Quantity", Required = true)]
    public partial int Quantity { get; }

    [Composition]
    public partial Money UnitPrice { get; }

    [Composition]
    public partial Money LineTotal { get; }
}

[Entity("PaymentDetails")]
public partial class PaymentDetails
{
    [EntityId]
    public partial PaymentId Id { get; }

    [Property("Method", Required = true)]
    public partial PaymentMethod Method { get; }

    [Composition]
    public partial Money Amount { get; }

    [Property("TransactionId")]
    public partial string? TransactionId { get; }
}

[ValueObject("Money")]
public partial class Money
{
    [ValueComponent("Amount", "decimal", Required = true)]
    public partial decimal Amount { get; }

    [ValueComponent("Currency", "string", Required = true)]
    public partial string Currency { get; }
}

[ValueObject("ShippingAddress")]
public partial class ShippingAddress
{
    [ValueComponent("Street", "string", Required = true)]
    public partial string Street { get; }

    [ValueComponent("City", "string", Required = true)]
    public partial string City { get; }

    [ValueComponent("PostalCode", "string", Required = true)]
    public partial string PostalCode { get; }

    [ValueComponent("Country", "string", Required = true)]
    public partial string Country { get; }
}

public enum OrderStatus { Draft, Placed, Paid, Shipped, Delivered, Cancelled }
public enum PaymentMethod { CreditCard, BankTransfer, PayPal }

What the developer writes: ~120 lines of attributed partial classes.

What the Compiler Generates

1. Entity Implementations (Stage 2)

// Generated: Order.g.cs
public partial class Order
{
    private OrderId _id;
    private DateTime _orderDate;
    private OrderStatus _status;
    private readonly List<OrderLine> _lines = new();
    private PaymentDetails? _payment;
    private ShippingAddress? _shippingAddress;
    private CustomerId? _customerId;

    public partial OrderId Id => _id;
    public partial DateTime OrderDate => _orderDate;
    public partial OrderStatus Status => _status;
    public partial IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
    public partial PaymentDetails Payment => _payment!;
    public partial ShippingAddress ShippingAddress => _shippingAddress!;
    public partial CustomerId CustomerId => _customerId!;

    // Domain event collection
    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
    public void ClearDomainEvents() => _domainEvents.Clear();
    protected void RaiseDomainEvent(IDomainEvent @event) => _domainEvents.Add(@event);
}

2. Invariant Enforcement (Stage 2)

// Generated: Order.Invariants.g.cs
public partial class Order
{
    /// <summary>
    /// Calls all [Invariant] methods and aggregates their Results.
    /// Injected into every command handler after state mutation.
    /// </summary>
    public Result EnsureInvariants()
    {
        return Result.Aggregate(
            HasLines(),
            TotalMatchesLines(),
            TotalIsPositive(),
            ShippedOrdersHaveAddress()
        );
    }
}

3. Fluent Builder (Stage 2)

// Generated: OrderBuilder.g.cs
public class OrderBuilder
{
    private DateTime _orderDate;
    private OrderStatus _status = OrderStatus.Draft;
    private readonly List<OrderLine> _lines = new();
    private PaymentDetails? _payment;
    private ShippingAddress? _shippingAddress;
    private CustomerId? _customerId;

    public OrderBuilder WithOrderDate(DateTime date) { _orderDate = date; return this; }
    public OrderBuilder WithStatus(OrderStatus status) { _status = status; return this; }
    public OrderBuilder AddLine(OrderLine line) { _lines.Add(line); return this; }
    public OrderBuilder WithPayment(PaymentDetails payment) { _payment = payment; return this; }
    public OrderBuilder WithShippingAddress(ShippingAddress addr) { _shippingAddress = addr; return this; }
    public OrderBuilder WithCustomer(CustomerId id) { _customerId = id; return this; }

    public Result<Order> Build()
    {
        var order = new Order
        {
            _id = OrderId.New(),
            _orderDate = _orderDate,
            _status = _status,
            _payment = _payment,
            _shippingAddress = _shippingAddress,
            _customerId = _customerId
        };
        order._lines.AddRange(_lines);

        var invariantResult = order.EnsureInvariants();
        return invariantResult.IsSuccess
            ? Result<Order>.Success(order)
            : Result<Order>.Failure(invariantResult.Reason!);
    }
}

4. EF Core Configuration (Stage 2-3)

// Generated: OrderConfiguration.g.cs
public class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id)
            .HasConversion(id => id.Value, v => new OrderId(v));

        builder.Property(x => x.OrderDate).IsRequired();
        builder.Property(x => x.Status).IsRequired()
            .HasConversion<string>();

        // Composition: 1:N entity (cascade delete)
        builder.HasMany(x => x.Lines)
            .WithOne()
            .OnDelete(DeleteBehavior.Cascade);

        // Composition: 1:1 entity (cascade delete)
        builder.HasOne(x => x.Payment)
            .WithOne()
            .HasForeignKey<PaymentDetails>("OrderId")
            .IsRequired(false)
            .OnDelete(DeleteBehavior.Cascade);

        // Composition: owned value object (embedded columns)
        builder.OwnsOne(x => x.ShippingAddress, sa =>
        {
            sa.Property(a => a.Street).HasMaxLength(200).IsRequired();
            sa.Property(a => a.City).HasMaxLength(100).IsRequired();
            sa.Property(a => a.PostalCode).HasMaxLength(20).IsRequired();
            sa.Property(a => a.Country).HasMaxLength(100).IsRequired();
        });

        // Association: cross-aggregate (set null, no cascade)
        builder.Property(x => x.CustomerId)
            .HasConversion(id => id.Value, v => new CustomerId(v))
            .IsRequired(false);
    }
}

5. Repository (Stage 2-3)

// Generated: IOrderRepository.g.cs
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task SaveChangesAsync(CancellationToken ct = default);
}

// Generated: OrderRepository.g.cs
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;

    public OrderRepository(AppDbContext db) => _db = db;

    public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default)
        => await _db.Orders
            .Include(o => o.Lines)
            .Include(o => o.Payment)
            .FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken ct = default)
        => await _db.Orders.ToListAsync(ct);

    public async Task AddAsync(Order order, CancellationToken ct = default)
        => await _db.Orders.AddAsync(order, ct);

    public async Task SaveChangesAsync(CancellationToken ct = default)
        => await _db.SaveChangesAsync(ct);
}

CQRS: Commands, Events, Queries, Sagas

Command Definition

[Command("PlaceOrder", AggregateRoot = "Order")]
public partial record PlaceOrderCommand
{
    [Property("CustomerId", Required = true)]
    public partial CustomerId CustomerId { get; }

    [Property("Lines", Required = true)]
    public partial IReadOnlyList<OrderLineDto> Lines { get; }

    [Property("ShippingAddress", Required = true)]
    public partial ShippingAddress ShippingAddress { get; }
}

Generated Command Handler

// Generated: PlaceOrderCommandHandler.g.cs
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, Result<OrderId>>
{
    private readonly IOrderRepository _repository;

    public PlaceOrderCommandHandler(IOrderRepository repository)
        => _repository = repository;

    public async Task<Result<OrderId>> HandleAsync(
        PlaceOrderCommand command, CancellationToken ct = default)
    {
        // Build the aggregate
        var builder = new OrderBuilder()
            .WithOrderDate(DateTime.UtcNow)
            .WithCustomer(command.CustomerId)
            .WithShippingAddress(command.ShippingAddress);

        foreach (var line in command.Lines)
            builder.AddLine(line.ToOrderLine());

        // Builder calls EnsureInvariants()
        var buildResult = builder.Build();
        if (!buildResult.IsSuccess)
            return Result<OrderId>.Failure(buildResult.Reason!);

        var order = buildResult.Value;

        // Persist
        await _repository.AddAsync(order, ct);
        await _repository.SaveChangesAsync(ct);

        // Raise domain event
        order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, command.CustomerId));

        return Result<OrderId>.Success(order.Id);
    }
}

Key: The builder calls EnsureInvariants() before returning the Order. If any invariant fails, the command returns a failure Result. No invalid aggregate ever reaches the database.

Domain Event Definition

[DomainEvent("OrderPlaced", SourceAggregate = "Order")]
public partial record OrderPlacedEvent
{
    [Property("OrderId", Required = true)]
    public partial OrderId OrderId { get; }

    [Property("CustomerId", Required = true)]
    public partial CustomerId CustomerId { get; }
}

CQRS Flow

Diagram

Saga Definition

[Saga("OrderFulfillment")]
public partial class OrderFulfillmentSaga
{
    [SagaStep(1, Command = "ReserveInventory", CompensateWith = "ReleaseInventory")]
    public partial void Step1_ReserveInventory();

    [SagaStep(2, Command = "ProcessPayment", CompensateWith = "RefundPayment")]
    public partial void Step2_ProcessPayment();

    [SagaStep(3, Command = "ShipOrder", CompensateWith = "CancelShipment")]
    public partial void Step3_ShipOrder();
}

The generator produces a saga orchestrator that executes steps in order, compensating in reverse if any step fails.


BoundedContext and Anti-Corruption Layers

Declaring Bounded Contexts

[BoundedContext("Catalog", Description = "Product catalog and pricing")]
[BoundedContext("Ordering", Description = "Order processing and fulfillment")]
[BoundedContext("Identity", Description = "User authentication and authorization")]

Context Mapping

[MappingContext("CatalogToOrdering", "Catalog", "Ordering",
    MappingType = ContextMappingType.AntiCorruptionLayer)]
public partial class CatalogToOrderingMapping
{
    [MapEntity("Product", "OrderItem",
        PropertyMappings = "Name -> ProductName, Price.Amount -> UnitPrice")]
    public partial OrderItem TranslateProduct(Product source);
}

Generated: CatalogToOrderingAdapter service that translates between context languages. Injected via DI. Used by cross-context command handlers.

Diagram

Compile-Time Validation (Stage 1)

The Stage 1 validator evaluates MetaConstraints and emits diagnostics:

Diagnostic Severity Rule
DDD001 Error Aggregate root missing [EntityId] property
DDD002 Error Entity not reachable via [Composition] from any aggregate root
DDD003 Error [Composition] across aggregate boundaries (should use [Association])
DDD004 Error Value object not owned by any entity via [Composition]
DDD005 Error Command references non-existent aggregate root
DDD006 Error Domain event references non-existent source aggregate
DDD007 Warning Saga step references non-existent command
DDD100 Warning Aggregate root has zero [Invariant] methods
DDD101 Warning [Invariant] method does not return Result
DDD102 Error [Invariant] method is not private
// Build output:
error DDD001: Order is an [AggregateRoot] but has no [EntityId] propertyAdd [EntityId] to a property of type OrderId

error DDD002: PaymentDetails is an [Entity] but is not reachable via
  [Composition] from any [AggregateRoot]
  → Add [Composition] on Order pointing to PaymentDetails

warning DDD100: Order is an [AggregateRoot] with zero [Invariant] methods
  → Add [Invariant] methods that return Result to enforce domain rules

Summary

The DDD DSL transforms ~120 lines of attributed partial classes into:

Generated Artifact Lines (approx.) Stage
Entity implementations (backing fields, properties) 200 2
Fluent builders with invariant checking 150 2
EnsureInvariants() aggregate method 20 2
EF Core configurations (IEntityTypeConfiguration<T>) 150 2-3
Repository interfaces + implementations 100 2-3
CQRS command handlers 100 per command 2-3
Domain event records 30 per event 2
REST API controllers 200 per aggregate 3
GraphQL schema types 100 per aggregate 3
Total per aggregate ~1,000-2,000

The developer writes the domain model. The compiler writes the plumbing. The invariants ensure correctness. The analyzers catch violations before runtime.