Strategic DDD (the most important part):
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); }); |