Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

Domain-Driven Design (HLD View)

Organizing complex systems around business domains, not technical layers

Imagine you’re building a school. You don’t organize it by “all the chairs in one room, all the desks in another.” Instead, you create:

  • Classrooms (for teaching)
  • Library (for books and studying)
  • Cafeteria (for food)
  • Gym (for sports)

Each area has everything it needs to do its job. That’s Domain-Driven Design - organizing software around what the business does, not how the tech works!

Traditional Approach (Technical):

Database Layer
Service Layer
API Layer

Problem: Business logic scattered across technical layers!

DDD Approach (Business):

Order Management Context
Payment Processing Context
Inventory Management Context
Customer Management Context

Benefit: Each context is self-contained with clear business responsibility!


A bounded context is a explicit boundary where a particular domain model applies.

Diagram

Key Insight: The word “Customer” means different things in different contexts! That’s OK and even desirable in DDD.

A shared language between developers and domain experts.

Bad (Technical Language):

Developer: "We need to persist the entity to the repository
using the ORM mapper."
Business: "What? I just want to save the order!"

Good (Ubiquitous Language):

Developer: "When a customer places an order, we reserve inventory."
Business: "Yes! And if payment fails, we release the reservation."
Developer: "Got it. Let me model that..."

In Code:

# BAD - Technical language
class DataManager:
def persist(self, entity):
self.db.insert(entity)
def retrieve(self, id):
return self.db.select(id)
# GOOD - Ubiquitous language
class OrderService:
def place_order(self, customer: Customer, items: list[Item]):
"""Customer places an order"""
order = Order(customer, items)
self._reserve_inventory(order.items)
return order
def cancel_order(self, order: Order):
"""Customer cancels their order"""
self._release_inventory_reservation(order.items)
order.status = OrderStatus.CANCELLED

An aggregate is a cluster of objects treated as a single unit for data changes.

Rules:

  1. One aggregate root (entry point)
  2. Maintain consistency within aggregate
  3. References between aggregates by ID only
  4. Transactions don’t cross aggregate boundaries
Diagram

Implementation:

from dataclasses import dataclass, field
from typing import List
from decimal import Decimal
@dataclass
class OrderLine:
"""Value object - part of aggregate"""
product_id: str
quantity: int
price: Decimal
def subtotal(self) -> Decimal:
return self.price * self.quantity
@dataclass
class Order:
"""Aggregate Root"""
id: str
customer_id: str # Reference by ID only!
lines: List[OrderLine] = field(default_factory=list)
status: str = "draft"
# All modifications go through aggregate root
def add_item(self, product_id: str, quantity: int, price: Decimal):
"""Business logic in aggregate"""
if self.status != "draft":
raise ValueError("Cannot modify confirmed order")
# Check if product already in order
for line in self.lines:
if line.product_id == product_id:
# Update existing line
line.quantity += quantity
return
# Add new line
self.lines.append(OrderLine(product_id, quantity, price))
def remove_item(self, product_id: str):
"""Business logic in aggregate"""
if self.status != "draft":
raise ValueError("Cannot modify confirmed order")
self.lines = [l for l in self.lines if l.product_id != product_id]
def calculate_total(self) -> Decimal:
"""Aggregate maintains its invariants"""
return sum(line.subtotal() for line in self.lines)
def confirm(self):
"""State transition"""
if not self.lines:
raise ValueError("Cannot confirm empty order")
if self.status != "draft":
raise ValueError("Order already confirmed")
self.status = "confirmed"
# Usage
order = Order(id="123", customer_id="cust-456")
order.add_item("prod-1", quantity=2, price=Decimal("19.99"))
order.add_item("prod-2", quantity=1, price=Decimal("49.99"))
total = order.calculate_total() # $89.97
order.confirm()
# ❌ DON'T modify order lines directly
# order.lines[0].quantity = 100 # Bypasses business logic!
# ✅ DO go through aggregate root
order.add_item("prod-1", quantity=1, price=Decimal("19.99"))

Capture significant business events that domain experts care about.

from dataclasses import dataclass
from datetime import datetime
@dataclass
class OrderPlacedEvent:
"""Domain event - something that happened"""
order_id: str
customer_id: str
total: Decimal
timestamp: datetime
@dataclass
class OrderCancelledEvent:
order_id: str
reason: str
timestamp: datetime
@dataclass
class PaymentReceivedEvent:
order_id: str
amount: Decimal
payment_method: str
timestamp: datetime
# In aggregate
class Order:
def __init__(self):
self._events = []
def place_order(self):
# Business logic
self.status = "placed"
# Record event
self._events.append(
OrderPlacedEvent(
order_id=self.id,
customer_id=self.customer_id,
total=self.calculate_total(),
timestamp=datetime.now()
)
)
def get_events(self):
"""Get uncommitted events"""
events = self._events
self._events = []
return events

