Domain-Driven Design in .NET: aggregates, value objects and EF Core owned types in C#

.NET Architecture & Design Patterns November 13, 2026 csharp

Strategic DDD (the most important part):

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// ======================================================================
// Q: What is DDD? How do you apply it in .NET?
//
// Strategic DDD (the most important part):
// - Ubiquitous language: the code uses the business terms (Backorder, PriceList,
//   CreditLimit).
// - Bounded contexts: Ordering, Catalog, Pricing, Shipping. "Product" means
//   something different in each, and each context has its own model.
// - Anti-corruption layer towards the ERP / PIM (Q11): their model does not
//   enter ours.
// Tactical DDD:
// - Entity: has an identity (Id) and changes over time.
// - Value Object: no identity, immutable, equality by value, so a record in C#.
// - Aggregate / Aggregate Root: a group of objects with a single entry point
//   that protects the invariants. Lines change only through the Order. One
//   transaction changes one aggregate.
// - Domain events: OrderPlaced, and other parts of the system react (e.g.
//   outbox, Q10).
// - Repository: one per aggregate root (Q22).
// Layers (Clean / Onion Architecture): Domain (entities, value objects, events,
// with no dependencies) <- Application (use cases, interfaces such as
// IErpClient) <- Infrastructure (EF Core, HTTP clients) <- Api (controllers,
// Program.cs).
// An anemic domain model (only getters/setters, all logic in services) is the
// usual anti-pattern. And DDD is not needed for simple CRUD: it pays off where
// there are complex rules, such as B2B pricing, credit limits and partial
// deliveries.
// ======================================================================

public record Money(decimal Amount, string Currency)          // Value Object
{
    public static Money operator +(Money a, Money b) =>
        a.Currency == b.Currency ? new(a.Amount + b.Amount, a.Currency)
                                 : throw new DomainException("Currency mismatch");
}

public class Order                                            // Aggregate Root
{
    private readonly List<OrderLine> _lines = [];
    private readonly List<IDomainEvent> _events = [];
    private Order() { }                                       // for EF Core

    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public IReadOnlyCollection<IDomainEvent> Events => _events;

    public static Order Create(int customerId) => new() { CustomerId = customerId };  // factory method

    public void AddLine(string sku, int qty, Money unitPrice)
    {
        if (Status != OrderStatus.Draft) throw new DomainException("Order is not editable");
        if (qty <= 0)                    throw new DomainException("Quantity must be positive");
        if (_lines.Count >= 50)          throw new DomainException("ERP accepts max 50 lines");
        _lines.Add(new OrderLine(sku, qty, unitPrice));
    }

    public void Place()
    {
        if (_lines.Count == 0) throw new DomainException("Empty order");
        Status = OrderStatus.Placed;
        _events.Add(new OrderPlaced(Id, CustomerId));
    }
}

// EF Core mapping: no public setters, value objects as owned types
modelBuilder.Entity<Order>(b =>
{
    b.OwnsMany(o => o.Lines, l => l.OwnsOne(x => x.UnitPrice));
    b.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);  // backing field _lines
    b.Ignore(o => o.Events);
});