Domain-Driven Design: The Complete Guide¶
You've heard the term. Maybe you've read a chapter of the blue book. But every time you try to apply DDD, it feels like you're just renaming things and calling it architecture.
The Problem DDD Was Built to Solve¶
Before diving into patterns and terms, let's understand the pain point.
Imagine you join a company with a five-year-old e-commerce monolith. There's a User class in three different modules. One means a customer, one means an admin, one means a seller. The Order table has 47 columns. The process_payment() function is 600 lines long and nobody dares touch it. This is the Big Ball of Mud — and it's the natural end state of software that grows without a shared understanding of the business.
The root cause isn't bad code. It's a gap between the people who understand the business and the people writing the code. Domain-Driven Design bridges that gap.
Eric Evans, Domain-Driven Design (2003)
"The heart of software is its ability to solve domain-related problems for its users. All other features, vital as they may be, support this basic purpose."
What Is Domain-Driven Design?¶
DDD is a software development philosophy — not a framework — that places the business domain at the center of all design decisions. It gives you:
- A shared language between developers and business experts
- Clear boundaries between different parts of your system
- Patterns for modeling complex business logic in code
DDD is organized into two layers of thinking:
| Layer | Focus | Question it answers |
|---|---|---|
| Strategic Design | The big picture — how your system is structured | What are we building and how do the pieces relate? |
| Tactical Design | The code level — how logic is implemented | How do we write the code inside each piece? |
Think of it this way: Strategic Design is the map; Tactical Design is how you navigate each road.
Part 1: Strategic Design¶
Strategic design is about understanding your business landscape before writing a single line of code. Most DDD projects fail because they skip this phase.
Step 1: Discover Your Domain¶
A domain is simply the subject area your software operates in. For a bank, the domain is banking. For an e-commerce company, it's online retail.
No domain is a monolith of equal parts. Every domain has areas that matter more than others. DDD splits them into three types:
┌─────────────────────────────────────┐
│ YOUR DOMAIN │
│ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ CORE │ │ SUPPORTING │ │
│ │ DOMAIN │ │ DOMAIN │ │
│ │ │ │ │ │
│ │ This is │ │ Enables your │ │
│ │ your │ │ core — but │ │
│ │ secret │ │ not unique │ │
│ │ sauce │ │ to you │ │
│ └──────────┘ └──────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ GENERIC DOMAIN │ │
│ │ │ │
│ │ Buy it, don't │ │
│ │ build it │ │
│ └─────────────────┘ │
└─────────────────────────────────────┘
Core Domain¶
The reason your company exists. This is your competitive advantage — the thing you do better than anyone else. You should invest heavily here and write custom software.
- Amazon: Recommendation engine + fulfillment logistics
- Netflix: Content recommendation algorithm
- Uber: Ride-matching and dynamic pricing
Never outsource your Core Domain
Buying a generic CRM and customizing it to be your core domain is a common and expensive mistake. If your competitors can buy the same software, it cannot be your differentiator.
Supporting Domain¶
Necessary but not differentiating. It supports your core, but it's not the reason customers choose you. Custom code may still be needed, but it's not where you invest your best engineers.
- An e-commerce company's inventory management is supporting — Amazon has it, Shopify has it, every retailer has it.
Generic Domain¶
Commodity functionality that's available off-the-shelf. You should buy, subscribe, or use open-source tools for this.
- Email delivery → SendGrid, SES
- Authentication → Auth0, Cognito
- Payments → Stripe, PayPal
Where to invest your team's attention
Maximize effort on Core, minimize effort on Generic (buy/outsource), and apply good-enough engineering to Supporting domains.
Step 2: Ubiquitous Language — One Language to Rule Them All¶
This is the most underrated concept in DDD. Before any code is written, your team and your business stakeholders need to agree on a shared vocabulary.
The problem is that the same word means different things to different people:
| Word | What the developer thinks | What the business analyst means |
|---|---|---|
User | A row in the users table | A person who logs in |
Account | A database record with credentials | A bank account with a balance |
Order | An entry in the orders table | A commercial intent to purchase goods |
Product | A row in the products table | A SKU, a catalog entry, or a physical item? |
Ubiquitous Language means: pick one term, define it precisely, and use it everywhere — in conversations, in code, in database columns, in API responses, in tickets, in emails.
# BAD: The word "User" means nothing specific
class User:
def process(self, item): # "process" what? who is "user"?
...
# GOOD: Ubiquitous Language makes intent clear
class Customer:
def place_order(self, cart: ShoppingCart) -> Order:
...
class Seller:
def list_product(self, product: Product) -> Listing:
...
How to build Ubiquitous Language
- Run a Domain Storytelling or Event Storming workshop (more on this later)
- Write a glossary — every term, one definition, agreed by both sides
- Code review for language violations — if a class name isn't in the glossary, push back
- Update the glossary as understanding evolves
Step 3: Bounded Contexts — Draw Your Boundaries¶
Here's the key insight: the same word can mean different things in different parts of your system — and that's okay, as long as you define the boundaries clearly.
A Bounded Context is a semantic boundary within which a particular model applies and a term has one precise meaning.
Think of it like countries and languages. "Billion" means 10⁹ in American English but 10¹² in British English (historically). Inside each country, there's no ambiguity. The boundary (the country border) is what makes the term unambiguous.
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ SALES BOUNDED CONTEXT │ │ SHIPPING BOUNDED CONTEXT │
│ │ │ │
│ Customer = person who │ │ Customer = delivery │
│ buys things │ │ address + name │
│ │ │ │
│ Order = intent to purchase │ │ Order = package to deliver │
│ │ │ │
│ Product = what we sell │ │ Product = physical item │
│ (price, desc) │ │ (weight, dims) │
└─────────────────────────────┘ └─────────────────────────────┘
Same words, different meanings, separated by clear boundaries. This is intentional, not a mistake.
How to Identify Bounded Contexts¶
Look for these signals in your business:
- Different teams using the same word to mean different things (language clue)
- Data that means something different in different departments (semantic clue)
- Parts of your system that rarely change together (coupling clue)
- Organizational boundaries — Conway's Law says your system will mirror your org structure
Conway's Law
"Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations." — Mel Conway, 1968
This means if your company has a Sales team and a Shipping team that barely talk to each other, your software should probably have a Sales bounded context and a Shipping bounded context. Fight Conway's Law and you'll fight your own architecture forever.
Bounded Context in Practice: E-Commerce Example¶
Let's use an e-commerce platform throughout this guide. Here are six bounded contexts a typical e-commerce company might have:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CATALOG │ │ ORDERS │ │ PAYMENTS │
│ │ │ │ │ │
│ Products │ │ Orders │ │ Transactions│
│ Categories │ │ Line Items │ │ Refunds │
│ Search │ │ Discounts │ │ Invoices │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ SHIPPING │ │ CUSTOMERS │ │ NOTIFICATIONS│
│ │ │ │ │ │
│ Shipments │ │ Profiles │ │ Emails │
│ Tracking │ │ Addresses │ │ SMS │
│ Carriers │ │ Preferences │ │ Push Alerts │
└─────────────┘ └─────────────┘ └─────────────┘
Each box is a bounded context. Each has its own model of the world. None share a database table with another (ideally).
Step 4: Context Maps — How Bounded Contexts Talk to Each Other¶
A Context Map documents the relationships between your bounded contexts. These aren't just technical relationships — they reflect political and organizational dynamics too.
There are nine classic context mapping patterns. Understanding them helps you make conscious integration decisions instead of just wiring things together.
Upstream vs Downstream¶
Before the patterns, understand direction:
- Upstream (U) — the provider. Changes here ripple downstream.
- Downstream (D) — the consumer. It adapts to what upstream provides.
[Orders] ──────────────────→ [Payments]
Upstream (U) Downstream (D)
Orders creates payment requests. Payments must adapt to Orders' model.
The Nine Context Mapping Patterns¶
Both teams succeed or fail together. They coordinate and evolve their interfaces together. No upstream/downstream — pure collaboration.
Use when: Two teams are tightly coupled and share the same release cadence.
A shared model owned by both teams. Changes to the shared code require both teams to agree.
Use when: Two contexts share concepts that are truly identical and you can afford the coordination cost.
# Shared kernel: a library both contexts import
# shared_kernel/money.py
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def add(self, other: 'Money') -> 'Money':
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
Warning
Shared Kernels create coupling. Use sparingly and keep the shared model minimal.
One team is the customer (downstream), the other is the supplier (upstream). The customer can request features; the supplier decides whether to build them. There's an explicit power dynamic, but also accountability.
Use when: One team provides services to another and you want formal communication channels.
The downstream simply conforms to the upstream's model — no translation. The downstream gives up autonomy. This happens when the upstream won't negotiate or adapt.
Use when: Integrating with a powerful third-party provider (like AWS or Salesforce) where you have no influence over their model.
The downstream translates the upstream model into its own model. The ACL protects your domain from external concepts bleeding in. This is the defensive pattern.
Use when: Integrating with a legacy system, external API, or upstream whose model is messy or incompatible with yours.
# Upstream: Legacy CRM with a terrible model
class LegacyCRMCustomer:
cust_no: str # this is actually our customer ID
f_name: str
l_name: str
addr1: str # actually a full address serialized as string
acct_bal: float # this is credit limit, not balance
# Anti-Corruption Layer: translates legacy → our model
class CustomerACL:
def __init__(self, crm_client):
self._crm = crm_client
def get_customer(self, customer_id: str) -> Customer:
raw = self._crm.fetch(customer_id)
return Customer(
id=CustomerId(raw.cust_no),
name=PersonName(first=raw.f_name, last=raw.l_name),
address=Address.parse(raw.addr1),
credit_limit=Money(Decimal(str(raw.acct_bal)), "USD")
)
The upstream publishes a well-defined, stable protocol for all consumers. It's like a public API with versioning and documentation. Multiple downstream contexts use it without requiring custom integrations.
Use when: Your context serves many other contexts and you want a stable integration point.
A well-documented, shared language (schema) used for communication between contexts. Often used together with Open Host Service. Examples: JSON Schema, Protobuf, AsyncAPI specs.
Two contexts have no integration. They solve similar problems independently. This sounds wasteful but is sometimes the right call when integration cost exceeds duplication cost.
Use when: The communication overhead of integration is higher than maintaining separate solutions.
The reality of many existing systems — no clear boundaries, everything talks to everything, no model to speak of. DDD acknowledges this as a pattern you might need to work around (using an ACL) while you slowly clean it up.
Part 2: Tactical Design¶
Now we get into the code. Tactical design gives you a toolkit of building blocks for modeling your domain. These patterns live inside a bounded context.
The Building Blocks¶
Inside a Bounded Context:
┌────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ (orchestrates, coordinates, no logic) │
├────────────────────────────────────────────────────────────┤
│ DOMAIN LAYER │
│ ┌──────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │Aggregates│ │ Entities │ │ Value Objects │ │
│ └──────────┘ └─────────────┘ └──────────────────────┘ │
│ ┌──────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ Services │ │ Factories │ │ Domain Events │ │
│ └──────────┘ └─────────────┘ └──────────────────────┘ │
├────────────────────────────────────────────────────────────┤
│ INFRASTRUCTURE LAYER │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Repositories (talk to databases) │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
Entities — Identity That Persists¶
An Entity is an object defined by its identity, not its attributes. Even if all its attributes change, it's still the same entity.
A person is an Entity. If you change your name, hair color, and address, you're still the same person — because identity persists.
from dataclasses import dataclass, field
from uuid import UUID, uuid4
@dataclass
class Customer:
id: UUID # ← identity is what matters
name: str
email: str
loyalty_points: int = 0
def earn_points(self, points: int) -> None:
if points <= 0:
raise ValueError("Points must be positive")
self.loyalty_points += points
def __eq__(self, other):
if not isinstance(other, Customer):
return False
return self.id == other.id # equality based on ID only
def __hash__(self):
return hash(self.id)
Key traits of Entities
- Have a unique identifier (UUID, sequential ID, etc.)
- Mutable — their state changes over time
- Two entities with the same ID are the same, regardless of other attributes
- Equality is based on identity, not attribute values
Value Objects — Identity-Free, Immutable, and Descriptive¶
A Value Object is an object defined entirely by its attributes. It has no identity. Two value objects with the same attributes are interchangeable.
Money is the classic example. $10.00 USD is interchangeable with any other $10.00 USD. You don't track which specific dollar bills you have.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True) # frozen=True makes it immutable
class Money:
amount: Decimal
currency: str
def __post_init__(self):
if self.amount < 0:
raise ValueError("Money amount cannot be negative")
if len(self.currency) != 3:
raise ValueError("Currency must be a 3-letter ISO code")
def add(self, other: 'Money') -> 'Money':
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def multiply(self, factor: Decimal) -> 'Money':
return Money(self.amount * factor, self.currency)
def __str__(self):
return f"{self.amount} {self.currency}"
@dataclass(frozen=True)
class Address:
street: str
city: str
postal_code: str
country_code: str
def is_domestic(self, home_country: str) -> bool:
return self.country_code == home_country
When to use Value Objects vs Entities
Ask: "Do I care which specific instance this is, or just what it represents?"
- Address on a letter: Value Object — any address with the same street/city/zip is interchangeable
- Customer's address record in a CRM that tracks address history: Entity — you care that it's this specific record that was updated on a specific date
Value Objects tend to be underused in practice. When in doubt, prefer Value Objects — they're simpler, safer (immutable), and easier to test.
Aggregates — The Consistency Boundary¶
This is the most powerful and most misunderstood tactical pattern.
An Aggregate is a cluster of Entities and Value Objects that are treated as a single unit for the purpose of data changes. Every aggregate has one root Entity called the Aggregate Root.
The rules: 1. All changes go through the Aggregate Root. External objects cannot directly modify inner entities. 2. The aggregate enforces all business invariants (rules that must always be true). 3. Each transaction modifies at most one aggregate.
Let's model an Order aggregate:
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from decimal import Decimal
from enum import Enum
from typing import List
class OrderStatus(Enum):
DRAFT = "draft"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class ProductId:
value: UUID
@dataclass(frozen=True)
class OrderLineItem: # Value Object inside the aggregate
product_id: ProductId
product_name: str
unit_price: Money
quantity: int
def __post_init__(self):
if self.quantity <= 0:
raise ValueError("Quantity must be positive")
@property
def subtotal(self) -> Money:
return self.unit_price.multiply(Decimal(self.quantity))
@dataclass
class Order: # Aggregate Root — this is the entry point for all changes
id: UUID
customer_id: UUID
status: OrderStatus
_line_items: List[OrderLineItem] = field(default_factory=list)
_events: List[object] = field(default_factory=list) # domain events
@classmethod
def create(cls, customer_id: UUID) -> 'Order':
order = cls(
id=uuid4(),
customer_id=customer_id,
status=OrderStatus.DRAFT,
)
order._events.append(OrderCreated(order_id=order.id, customer_id=customer_id))
return order
def add_item(self, product_id: ProductId, name: str, price: Money, qty: int) -> None:
# Business rule: can only modify draft orders
if self.status != OrderStatus.DRAFT:
raise ValueError(f"Cannot add items to an order in {self.status} status")
# Business rule: max 50 items per order
if len(self._line_items) >= 50:
raise ValueError("Cannot add more than 50 line items to an order")
line_item = OrderLineItem(
product_id=product_id,
product_name=name,
unit_price=price,
quantity=qty
)
self._line_items.append(line_item)
def confirm(self) -> None:
# Business rule: cannot confirm an empty order
if not self._line_items:
raise ValueError("Cannot confirm an order with no items")
if self.status != OrderStatus.DRAFT:
raise ValueError(f"Cannot confirm an order in {self.status} status")
self.status = OrderStatus.CONFIRMED
self._events.append(OrderConfirmed(order_id=self.id, total=self.total))
def cancel(self, reason: str) -> None:
if self.status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED):
raise ValueError("Cannot cancel an order that has already shipped")
self.status = OrderStatus.CANCELLED
self._events.append(OrderCancelled(order_id=self.id, reason=reason))
@property
def total(self) -> Money:
if not self._line_items:
return Money(Decimal("0"), "USD")
totals = [item.subtotal for item in self._line_items]
result = totals[0]
for t in totals[1:]:
result = result.add(t)
return result
def pull_events(self) -> List[object]:
events = list(self._events)
self._events.clear()
return events
The most common Aggregate mistake
Making aggregates too large. If your Order aggregate contains Customer, Products, Inventory, and Payments, you've created a giant locking bottleneck. A transaction on any of these blocks all the others.
Rule of thumb: Keep aggregates small. When in doubt, separate them and use domain events to communicate between them.
How to size your aggregates¶
Ask: "What is the minimum set of objects that must be consistent together?"
For an e-commerce order, if you change the quantity of one line item, you need the whole order to stay consistent (total must be recalculated, max items rule must be checked). So Order + LineItems is the right aggregate boundary.
You do NOT need Customer in the Order aggregate to enforce order rules. A customerId reference is enough.
Domain Events — Things That Happened¶
A Domain Event represents something meaningful that happened in the domain. It's always named in past tense: NounPastTense.
OrderConfirmedPaymentProcessedCustomerRegisteredShipmentDispatched
Domain events are powerful for decoupling — when one bounded context needs to react to something that happened in another, they communicate via events rather than direct calls.
from dataclasses import dataclass
from datetime import datetime, UTC
from uuid import UUID
@dataclass(frozen=True)
class DomainEvent:
occurred_at: datetime = field(default_factory=lambda: datetime.now(UTC))
@dataclass(frozen=True)
class OrderConfirmed(DomainEvent):
order_id: UUID
customer_id: UUID
total: Money
@dataclass(frozen=True)
class OrderCancelled(DomainEvent):
order_id: UUID
customer_id: UUID
reason: str
# Another bounded context listens:
class NotificationService:
def on_order_confirmed(self, event: OrderConfirmed) -> None:
# Send confirmation email
self._email_client.send_confirmation(
customer_id=event.customer_id,
order_id=event.order_id,
amount=str(event.total)
)
def on_order_cancelled(self, event: OrderCancelled) -> None:
# Send cancellation notification
self._email_client.send_cancellation(
customer_id=event.customer_id,
reason=event.reason
)
Event naming matters
- Use past tense — things that happened, not commands
- Be specific —
OrderConfirmednotOrderUpdated - Include enough data to react without extra queries — events should be self-contained
- Don't use events inside a single aggregate — that's just method calls in disguise
Repositories — The Persistence Abstraction¶
A Repository provides a collection-like interface for accessing aggregates. It hides the details of how aggregates are stored and retrieved. Your domain code should never know if data is in PostgreSQL, MongoDB, or an in-memory dict.
from abc import ABC, abstractmethod
from uuid import UUID
from typing import Optional
# Define the interface in the domain layer
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None:
...
@abstractmethod
def find_by_id(self, order_id: UUID) -> Optional[Order]:
...
@abstractmethod
def find_by_customer(self, customer_id: UUID) -> list[Order]:
...
# Implement in the infrastructure layer
class PostgresOrderRepository(OrderRepository):
def __init__(self, db_session):
self._session = db_session
def save(self, order: Order) -> None:
record = self._to_record(order)
self._session.merge(record)
self._session.flush()
# Publish domain events after saving
for event in order.pull_events():
self._event_bus.publish(event)
def find_by_id(self, order_id: UUID) -> Optional[Order]:
record = self._session.query(OrderRecord).filter_by(id=order_id).first()
if not record:
return None
return self._to_domain(record)
def _to_record(self, order: Order) -> 'OrderRecord':
# Map domain object → database record
...
def _to_domain(self, record: 'OrderRecord') -> Order:
# Map database record → domain object
...
# In tests: swap for an in-memory implementation
class InMemoryOrderRepository(OrderRepository):
def __init__(self):
self._store: dict[UUID, Order] = {}
def save(self, order: Order) -> None:
self._store[order.id] = order
def find_by_id(self, order_id: UUID) -> Optional[Order]:
return self._store.get(order_id)
def find_by_customer(self, customer_id: UUID) -> list[Order]:
return [o for o in self._store.values() if o.customer_id == customer_id]
Repository rules
- One repository per aggregate root, not per entity
- The repository interface belongs to the domain layer
- The implementation belongs to the infrastructure layer
- Never put business logic in a repository — it's pure data access
- Test your domain logic using the
InMemoryrepository — fast, no database required
Domain Services — Logic That Doesn't Belong to One Entity¶
Sometimes business logic naturally spans multiple aggregates or doesn't fit neatly inside any single entity. That's where Domain Services come in.
# This logic involves two aggregates: Inventory and Order
# It doesn't naturally belong to either one alone
class OrderFulfillmentService:
def __init__(
self,
order_repo: OrderRepository,
inventory_repo: InventoryRepository,
event_bus: EventBus
):
self._orders = order_repo
self._inventory = inventory_repo
self._event_bus = event_bus
def fulfill_order(self, order_id: UUID) -> None:
order = self._orders.find_by_id(order_id)
if not order:
raise OrderNotFoundError(order_id)
for item in order.line_items:
inventory = self._inventory.find_by_product(item.product_id)
if not inventory.can_fulfill(item.quantity):
raise InsufficientInventoryError(item.product_id)
# Reserve inventory for each item
for item in order.line_items:
inventory = self._inventory.find_by_product(item.product_id)
inventory.reserve(item.quantity)
self._inventory.save(inventory)
order.mark_as_processing()
self._orders.save(order)
Domain Services vs Application Services¶
This is a common point of confusion:
| Domain Service | Application Service | |
|---|---|---|
| Lives in | Domain layer | Application layer |
| Contains | Business logic | Orchestration logic |
| Knows about | Domain concepts | Use cases |
| Dependencies | Other domain objects | Repositories, domain services, external services |
| Testable without | Infrastructure | Infrastructure |
# Application Service: orchestrates, doesn't contain business logic
class PlaceOrderUseCase:
def __init__(
self,
order_repo: OrderRepository,
customer_repo: CustomerRepository,
fulfillment_service: OrderFulfillmentService,
notification_service: NotificationService
):
self._orders = order_repo
self._customers = customer_repo
self._fulfillment = fulfillment_service
self._notifications = notification_service
def execute(self, command: PlaceOrderCommand) -> UUID:
# Validate customer exists
customer = self._customers.find_by_id(command.customer_id)
if not customer:
raise CustomerNotFoundError(command.customer_id)
# Create order (domain logic)
order = Order.create(customer_id=command.customer_id)
for item in command.items:
order.add_item(
product_id=item.product_id,
name=item.product_name,
price=item.price,
qty=item.quantity
)
order.confirm()
# Persist
self._orders.save(order)
# Coordinate side effects
self._fulfillment.fulfill_order(order.id)
return order.id
Factories — Complex Object Creation¶
When creating an aggregate is complex (multiple validations, loading related data, establishing invariants), a Factory encapsulates that creation logic.
class OrderFactory:
def __init__(self, product_catalog: ProductCatalog):
self._catalog = product_catalog
def create_from_cart(self, customer_id: UUID, cart: ShoppingCart) -> Order:
if cart.is_empty():
raise ValueError("Cannot create an order from an empty cart")
order = Order.create(customer_id=customer_id)
for cart_item in cart.items:
# Enrich with current product data from catalog
product = self._catalog.find_by_id(cart_item.product_id)
if not product:
raise ProductNotFoundError(cart_item.product_id)
if not product.is_available():
raise ProductUnavailableError(cart_item.product_id)
order.add_item(
product_id=product.id,
name=product.name,
price=product.current_price, # use current price, not cached cart price
qty=cart_item.quantity
)
return order
Part 3: DDD in Action — Event Storming¶
Event Storming is a collaborative workshop technique invented by Alberto Brandolini that's the fastest way to discover domain events, commands, and aggregates. It's the practical entry point to DDD.
How Event Storming Works¶
You need: a long wall, sticky notes in 6 colors, and all the right people (developers, business analysts, domain experts, ops).
| Color | Represents | Example |
|---|---|---|
| 🟠 Orange | Domain Events (past tense) | Order Confirmed |
| 🔵 Blue | Commands (imperative) | Confirm Order |
| 🟡 Yellow | Aggregates (nouns) | Order |
| 🟣 Purple | Policies (when X, do Y) | When payment received → fulfill order |
| 🩷 Pink | External Systems | Stripe, FedEx API |
| 🟢 Green | Read Models / Views | Order Summary Page |
The Process (4 hours)¶
-
Chaotic exploration (45 min): Everyone writes domain events on orange stickies and puts them on the wall in any order. No rules, no judgment.
-
Timeline (30 min): Sort the events into chronological order. Discover conflicts and missing events.
-
Commands and Actors (30 min): For each event, add the blue command that caused it and who/what triggered it.
-
Aggregates (30 min): Group related commands and events under yellow aggregate stickies.
-
Bounded Contexts (30 min): Draw boundaries around groups of aggregates that belong together.
-
Policies and External Systems (45 min): Add purple policies (automation rules) and pink external systems.
The result is a visual, shared understanding of your domain that becomes the foundation for your architecture.
Event Storming output for our e-commerce example
[Add to Cart] → CartItemAdded → [Checkout Initiated] → CheckoutStarted
↓
[Enter Payment] → PaymentSubmitted → [STRIPE] → PaymentProcessed
↓
→ OrderConfirmed → [Notify Customer] → ConfirmationEmailSent
↓
→ [Warehouse System] → InventoryReserved → ShipmentScheduled → OrderShipped
↓
→ TrackingCodeIssued → DeliveryConfirmed → OrderCompleted
DDD and Microservices — The Natural Fit¶
Bounded Contexts and microservices are not the same thing, but they map naturally to each other:
One Bounded Context → One Microservice (or one service, or one module — depending on your deployment model)
This is why DDD is essential for microservices architecture. Without bounded contexts, microservices become a distributed monolith — all the complexity of distribution, none of the isolation benefits.
┌──────────────────────────────────────────────────────────────┐
│ API GATEWAY │
└──────────────────────────────────────────────────────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐
│ Catalog │ │ Orders │ │Payments │ │Shipping │ │Customers │
│ Service │ │ Service │ │ Service │ │ Service │ │ Service │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └───────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐
│ Catalog │ │ Orders │ │Payments │ │Shipping │ │Customers │
│ DB │ │ DB │ │ DB │ │ DB │ │ DB │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └───────────┘
│
┌───────────────┐
│ Event Bus │
│ (Kafka / │
│ RabbitMQ) │
└───────────────┘
The Rules for DDD Microservices¶
- Each service owns its database. No sharing. Never direct SQL across service boundaries.
- Communicate via events, not direct calls (where possible). This preserves autonomy.
- Each service exposes an API (REST, gRPC) for synchronous reads. Events for async state changes.
- The service boundary = the bounded context boundary. One team owns one context owns one service.
Common Anti-Patterns to Avoid¶
The Anemic Domain Model¶
The most common DDD anti-pattern. Your domain objects are just data containers with getters and setters, and all the logic lives in service classes.
# ANEMIC — this is just a data bag, not a domain model
@dataclass
class Order:
id: UUID
status: str
items: list
total: Decimal
# Logic lives elsewhere — hard to find, easy to duplicate
class OrderService:
def confirm_order(self, order: Order):
order.status = "confirmed" # no validation, no invariants enforced
def cancel_order(self, order: Order, reason: str):
order.status = "cancelled" # anyone can cancel any order, any time
def add_item(self, order: Order, item: dict):
order.items.append(item) # no limit, no validation
order.total += item['price'] * item['quantity']
# RICH DOMAIN MODEL — logic lives where it belongs
@dataclass
class Order:
id: UUID
status: OrderStatus
_items: list[OrderLineItem]
def confirm(self) -> None:
if not self._items:
raise EmptyOrderError()
if self.status != OrderStatus.DRAFT:
raise InvalidStateTransitionError(self.status, OrderStatus.CONFIRMED)
self.status = OrderStatus.CONFIRMED
def cancel(self, reason: str) -> None:
if self.status.is_terminal():
raise InvalidStateTransitionError(...)
self.status = OrderStatus.CANCELLED
The God Aggregate¶
An aggregate that contains everything. If your User aggregate holds addresses, payment methods, orders, preferences, and loyalty points, you'll have concurrency nightmares and massive objects that are slow to load.
Split it. Keep aggregates small and focused.
Leaking Domain Logic into the Application Layer¶
The application layer should orchestrate, not decide. If you see business rules (discounts, eligibility checks, validation) in your use case classes, it belongs in the domain layer.
Overusing Domain Events Internally¶
Domain events are for cross-aggregate and cross-context communication. Inside a single aggregate, just call methods directly. Don't make a CustomerNameChanged event that the same aggregate handles internally — that's just complicated method calls.
When Should You Use DDD?¶
DDD is powerful but comes with a learning curve and upfront cost. It's not always the right tool.
Use DDD when:¶
- Your domain is genuinely complex — lots of business rules, edge cases, and workflows
- The domain is the core of your competitive advantage
- You have (or can get) domain experts who are willing to collaborate
- Your system needs to evolve over years, not months
- Multiple teams need to work on the system independently
Skip DDD (or use a lighter version) when:¶
- You're building a simple CRUD application
- The domain is trivial and well-understood
- You're under extreme time pressure and complexity is low
- Your team is small and everyone already has a shared mental model
- It's a short-lived project or prototype
Start with Strategic Design only
You don't have to adopt all tactical patterns on day one. Start with the collaborative domain discovery (Event Storming), establish bounded contexts and ubiquitous language, then gradually introduce tactical patterns where complexity warrants it.
DDD Quick Reference Cheat Sheet¶
Strategic Design¶
| Concept | Definition | One-liner |
|---|---|---|
| Domain | The subject area | What your business does |
| Core Domain | Your competitive advantage | Build and invest here |
| Supporting Domain | Enables core, not unique | Build good enough |
| Generic Domain | Standard commodity | Buy or use open source |
| Ubiquitous Language | Shared vocabulary | One term, one meaning |
| Bounded Context | Semantic boundary | Where a model is valid |
| Context Map | Relationships between contexts | Who talks to whom, and how |
Context Mapping Patterns¶
| Pattern | Relationship | When to use |
|---|---|---|
| Partnership | Equal collaboration | Tightly coupled teams |
| Shared Kernel | Shared code, mutual agreement | Small shared model |
| Customer-Supplier | Formal upstream/downstream | Different teams, different cadences |
| Conformist | Downstream adapts fully | Powerful upstream you can't influence |
| Anti-Corruption Layer | Downstream translates | Legacy or messy upstream |
| Open Host Service | Upstream publishes API | One-to-many integration |
| Published Language | Shared schema/format | Standard protocol for events/APIs |
| Separate Ways | No integration | Cost of integration > benefit |
| Big Ball of Mud | No clear model | Legacy reality — work around it |
Tactical Design Building Blocks¶
| Building Block | Identity? | Mutable? | Purpose |
|---|---|---|---|
| Entity | Yes (has ID) | Yes | Things that have a lifecycle and identity |
| Value Object | No | No (immutable) | Descriptive attributes |
| Aggregate | Yes (root ID) | Yes | Consistency boundary |
| Domain Event | N/A | No | Something that happened |
| Repository | N/A | N/A | Persistence abstraction |
| Domain Service | N/A | N/A | Logic spanning multiple objects |
| Factory | N/A | N/A | Complex object creation |
| Application Service | N/A | N/A | Use case orchestration |
Further Reading¶
DDD is a deep subject. These resources will take you from practitioner to expert:
| Resource | Type | Best for |
|---|---|---|
| Domain-Driven Design by Eric Evans | Book | The original — dense but definitive |
| Implementing Domain-Driven Design by Vaughn Vernon | Book | Practical implementation, better code examples |
| Learning Domain-Driven Design by Vlad Khononov | Book | Modern, accessible entry point — start here if you're new |
| Domain Storytelling by Hofer & Schwentner | Book | Collaborative domain discovery technique |
| ddd-crew/ddd-starter-modelling-process | GitHub | Step-by-step process guide |
| EventStorming.com | Web | Alberto Brandolini's canonical resource |
Summary¶
Domain-Driven Design is ultimately about one thing: closing the gap between the people who understand the business and the people building the software.
The path looks like this:
- Discover your domain through collaborative workshops (Event Storming)
- Classify your subdomains — invest appropriately in Core, Supporting, and Generic
- Establish Ubiquitous Language — shared vocabulary, no ambiguity
- Define Bounded Contexts — where models are valid, teams are autonomous
- Map Context relationships — partnership, ACL, conformist, events
- Model your domain in code — rich Entities, immutable Value Objects, Aggregates that enforce invariants
- Communicate across boundaries via Domain Events
- Iterate — DDD is a conversation that never ends
The code is a model of your understanding. As your understanding of the domain deepens, your model should deepen with it. That's the essence of Domain-Driven Design.
Have questions or thoughts? Connect with me on LinkedIn or reach out via email.
Questions or discussion? Connect on LinkedIn, X or reach out via email.
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.