Domain-Driven Design

Table of Contents

Overview

Domain-Driven Design (DDD) is a software development approach that centers the development on programming a domain model with a rich understanding of the processes and rules of a domain. First introduced by Eric Evans in his 2003 book "Domain-Driven Design: Tackling Complexity in the Heart of Software," DDD provides both strategic and tactical patterns for managing complex business domains in software systems.

The fundamental premise of DDD is that most software projects should focus primarily on the core domain and domain logic, basing complex designs on a model of the domain. This requires iterative collaboration between technical and domain experts to create a conceptual model that addresses particular domain problems.

Background

Historical Context

DDD emerged from the object-oriented programming community in the early 2000s as a response to the growing complexity of enterprise software systems. Eric Evans synthesized patterns from various sources including:

  • Object-oriented analysis and design (OOAD)
  • Pattern languages from Christopher Alexander
  • Enterprise application architecture patterns from Martin Fowler
  • Extreme Programming practices

The Problem DDD Solves

Traditional software development often suffers from:

  1. Translation Loss: Business requirements get distorted as they pass through multiple layers of interpretation
  2. Anemic Domain Models: Domain objects become mere data containers with business logic scattered elsewhere
  3. Big Ball of Mud: Systems evolve into tangled, unmaintainable codebases
  4. Misaligned Teams: Technical teams and business experts speak different languages

Key Concepts

Ubiquitous Language

The ubiquitous language is the foundational concept of DDD. It is a common, rigorous language between developers and users, shared by all team members to connect all activities of the team with the software.

"Use the model as the backbone of a language. Commit the team to exercising that language relentlessly in all communication within the team and in the code." – Eric Evans

Key principles:

  • Terms should have precise meanings with no ambiguity
  • The language should be used in code, documentation, and conversation
  • Changes to the language reflect changes to the model
  • Developers should push back on domain terms that don't make sense in the model

Strategic Design Patterns

Strategic patterns help manage large-scale system architecture and team boundaries.

Bounded Context

A bounded context is a explicit boundary within which a particular domain model is defined and applicable. Different bounded contexts can have different models of the same concept.

  +-------------------+     +-------------------+
  |   Sales Context   |     |  Shipping Context |
  |                   |     |                   |
  |  Customer:        |     |  Customer:        |
  |  - Name           |     |  - Address        |
  |  - Credit Limit   |     |  - Delivery Prefs |
  |  - Purchase Hist  |     |  - Contact Phone  |
  +-------------------+     +-------------------+

Context Mapping

Context maps show relationships between bounded contexts:

Pattern Description
Shared Kernel Two contexts share a subset of the domain model
Customer-Supplier Upstream context provides what downstream needs
Conformist Downstream conforms to upstream model
Anti-Corruption Layer Translation layer to protect from external model changes
Open Host Service Well-defined protocol for integration
Published Language Shared interchange language (e.g., XML schema, JSON schema)
Separate Ways No integration; contexts are completely independent
Big Ball of Mud Acknowledge that parts of the system have no clear model

Subdomains

Organizations have different types of subdomains:

  1. Core Domain: The competitive advantage; where the most investment should go
  2. Supporting Subdomain: Necessary for business but not a differentiator
  3. Generic Subdomain: Common problems solved by off-the-shelf solutions

Tactical Design Patterns

Tactical patterns provide building blocks for the domain model within a bounded context.

Entities

Entities are objects defined primarily by their identity rather than their attributes. They have a lifecycle and continuity of identity.

class Customer:
    """Entity: identified by customer_id across its lifecycle."""

    def __init__(self, customer_id: CustomerId, name: str, email: Email):
        self._id = customer_id
        self._name = name
        self._email = email
        self._orders: List[OrderId] = []

    @property
    def id(self) -> CustomerId:
        return self._id

    def change_email(self, new_email: Email) -> None:
        # Business rule: email change requires verification
        self._email = new_email
        self._email_verified = False

    def __eq__(self, other):
        if not isinstance(other, Customer):
            return False
        return self._id == other._id

    def __hash__(self):
        return hash(self._id)

Value Objects

Value objects are immutable objects defined entirely by their attributes. They have no identity and can be freely shared and compared by value.

