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

References


Whats next