ddd-tactical
DDD tactical design in .NET — aggregates and consistency boundaries, invariants, value objects, strongly-typed ids, domain events vs integration events, domain services, factories, repositories, and E
SKILL.md.claude/skills/ddd-tactical/SKILL.md
GitHubDDD Tactical Patterns
Aggregates — the decisions that matter
- An aggregate is a consistency boundary, not an object graph. It contains exactly what must be consistent in one transaction to protect an invariant.
- Small aggregates. Start with root + value objects; add child entities only when an invariant spans them (invoice lines belong to the invoice because totals/VAT must be consistent).
- Reference other aggregates by id (
PartnerId), never by navigation property. - One aggregate modified per transaction. Other aggregates react to domain events — in the same module synchronously in the same transaction only if the business requires atomicity; otherwise eventually.
- All changes go through methods named in business language (
Issue,Cancel,AddLine). No public setters. Constructors/factories produce only valid instances.
Designing an aggregate (procedure)
- List invariants as sentences: "An issued invoice cannot change its lines." "The sum of lines equals the total." "Numbers are sequential within a series and year."
- For each invariant, find the smallest set of data required to check it → that set is the aggregate.
- Invariants that need data from many instances (uniqueness, sequences) → enforce with a domain
service + database constraint, or model the collection as its own aggregate (
InvoiceSeries). - Model state as an explicit enum + guarded transitions; illegal transitions return errors.
- Decide which transitions raise domain events.
Building blocks
| Block | Rules |
|---|---|
| Value object | immutable record/readonly record struct; validated factory Create(...) → Result<T>; equality by value; carries behavior (Money.Add, VatRate.Apply) |
| Strongly-typed id | readonly record struct InvoiceId(Guid Value); generated with Guid.CreateVersion7(); EF value converter |
| Entity | identity within the aggregate; no public setters; only reachable through the root |
| Domain event | past tense, immutable, raised inside aggregate methods, contains ids + facts; module-internal |
| Integration event | lives in Contracts; primitives only; versioned; produced from domain events at the module boundary via outbox |
| Domain service | stateless rule needing several aggregates or external policy data passed in; no I/O inside |
| Factory | static method on the root or dedicated class when creation needs policies |
| Repository | only for aggregate roots, intent-revealing methods; skip it when the module uses a DbContext abstraction (decide once, per profile) |
| Domain error | static factory class per aggregate (InvoiceErrors.AlreadyIssued) returning Error with stable code |
Minimal aggregate shape (.NET)
public sealed class Invoice : AggregateRoot<InvoiceId>
{
private readonly List<InvoiceLine> _lines = [];
public IReadOnlyList<InvoiceLine> Lines => _lines;
public InvoiceStatus Status { get; private set; }
public Money Total { get; private set; }
private Invoice() { } // EF
public static Result<Invoice> Draft(PartnerId partner, Currency currency) { ... }
public Result AddLine(ProductId product, Quantity qty, Money unitPrice, VatRate vat)
{
if (Status != InvoiceStatus.Draft) return InvoiceErrors.NotEditable(Id);
_lines.Add(InvoiceLine.Create(product, qty, unitPrice, vat));
Total = _lines.Sum(l => l.Total, Total.Currency);
return Result.Success();
}
public Result Issue(InvoiceNumber number, DateTimeOffset now)
{
if (Status != InvoiceStatus.Draft) return InvoiceErrors.AlreadyIssued(Id);
if (_lines.Count == 0) return InvoiceErrors.Empty(Id);
Status = InvoiceStatus.Issued;
Raise(new InvoiceIssuedDomainEvent(Id, number, now));
return Result.Success();
}
}
EF Core mapping without polluting the domain
IEntityTypeConfiguration<T>in Infrastructure; backing fields viaNavigation(x => x.Lines).UsePropertyAccessMode(PropertyAccessMode.Field).- Value objects →
ComplexProperty(EF 8+) or owned types; ids → value converters (convention-based). - Concurrency →
rowversionshadow property on the root. - Domain events →
SaveChangesInterceptorcollects, dispatches in-module handlers, writes outbox rows in the same transaction.
Result vs exceptions
Expected business failures return Result/Error (profile error_model: result). Exceptions only for
programmer errors and infrastructure faults. Never throw to signal "invoice already issued".
Smells
- Handler contains
if (invoice.Status == ...)→ rule belongs in the aggregate. - Aggregate loads > ~100 child rows routinely → boundary too big; split or model as separate aggregate.
- Domain event handlers modifying the aggregate that raised the event → cycle; rethink boundaries.
- Aggregates injected with services → pass policies/values as method arguments instead.