Why Events?

  • Loose coupling between bounded contexts
  • Audit trail of what happened
  • Event sourcing (store events instead of state)
  • Eventual consistency across contexts

Each bounded context becomes a microservice!

Diagram

How bounded contexts relate to each other:

Diagram

Context Mapping Patterns:

  1. Customer-Supplier

    • Order Context (customer) depends on Payment Context (supplier)
    • Payment Context provides API that Order Context consumes
  2. Conformist

    • Order Context conforms to Customer Context’s model
    • Accepts Customer’s definition of “customer”
  3. Published Language

    • Order Context publishes events in common format
    • Shipping Context subscribes to events
  4. Anticorruption Layer

    • Translation layer between contexts
    • Protects your model from external influences
# Anticorruption Layer Example
class LegacyOrderAdapter:
"""Translates between new domain model and legacy system"""
def __init__(self, legacy_system):
self._legacy = legacy_system
def get_order(self, order_id: str) -> Order:
"""Convert legacy order to our domain model"""
legacy_order = self._legacy.fetch_order(order_id)
# Translate legacy data to our model
return Order(
id=legacy_order["ORDER_ID"],
customer_id=legacy_order["CUST_NUM"],
status=self._translate_status(legacy_order["STATUS_CD"]),
lines=[
OrderLine(
product_id=item["PROD_ID"],
quantity=item["QTY"],
price=Decimal(item["PRICE"])
)
for item in legacy_order["ITEMS"]
]
)
def _translate_status(self, legacy_status: str) -> str:
"""Translate legacy status codes to our ubiquitous language"""
mapping = {
"N": "draft",
"C": "confirmed",
"S": "shipped",
"D": "delivered",
}
return mapping.get(legacy_status, "unknown")

Finding Bounded Contexts (Practical Guide)

Section titled “Finding Bounded Contexts (Practical Guide)”

What is Event Storming?

  • Workshop with developers and domain experts
  • Use sticky notes to map out business processes
  • Identify domain events, commands, aggregates, and contexts

Process:

  1. List domain events (orange sticky notes)

    • OrderPlaced
    • PaymentReceived
    • ItemShipped
    • CustomerRegistered
  2. Add commands that trigger events (blue sticky notes)

    • PlaceOrder → OrderPlaced
    • ProcessPayment → PaymentReceived
    • ShipItem → ItemShipped
  3. Identify aggregates (yellow sticky notes)

    • Order (handles PlaceOrder, CancelOrder)
    • Payment (handles ProcessPayment, RefundPayment)
    • Shipment (handles ShipItem, TrackShipment)
  4. Draw boundaries (pink lines)

    • Where language changes = bounded context boundary!
    • Where team ownership changes = bounded context boundary!

List all business capabilities:

E-Commerce Business Capabilities:
├── Product Catalog
│ └── Manage products, search, browse
├── Order Management
│ └── Place orders, track orders, cancel orders
├── Payment Processing
│ └── Process payments, refunds, payment methods
├── Inventory Management
│ └── Track stock, reserve inventory, replenish
├── Shipping & Fulfillment
│ └── Ship orders, track shipments, returns
├── Customer Management
│ └── Register customers, profiles, preferences
└── Customer Service
└── Support tickets, returns, complaints

Each capability = potential bounded context!

Method 3: Look for Different Models of Same Concept

Section titled “Method 3: Look for Different Models of Same Concept”

If “Product” means different things in different parts of system:

Product in Catalog Context:

class Product:
name: str
description: str
images: list[str]
category: str
attributes: dict
# Focus: browsing and discovery

Product in Inventory Context:

class Product:
sku: str
quantity_on_hand: int
reorder_point: int
warehouse_location: str
# Focus: stock management

Product in Order Context:

class OrderLineProduct:
product_id: str
name: str
price: Decimal
# Focus: what was ordered at what price

Different models = different contexts!


Abstracts data access for aggregates.

