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()

Subdomains: where to invest

Not all of the domain deserves equal effort. DDD classifies subdomains so you spend modeling energy where it pays off:

  • Core domain — the part that differentiates the business (your competitive advantage). Invest your best people and rich modeling here.
  • Supporting subdomain — necessary but not differentiating; build it simply.
  • Generic subdomain — a solved problem (auth, billing, notifications); buy or use off-the-shelf, don’t hand-craft.

The mistake is lavishing DDD tactical patterns on a generic subdomain while under-modeling the core.

Context Mapping

Bounded contexts don’t live in isolation — context maps describe the relationships between them and how their models integrate:

  • Anti-Corruption Layer (ACL) — a translation layer that protects your model from a messy/legacy upstream one, converting their concepts into yours so their model can’t “leak” in. The single most important integration pattern when wrapping legacy or third-party systems.
  • Shared Kernel — two contexts share a small, jointly-owned model subset (high coupling; change requires both teams to agree).
  • Customer/Supplier — downstream context’s needs influence the upstream’s roadmap.
  • Conformist — downstream simply adopts the upstream model as-is (no ACL); cheap but you inherit their concepts.
  • Open Host Service / Published Language — upstream publishes a stable, well-documented API/schema (e.g. an event contract) for many consumers.

The map is a strategic deliverable: it tells you where to put ACLs, which integrations are risky (Shared Kernel), and where team coordination is required.

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.

Two design rules make aggregates work:

  • Reference other aggregates by ID, not by object reference. An Order holds a CustomerId, not a Customer object. This keeps each aggregate a small, independently-loadable consistency boundary (and avoids loading half the database to confirm one order).
  • One aggregate per transaction. A single transaction should modify exactly one aggregate instance; changes that span aggregates are made eventually consistent via sagas, not a big multi-aggregate transaction. The aggregate is the transactional consistency boundary.

Keep aggregates as small as invariants allow — a too-large aggregate (see Pitfalls) serializes unrelated updates and causes contention.

Domain Service

When a piece of behavior doesn’t naturally belong to any single Entity or Value Object — typically because it coordinates several aggregates or expresses a domain concept that isn’t a “thing” — put it in a Domain Service: a stateless object named in the ubiquitous language (e.g. FundsTransferService.Transfer(from, to, amount) where the logic belongs to neither account alone). Don’t confuse it with an application service (which orchestrates use cases, transactions, and I/O) — a domain service contains business rules and lives in the domain layer with no infrastructure dependencies. Reach for it sparingly; most behavior should still live on the aggregate that owns the data (Information Expert).

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

ApproachStrengthsWeaknessesWhen to use
Full DDD (Aggregates, Bounded Contexts)Rich domain model, enforces invariants, scales with complexityHigh upfront investment, overkill for simple domainsComplex business rules, multiple teams, long-lived systems
Transaction ScriptSimple, fast to writeLogic scattered in services, hard to maintain as complexity growsSimple CRUD, scripts, prototypes
Anemic model + servicesFamiliar to most developersNo invariant enforcement, business rules leak everywhereShort-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

References