from dataclasses import dataclass
from typing import Self

@dataclass(frozen=True)
class Money:
    """Value Object: immutable, compared by value."""

    amount: Decimal
    currency: str

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Amount cannot be negative")
        if len(self.currency) != 3:
            raise ValueError("Currency must be ISO 4217 code")

    def add(self, other: Self) -> Self:
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)

    def multiply(self, factor: Decimal) -> Self:
        return Money(self.amount * factor, self.currency)


@dataclass(frozen=True)
class Address:
    """Value Object: complete address as a single concept."""

    street: str
    city: str
    postal_code: str
    country: str

    def format_for_shipping(self) -> str:
        return f"{self.street}\n{self.city}, {self.postal_code}\n{self.country}"

Aggregates

Aggregates are clusters of domain objects treated as a single unit. Each aggregate has a root entity (the aggregate root) and a boundary.

Rules for aggregates:

  • External objects can only reference the aggregate root
  • Changes to objects inside the aggregate must go through the root
  • Only one aggregate should be modified per transaction
  • Aggregates enforce invariants within their boundary
class Order:
    """Aggregate Root: enforces invariants for the entire order."""

    def __init__(self, order_id: OrderId, customer_id: CustomerId):
        self._id = order_id
        self._customer_id = customer_id
        self._items: List[OrderLine] = []
        self._status = OrderStatus.DRAFT

    def add_item(self, product_id: ProductId, quantity: int, unit_price: Money) -> None:
        """Business logic protected by the aggregate root."""
        if self._status != OrderStatus.DRAFT:
            raise InvalidOperationError("Cannot modify confirmed order")

        existing = self._find_item(product_id)
        if existing:
            existing.increase_quantity(quantity)
        else:
            self._items.append(OrderLine(product_id, quantity, unit_price))

    def confirm(self) -> None:
        """Transition with business rules."""
        if not self._items:
            raise InvalidOperationError("Cannot confirm empty order")
        if self._total() > Money(Decimal("10000"), "USD"):
            raise InvalidOperationError("Orders over $10,000 require approval")
        self._status = OrderStatus.CONFIRMED

    def _total(self) -> Money:
        return sum((item.subtotal() for item in self._items), Money(Decimal("0"), "USD"))


class OrderLine:
    """Entity within the Order aggregate (not externally accessible)."""

    def __init__(self, product_id: ProductId, quantity: int, unit_price: Money):
        self._product_id = product_id
        self._quantity = quantity
        self._unit_price = unit_price

    def subtotal(self) -> Money:
        return self._unit_price.multiply(Decimal(self._quantity))

    def increase_quantity(self, amount: int) -> None:
        self._quantity += amount

Domain Events

Domain events represent something meaningful that happened in the domain. They are named in past tense and capture the intent of what occurred.

from dataclasses import dataclass
from datetime import datetime
from uuid import UUID

@dataclass(frozen=True)
class DomainEvent:
    """Base class for all domain events."""
    event_id: UUID
    occurred_at: datetime


@dataclass(frozen=True)
class OrderConfirmed(DomainEvent):
    """Event: order has been confirmed by customer."""
    order_id: OrderId
    customer_id: CustomerId
    total_amount: Money


@dataclass(frozen=True)
class PaymentReceived(DomainEvent):
    """Event: payment was successfully processed."""
    order_id: OrderId
    payment_id: PaymentId
    amount: Money
    method: PaymentMethod

Repositories

Repositories provide the illusion of an in-memory collection for aggregates. They abstract persistence concerns away from the domain model.

from abc import ABC, abstractmethod
from typing import Optional

class OrderRepository(ABC):
    """Repository interface: part of the domain layer."""

    @abstractmethod
    def find_by_id(self, order_id: OrderId) -> Optional[Order]:
        pass

    @abstractmethod
    def save(self, order: Order) -> None:
        pass

    @abstractmethod
    def find_by_customer(self, customer_id: CustomerId) -> List[Order]:
        pass


