Domain-Driven Design
Domain-Driven Design
Domain-Driven Design (DDD) is an approach to software development that centers the design on the business domain — its language, rules, and boundaries — rather than on technical infrastructure. The core idea: complex software fails not because of bad technology but because the code doesn't reflect how the business actually works. DDD provides a set of tactical patterns (Entities, Value Objects, Aggregates, Domain Events, Repositories) and strategic patterns (Bounded Contexts, Ubiquitous Language) to close that gap. In an insurance claims platform, introducing Bounded Contexts between Underwriting and Claims Processing eliminated a class of bugs where claim status updates silently overwrote underwriting decisions — the two contexts had different definitions of "approved" that a shared Policy model conflated.
DDD is most valuable in complex domains with rich business rules. For CRUD-heavy systems with little domain logic, the overhead is not justified.
Strategic Patterns
Ubiquitous Language
A shared vocabulary used by both developers and domain experts in conversations, code, and documentation. When the code uses the same terms as the business ("Order", "Shipment", "Fulfillment"), there is no translation layer where meaning gets lost.
In practice: if a domain expert says "an order is fulfilled when all line items are shipped," the code should have an Order with a Fulfill() method that checks LineItems.All(li => li.IsShipped) — not a ProcessOrderStatusUpdate() method that sets StatusId = 3.
Bounded Context
A Bounded Context is an explicit boundary within which a particular domain model applies. The same word can mean different things in different contexts: "Customer" in the Sales context (prospect, contact info, deal history) is different from "Customer" in the Billing context (payment method, invoice address, credit limit).
Each Bounded Context has its own model, its own database schema, and its own team ownership. Communication between contexts happens via well-defined contracts (events, APIs) — not shared database tables.
Sales Context Billing Context
───────────── ───────────────
Customer Customer
- Name - PaymentMethod
- ContactInfo - CreditLimit
- DealHistory - InvoiceAddress
OrderPlaced event ──→ BillingService.CreateInvoice()
Tactical Patterns
Entity
An object with a unique identity that persists over time. Two entities with the same data are still different objects if their IDs differ.
public sealed class Order
{
public OrderId Id { get; }
public CustomerId CustomerId { get; }
private readonly List<LineItem> _lineItems = new();
public Order(OrderId id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
}
public void AddItem(ProductId productId, int quantity, Money price)
{
_lineItems.Add(new LineItem(productId, quantity, price));
}
}
Value Object
An object defined entirely by its attributes, with no identity. Two Value Objects with the same data are equal. Value Objects are immutable.
public sealed record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Currency mismatch");
return new Money(Amount + other.Amount, Currency);
}
}
Money(10, "USD") equals Money(10, "USD") — no identity needed.
Aggregate
An Aggregate is a cluster of Entities and Value Objects treated as a single unit for data changes. The Aggregate Root is the only entry point — external code cannot modify internal objects directly.
public sealed class Order // Aggregate Root
{
private readonly List<LineItem> _lineItems = new();
public IReadOnlyList<LineItem> LineItems => _lineItems.AsReadOnly();
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public void Confirm()
{
if (!_lineItems.Any())
throw new DomainException("Cannot confirm an empty order");
Status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmed(Id, DateTimeOffset.UtcNow));
}
}
The Aggregate enforces invariants: you cannot confirm an empty order. External code calls order.Confirm() — it never sets order.Status directly.
Domain Events
Facts that something happened in the domain, published after a state change. Other parts of the system react without the originating aggregate knowing about them.
public sealed record OrderConfirmed(OrderId OrderId, DateTimeOffset OccurredAt)
: IDomainEvent;
Domain Events are raised inside the Aggregate and dispatched after the transaction commits (see Event-driven Development for the dispatch mechanism).
Repository
An abstraction over persistence that provides a collection-like interface for Aggregates. The domain layer depends on the IOrderRepository interface; the infrastructure layer provides the EF Core implementation.
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
See Repository & Unit of Work for the full pattern.
Pitfalls
Anemic Domain Model
What goes wrong: Entities are data bags with only getters/setters. All business logic lives in service classes. The domain model doesn't enforce invariants.
Why it happens: developers familiar with CRUD patterns treat domain objects as DTOs and put logic in "service" or "manager" classes.
Mitigation: business rules belong in the Aggregate. If you find yourself writing if (order.Status == OrderStatus.Draft) order.Status = OrderStatus.Confirmed; in a service, move that logic into order.Confirm().
Aggregate Boundaries Too Large
What goes wrong: one Aggregate contains dozens of Entities. Every operation loads the entire graph, causing performance problems and contention. A Customer Aggregate that included Orders, Addresses, PaymentMethods, and ActivityLog loaded 15MB of data on every update — a simple address change took 4 seconds and held a database lock that blocked concurrent writes to the same customer.
Why it happens: developers model "what belongs together conceptually" rather than "what must change together transactionally."
Mitigation: Aggregate boundaries should be defined by transactional consistency requirements, not conceptual grouping. If Order and Customer don't need to change in the same transaction, they should be separate Aggregates referenced by ID.
Tradeoffs
| Approach | Strengths | Weaknesses | When to use |
|---|---|---|---|
| Full DDD (Aggregates, Bounded Contexts) | Rich domain model, enforces invariants, scales with complexity | High upfront investment, overkill for simple domains | Complex business rules, multiple teams, long-lived systems |
| Transaction Script | Simple, fast to write | Logic scattered in services, hard to maintain as complexity grows | Simple CRUD, scripts, prototypes |
| Anemic model + services | Familiar to most developers | No invariant enforcement, business rules leak everywhere | Short-lived projects, simple domains |
Decision rule: apply DDD tactical patterns (Aggregates, Value Objects, Domain Events) when the domain has non-trivial invariants and multiple teams. Apply strategic patterns (Bounded Contexts) when the system is large enough that a single shared model becomes a coordination bottleneck. For simple CRUD, skip DDD — the overhead is not justified.
Questions
- Entity: has a unique identity that persists over time. Two entities with the same data are different if their IDs differ. Example:
OrderwithOrderId. - Value Object: defined entirely by its attributes, no identity. Two Value Objects with the same data are equal. Immutable. Example:
Money(10, "USD"). - Rule of thumb: if you care about which instance it is, it's an Entity. If you only care about what it contains, it's a Value Object.
- Tradeoff: Value Objects are simpler to reason about (immutable, no identity tracking) but require copying on mutation. Use them for concepts like Money, Address, DateRange, Coordinates.
- The Aggregate Root enforces all invariants for the cluster. If external code modifies a
LineItemdirectly, it bypasses theOrder's consistency checks. - Example: adding a line item after an order is confirmed should be rejected. If
LineItemis modified directly, theOrdernever gets a chance to enforce this rule. - Practical implication: repositories load and save entire Aggregates, not individual child entities. EF Core's change tracking makes this natural.
- Tradeoff: loading the full Aggregate for every operation can be expensive if the Aggregate is large. This is a signal that the Aggregate boundary is too wide.
References
- Domain-Driven Design (Martin Fowler) — Fowler's collection of DDD articles covering Aggregates, Bounded Contexts, and the Ubiquitous Language with practical examples.
- Domain-Driven Design: Tackling Complexity in the Heart of Software (Eric Evans) — the original DDD book; dense but authoritative. Read Part II (Building Blocks) for tactical patterns.
- Implementing Domain-Driven Design (Vaughn Vernon) — more practical than Evans; covers Aggregate design, Domain Events, and Bounded Context integration with code examples.
- CQRS.nu DDD FAQ — concise Q&A on DDD concepts, Aggregates, and how DDD relates to CQRS and Event Sourcing.
- CQRS — architectural pattern that pairs naturally with DDD: commands map to Aggregate operations, queries bypass the domain model for read efficiency.