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:
- Translation Loss: Business requirements get distorted as they pass through multiple layers of interpretation
- Anemic Domain Models: Domain objects become mere data containers with business logic scattered elsewhere
- Big Ball of Mud: Systems evolve into tangled, unmaintainable codebases
- 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:
- Core Domain: The competitive advantage; where the most investment should go
- Supporting Subdomain: Necessary for business but not a differentiator
- 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.
Online Resources
- DDD Community: https://www.domainlanguage.com/
- Martin Fowler's Bliki: https://martinfowler.com/tags/domain%20driven%20design.html
- EventStorming: https://www.eventstorming.com/
- Awesome DDD: https://github.com/heynickc/awesome-ddd
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
- Anemic Domain Model: Entities with only getters/setters and no behavior
- Aggregate Too Large: Trying to make everything consistent in one transaction
- Ignoring Bounded Contexts: Forcing one model to fit all contexts
- Over-engineering: Applying DDD patterns where simple CRUD would suffice
- 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