class SqlOrderRepository(OrderRepository):
    """Infrastructure implementation of the repository."""

    def __init__(self, session: Session):
        self._session = session

    def find_by_id(self, order_id: OrderId) -> Optional[Order]:
        # Reconstruct aggregate from persistence
        row = self._session.query(OrderModel).filter_by(id=order_id.value).first()
        if not row:
            return None
        return self._to_domain(row)

    def save(self, order: Order) -> None:
        # Persist aggregate state
        model = self._to_model(order)
        self._session.merge(model)

Domain Services

Domain services encapsulate domain logic that doesn't naturally fit within an entity or value object, often involving multiple aggregates.

class PricingService:
    """Domain Service: calculates prices across multiple aggregates."""

    def __init__(self,
                 discount_policy: DiscountPolicy,
                 tax_calculator: TaxCalculator):
        self._discount_policy = discount_policy
        self._tax_calculator = tax_calculator

    def calculate_order_total(self, order: Order, customer: Customer) -> Money:
        subtotal = order.subtotal()
        discount = self._discount_policy.calculate_discount(customer, subtotal)
        after_discount = subtotal.subtract(discount)
        tax = self._tax_calculator.calculate(after_discount, customer.shipping_address)
        return after_discount.add(tax)

Factories

Factories encapsulate complex object creation logic, ensuring aggregates are created in a valid state.

class OrderFactory:
    """Factory: encapsulates complex order creation."""

    def __init__(self, id_generator: IdGenerator):
        self._id_generator = id_generator

    def create_from_cart(self, cart: ShoppingCart, customer: Customer) -> Order:
        order_id = self._id_generator.generate_order_id()
        order = Order(order_id, customer.id)

        for item in cart.items:
            order.add_item(item.product_id, item.quantity, item.unit_price)

        return order

    def create_recurring_order(self,
                                subscription: Subscription,
                                customer: Customer) -> Order:
        order_id = self._id_generator.generate_order_id()
        order = Order(order_id, customer.id)
        order.mark_as_recurring(subscription.id)

        for product in subscription.products:
            order.add_item(product.id, product.quantity, product.current_price)

        return order

Implementation

Event Sourcing

Event sourcing stores the state of aggregates as a sequence of domain events rather than current state. The current state is derived by replaying events.

Benefits

  • Complete audit trail of all changes
  • Ability to reconstruct past states
  • Natural fit with DDD events
  • Enables temporal queries
  • Supports debugging through event replay

Implementation Pattern

from abc import ABC, abstractmethod
from typing import List

class EventSourcedAggregate(ABC):
    """Base class for event-sourced aggregates."""

    def __init__(self):
        self._uncommitted_events: List[DomainEvent] = []
        self._version = 0

    def apply(self, event: DomainEvent) -> None:
        """Apply an event and record it."""
        self._apply_event(event)
        self._uncommitted_events.append(event)

    @abstractmethod
    def _apply_event(self, event: DomainEvent) -> None:
        """Handle state change for the event."""
        pass

    def load_from_history(self, events: List[DomainEvent]) -> None:
        """Reconstitute aggregate from event stream."""
        for event in events:
            self._apply_event(event)
            self._version += 1

    def get_uncommitted_events(self) -> List[DomainEvent]:
        return list(self._uncommitted_events)

    def clear_uncommitted_events(self) -> None:
        self._uncommitted_events.clear()


class Order(EventSourcedAggregate):
    """Event-sourced Order aggregate."""

    def __init__(self, order_id: OrderId = None):
        super().__init__()
        self._id = order_id
        self._items = {}
        self._status = None

    def create(self, order_id: OrderId, customer_id: CustomerId) -> None:
        self.apply(OrderCreated(
            event_id=uuid4(),
            occurred_at=datetime.utcnow(),
            order_id=order_id,
            customer_id=customer_id
        ))

    def add_item(self, product_id: ProductId, quantity: int, price: Money) -> None:
        if self._status != OrderStatus.DRAFT:
            raise InvalidOperationError("Cannot modify non-draft order")
        self.apply(ItemAddedToOrder(
            event_id=uuid4(),
            occurred_at=datetime.utcnow(),
            order_id=self._id,
            product_id=product_id,
            quantity=quantity,
            unit_price=price
        ))

    def _apply_event(self, event: DomainEvent) -> None:
        match event:
            case OrderCreated():
                self._id = event.order_id
                self._customer_id = event.customer_id
                self._status = OrderStatus.DRAFT
            case ItemAddedToOrder():
                self._items[event.product_id] = OrderLine(
                    event.product_id,
                    event.quantity,
                    event.unit_price
                )
            case OrderConfirmed():
                self._status = OrderStatus.CONFIRMED

