Domain-Driven Design (DDD)

Learning Objectives

Use ubiquitous language, entities, value objects, aggregates and repositories.

Enforce invariants within aggregate boundaries; raise domain events.

Apply bounded contexts and context mapping; know when DDD is overkill.

Prerequisites: Modules 18, 25, 71–73.

Intuition and Definition

Domain-Driven Design (Eric Evans, 2003) aligns software with the business domain through close collaboration with domain experts.

Strategic design Domain / subdomains: core (competitive advantage), supporting, generic.

Ubiquitous language: one shared vocabulary in conversations, docs and code.

Bounded context: a boundary within which a model and its language are consistent ("Vehicle" means something different to Sales and to Service).

Context mapping: relationships between contexts (customer/supplier, anti-corruption layer, shared kernel, published language).

Tactical design Entity, value object (Module 18).

Aggregate: a cluster of objects treated as one unit for changes, with an aggregate root that enforces invariants; outside objects reference only the root (by id).

Repository: collection-like access to aggregates.

Domain service: domain logic that doesn't belong to one entity.

Domain event: something that happened that domain experts care about ("ServiceCompleted").

Transaction boundary: usually one aggregate per transaction; other aggregates update via events (eventual consistency).

Why It Matters

In complex domains, most bugs are misunderstandings. DDD makes the model explicit, keeps rules consistent, and gives large systems seams (contexts) along which teams and services can split.

Syntax and Language Notes

DDD is language-neutral; records/value types (C#, Java, Kotlin), dataclasses (Python) and immutable classes (C++) express value objects concisely.

Complete Example

An Order aggregate that protects its invariants and records domain events:

#include <iomanip>
#include <iostream>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>

struct Money { long long cents; };
struct OrderPlaced { std::string orderId; long long totalCents; };
struct OrderLine { std::string sku; int qty; Money unitPrice; };

class Order {                                                // aggregate root
    std::string id_;
    std::string customerId_;                                  // other aggregate referenced by id
    std::vector<OrderLine> lines_;
    bool placed_ = false;
    std::vector<OrderPlaced> events_;
    static constexpr int kMaxLines = 20;
    static constexpr long long kMaxTotalCents = 1'000'000;
public:
    Order(std::string id, std::string customer) : id_(std::move(id)), customerId_(std::move(customer)) {}

    void addLine(const std::string& sku, int qty, Money price) {      // all changes go through the root
        if (placed_) throw std::logic_error("order already placed");
        if (qty <= 0) throw std::invalid_argument("quantity must be positive");
        if (total().cents + qty * price.cents > kMaxTotalCents)           // check BEFORE changing anything
            throw std::domain_error("order tot
…
rejected: order total exceeds credit limit
rejected: order already placed
event OrderPlaced O-1 total 219.00

Step-by-Step Walkthrough

Every change goes through Order (the root); lines can't be modified from outside, so invariants (positive quantities, max lines, credit limit, no changes after placing) always hold.

Adding the same SKU merges into one line — a rule expressed in the ubiquitous language ("an order has one line per product").

The credit limit is checked against the *prospective* total before anything changes, so the rejected engine line leaves the order exactly as it was (strong exception guarantee) — the event's total contains only accepted lines.

place() records OrderPlaced; other aggregates (inventory, invoicing) react to the event in their own transactions.

classDiagram
  class Order {
    <<aggregate root>>
    +addLine(sku, qty, price)
    +place()
    +total() Money
  }
  Order *-- "1..20" OrderLine
  Order --> Customer : customerId only
  Order ..> OrderPlaced : raises

Common Mistakes

Huge aggregates (customer with all orders, all invoices) — contention and slow loads; keep aggregates small.

References to other aggregates by object instead of id.

Modifying several aggregates in one transaction routinely — use domain events and eventual consistency.

Anemic aggregates with public setters.

One model for the whole company — ignore bounded contexts and the model becomes contradictory.

DDD ceremony for CRUD — overkill when there's little domain logic.

Trade-offs and Alternatives

DDD pays off in complex, evolving domains with access to experts. For simple data-entry applications, transaction scripts or active-record styles are cheaper.

Real-World Use

Insurance, banking, logistics, e-commerce, and vehicle-fleet platforms (bounded contexts such as Sales, Service, Telematics, Billing — each with its own "Vehicle" model and anti-corruption layers between them).

Practice

Q1. (Easy) What is an aggregate root?

Answer: The single entity through which all changes to an aggregate happen and which enforces its invariants.

Q2. (Medium) Why reference other aggregates by id?

Answer: To keep aggregate boundaries (and transactions) small and independent, avoid loading large object graphs, and prevent changes to other aggregates bypassing their roots.

Q3. (Advanced) Why does addLine check the credit limit before merging or appending, instead of adding the line and then checking?

Answer: If it mutated first and threw afterwards, the rejected line would stay in the aggregate — the root would have broken its own invariant, and place() would publish a wrong total. Checking the prospective total first (or modifying a copy and swapping it in) gives the strong guarantee: a failed command leaves the aggregate unchanged.

Interview Question

Q1. "What's a bounded context, and why does it matter for microservices?"

Answer: A bounded context is an explicit boundary within which one domain model and its language apply consistently; the same word can mean different things in different contexts (a "Vehicle" in Sales has price and options, in Service it has maintenance history). Bounded contexts are natural service boundaries: each service owns its model and data, integrates with others via published contracts/events, and protects itself with anti-corruption layers. Splitting services along technical layers instead creates chatty, tightly coupled systems.

Quick Quiz

Q1. What is ubiquitous language?

Answer: A shared vocabulary used consistently by domain experts and in the code, within a bounded context.

Q2. How many aggregates should one transaction usually modify?

Answer: One.

Q3. What protects a context from a foreign model?

Answer: An anti-corruption layer (translation adapters).

Summary

Strategic DDD: subdomains, ubiquitous language, bounded contexts, context maps.

Tactical DDD: entities, value objects, small aggregates with roots, repositories, domain events.

Use it where domain complexity justifies it.

Related: Modules 18, 51, 72, 75, 76.