from abc import ABC, abstractmethod
from typing import Optional, List
class OrderRepository(ABC):
"""Repository interface - part of domain layer"""
@abstractmethod
def save(self, order: Order) -> None:
"""Save aggregate"""
pass
@abstractmethod
def find_by_id(self, order_id: str) -> Optional[Order]:
"""Load aggregate by ID"""
pass
@abstractmethod
def find_by_customer(self, customer_id: str) -> List[Order]:
"""Query by customer"""
pass
class PostgresOrderRepository(OrderRepository):
"""Concrete implementation - infrastructure layer"""
def __init__(self, db_session):
self._session = db_session
def save(self, order: Order) -> None:
# Persist aggregate
self._session.add(order)
self._session.commit()
# Publish domain events
for event in order.get_events():
event_bus.publish(event)
def find_by_id(self, order_id: str) -> Optional[Order]:
return self._session.query(Order).filter_by(id=order_id).first()
def find_by_customer(self, customer_id: str) -> List[Order]:
return self._session.query(Order)\
.filter_by(customer_id=customer_id)\
.all()

When business logic doesn’t fit in an entity:

class PricingService:
"""Domain service - business logic that spans multiple aggregates"""
def __init__(self, discount_policy_repo: DiscountPolicyRepository):
self._discount_policies = discount_policy_repo
def calculate_order_total(
self,
order: Order,
customer: Customer
) -> Decimal:
"""Calculate total with business rules"""
# Base total from order
subtotal = order.calculate_subtotal()
# Apply customer discounts
discount = self._calculate_customer_discount(customer, subtotal)
# Apply promotional discounts
promo_discount = self._calculate_promo_discount(order)
# Calculate tax
tax = self._calculate_tax(order, customer)
return subtotal - discount - promo_discount + tax
def _calculate_customer_discount(
self,
customer: Customer,
subtotal: Decimal
) -> Decimal:
"""Apply VIP discounts"""
if customer.is_vip():
return subtotal * Decimal("0.10") # 10% VIP discount
return Decimal("0")
def _calculate_promo_discount(self, order: Order) -> Decimal:
"""Apply promotional discounts"""
# Complex business logic here
policies = self._discount_policies.find_active_policies()
# ... apply policies
return Decimal("0")

Diagram
# Order Context publishes event
class OrderService:
def place_order(self, cart: Cart, customer: Customer):
order = Order.from_cart(cart, customer)
self._order_repo.save(order)
# Publish domain event
event_bus.publish(
OrderPlacedEvent(
order_id=order.id,
customer_id=customer.id,
items=order.items,
total=order.total
)
)
# Payment Context subscribes
class PaymentService:
@subscribe_to(OrderPlacedEvent)
def handle_order_placed(self, event: OrderPlacedEvent):
# Process payment in Payment context
payment = Payment.for_order(event.order_id, event.total)
result = self._payment_processor.charge(payment)
if result.success:
event_bus.publish(
PaymentReceivedEvent(
order_id=event.order_id,
payment_id=payment.id,
amount=event.total
)
)
# Fulfillment Context subscribes
class FulfillmentService:
@subscribe_to(PaymentReceivedEvent)
def handle_payment_received(self, event: PaymentReceivedEvent):
# Create shipment in Fulfillment context
shipment = Shipment.for_order(event.order_id)
self._shipment_repo.save(shipment)
event_bus.publish(
ShipmentCreatedEvent(
order_id=event.order_id,
shipment_id=shipment.id
)
)

Clear Boundaries

Bounded contexts provide natural service boundaries for microservices. No guessing where to split!

Business Alignment

Software structure mirrors business structure. Easier to understand and evolve with business.

Team Autonomy

Each bounded context owned by one team. Minimize coordination overhead.

Maintainability

Ubiquitous language and clear models make code self-documenting and easier to maintain.


Bounded Context = Service

Each bounded context becomes a microservice with its own database and domain model.

Speak the Language

Use ubiquitous language in code. If business says “place order”, method should be place_order(), not createOrderEntity().

Aggregate Boundaries

Transactions don’t cross aggregate boundaries. Use eventual consistency between aggregates.

Events Connect Contexts

Use domain events for loose coupling between bounded contexts. Enables eventual consistency.



  • “Domain-Driven Design” by Eric Evans (the blue book)
  • “Implementing Domain-Driven Design” by Vaughn Vernon (the red book)
  • “Domain-Driven Design Distilled” by Vaughn Vernon (quick intro)
  • “Learning Domain-Driven Design” by Vlad Khononov (modern take)
  • Domain-Driven Design Community - dddcommunity.org