Event Store

class EventStore:
    """Append-only store for domain events."""

    def __init__(self, connection):
        self._connection = connection

    def append(self,
               aggregate_id: str,
               events: List[DomainEvent],
               expected_version: int) -> None:
        """Append events with optimistic concurrency."""
        current_version = self._get_version(aggregate_id)
        if current_version != expected_version:
            raise ConcurrencyError(
                f"Expected version {expected_version}, found {current_version}"
            )

        for i, event in enumerate(events):
            self._connection.execute(
                """
                INSERT INTO events (aggregate_id, version, event_type, data, occurred_at)
                VALUES (?, ?, ?, ?, ?)
                """,
                (aggregate_id, expected_version + i + 1,
                 type(event).__name__, serialize(event), event.occurred_at)
            )

    def get_events(self, aggregate_id: str) -> List[DomainEvent]:
        """Retrieve all events for an aggregate."""
        rows = self._connection.execute(
            "SELECT event_type, data FROM events WHERE aggregate_id = ? ORDER BY version",
            (aggregate_id,)
        ).fetchall()
        return [deserialize(row['event_type'], row['data']) for row in rows]

CQRS (Command Query Responsibility Segregation)

CQRS separates read and write operations into different models. The write model (command side) handles commands and enforces business rules, while the read model (query side) is optimized for queries.

Architecture

                    +-----------------+
                    |     Client      |
                    +--------+--------+
                             |
            +----------------+----------------+
            |                                 |
            v                                 v
    +-------+-------+                 +-------+-------+
    | Command Side  |                 |  Query Side   |
    +-------+-------+                 +-------+-------+
            |                                 |
            v                                 v
    +-------+-------+                 +-------+-------+
    | Write Model   |   --------->   | Read Model    |
    | (Aggregates)  |    Events      | (Projections) |
    +-------+-------+                 +-------+-------+
            |                                 |
            v                                 v
    +-------+-------+                 +-------+-------+
    | Event Store   |                 | Read Database |
    +---------------+                 +---------------+

Command Side

from dataclasses import dataclass

@dataclass(frozen=True)
class CreateOrderCommand:
    """Command to create a new order."""
    customer_id: str
    items: List[OrderItemDto]


class CreateOrderHandler:
    """Command handler: orchestrates the use case."""

    def __init__(self,
                 order_repository: OrderRepository,
                 customer_repository: CustomerRepository,
                 event_publisher: EventPublisher):
        self._orders = order_repository
        self._customers = customer_repository
        self._events = event_publisher

    def handle(self, command: CreateOrderCommand) -> OrderId:
        customer = self._customers.find_by_id(CustomerId(command.customer_id))
        if not customer:
            raise CustomerNotFoundError(command.customer_id)

        order = Order.create(customer.id)
        for item in command.items:
            order.add_item(ProductId(item.product_id), item.quantity)

        self._orders.save(order)
        self._events.publish(order.get_uncommitted_events())

        return order.id

Query Side (Projections)

class OrderSummaryProjection:
    """Read model projection: denormalized for fast queries."""

    def __init__(self, read_db: ReadDatabase):
        self._db = read_db

    def on_order_created(self, event: OrderCreated) -> None:
        self._db.execute(
            """
            INSERT INTO order_summaries
            (order_id, customer_id, status, created_at, total_amount, item_count)
            VALUES (?, ?, 'draft', ?, 0, 0)
            """,
            (event.order_id.value, event.customer_id.value, event.occurred_at)
        )

    def on_item_added(self, event: ItemAddedToOrder) -> None:
        self._db.execute(
            """
            UPDATE order_summaries
            SET item_count = item_count + 1,
                total_amount = total_amount + ?
            WHERE order_id = ?
            """,
            (event.quantity * event.unit_price.amount, event.order_id.value)
        )


class OrderQueryService:
    """Query service: reads from denormalized read model."""

    def __init__(self, read_db: ReadDatabase):
        self._db = read_db

    def get_customer_orders(self, customer_id: str) -> List[OrderSummaryDto]:
        rows = self._db.query(
            """
            SELECT order_id, status, created_at, total_amount, item_count
            FROM order_summaries
            WHERE customer_id = ?
            ORDER BY created_at DESC
            """,
            (customer_id,)
        )
        return [OrderSummaryDto(**row) for row in rows]

    def get_order_details(self, order_id: str) -> OrderDetailsDto:
        # Fetch from pre-computed read model
        pass

Layered Architecture

DDD typically uses a layered architecture to separate concerns:

+------------------------------------------+
|           Presentation Layer             |
|      (Controllers, Views, DTOs)          |
+------------------------------------------+
                    |
+------------------------------------------+
|           Application Layer              |
|   (Use Cases, Command/Query Handlers)    |
+------------------------------------------+
                    |
+------------------------------------------+
|             Domain Layer                 |
| (Entities, Value Objects, Aggregates,    |
|  Domain Services, Domain Events)         |
+------------------------------------------+
                    |
+------------------------------------------+
|          Infrastructure Layer            |
|  (Repositories Impl, External Services,  |
|   Persistence, Messaging)                |
+------------------------------------------+

Hexagonal Architecture (Ports and Adapters)

An alternative to layered architecture that works well with DDD:

                   +-------------------+
                   |    HTTP/REST      |
                   +--------+----------+
                            |
              +-------------+-------------+
              |                           |
   +----------+----------+     +----------+----------+
   |   CLI Adapter       |     |   Message Adapter   |
   +----------+----------+     +----------+----------+
              |                           |
              +-------------+-------------+
                            |
                    +-------+-------+
                    |  Application  |
                    |    (Ports)    |
                    +-------+-------+
                            |
                    +-------+-------+
                    |    Domain     |
                    +-------+-------+
                            |
              +-------------+-------------+
              |                           |
   +----------+----------+     +----------+----------+
   | Database Adapter    |     |  External API       |
   +---------------------+     +---------------------+

References

Books

  • Evans, Eric. "Domain-Driven Design: Tackling Complexity in the Heart of Software." Addison-Wesley, 2003.
  • Vernon, Vaughn. "Implementing Domain-Driven Design." Addison-Wesley, 2013.
  • Vernon, Vaughn. "Domain-Driven Design Distilled." Addison-Wesley, 2016.
  • Millett, Scott and Nick Tune. "Patterns, Principles, and Practices of Domain-Driven Design." Wrox, 2015.
  • Fowler, Martin. "Patterns of Enterprise Application Architecture." Addison-Wesley, 2002.

Related Patterns

  • Clean Architecture (Robert C. Martin)
  • Onion Architecture (Jeffrey Palermo)
  • Hexagonal Architecture / Ports and Adapters (Alistair Cockburn)
  • Event Storming (Alberto Brandolini)

Notes

When to Use DDD

DDD is most valuable when:

  • The domain is complex with many business rules
  • The project will have a long lifespan
  • Domain experts are available for collaboration
  • The team can commit to iterative modeling

DDD may be overkill when:

  • The domain is simple or well-understood
  • The project is primarily data-centric CRUD
  • Time constraints prevent proper modeling
  • No access to domain experts

Common Pitfalls

  1. Anemic Domain Model: Entities with only getters/setters and no behavior
  2. Aggregate Too Large: Trying to make everything consistent in one transaction
  3. Ignoring Bounded Contexts: Forcing one model to fit all contexts
  4. Over-engineering: Applying DDD patterns where simple CRUD would suffice
  5. Neglecting Ubiquitous Language: Technical jargon instead of domain terms

Integration with Modern Practices

  • Microservices: Bounded contexts map naturally to service boundaries
  • Event-Driven Architecture: Domain events enable loose coupling
  • Serverless: Command handlers can be deployed as functions
  • GraphQL: Can serve as a flexible query layer for read models

Author: Jason Walsh

j@wal.sh

Last Updated: 2026-01-11 11:00:47

build: 2026-01-11 18:33 | sha: eb805a8