DDD patterns — Entity, Aggregate, Bounded Context — answer the question "what to build." This article is about something else: how to think when designing. It gathers the principles from Eric Evans' book that don't fit into a single pattern, yet shape the quality of the entire architecture.
Knowledge belongs in code, not in SQL
When a team starts building a system, business rules often settle wherever it's convenient right now: in SQL queries, in controllers, in scripts. A year later, nobody remembers where the discount for a "gold" customer comes from — is it a WHERE in a query or a condition in a service?
Evans calls this process Knowledge Crunching. The idea: the model should grow out of a dialogue with the people who understand the domain. And that knowledge should stay in the domain code rather than leaking out.
Here is what a knowledge leak looks like:
# Knowledge hidden in SQL — who will read this a year from now?
class OrderDao:
def risk_for(self, id: OrderId) -> RiskRating:
# SELECT CASE WHEN total > 10000 THEN 'HIGH' WHEN ... END
return RiskRating.LOW
# A copy of the DB structure instead of a model — no behavior at all
class OrderRecord:
id: int
total_amount: Decimal
status_code: str
Here is what an explicit domain concept looks like:
class CustomerTierResolver:
def resolve(self, customer: Customer) -> CustomerTier:
if customer.orders_in_last_year() >= 20:
return CustomerTier.GOLD
if customer.orders_in_last_year() >= 5:
return CustomerTier.SILVER
return CustomerTier.BRONZE
The rule reads like a rule — no SQL, no magic numbers buried in comments.
The main trap: freezing the model too early or copying the database structure as the domain model. Early models are always naive — and that's fine, they need to be actively revised.
The model belongs in code, not just on the wiki
Picture this: an architect draws a beautiful diagram on the whiteboard. The developers take a look and then write the code their own way. Model and implementation drift apart, and six months later the diagram describes an imaginary system, not the real one.
Evans calls this Model-Driven Design: the model must be expressed directly in the code. If the model exists only in an analyst's head or on the wiki — that's not Model-Driven Design.
The most common problem is the anemic model: an object with only fields and getters, with all the logic in a separate service. It looks tidy, but the business rules start leaking into services, controllers, mappings — and over time it's unclear where to look for the truth.
# Anemic model: an entity without behavior
class Order:
id: int
status: OrderStatus
# data only, no behavior
# All the logic sits in a service
class OrderService:
def confirm(self, order: Order) -> None:
if order.status is not OrderStatus.DRAFT:
raise RuntimeError()
order.status = OrderStatus.CONFIRMED
In Model-Driven Design, behavior lives where the data lives:
class Order:
def confirm(self) -> None:
if not self._can_be_confirmed():
raise RuntimeError("Order cannot be confirmed")
self._status = OrderStatus.CONFIRMED
def mark_as_paid(self, payment_id: PaymentId) -> None:
self._status = OrderStatus.PAID
self._events.append(OrderPaidEvent(self._id, payment_id))
Now the rule "you can't confirm an unsuitable order" lives inside Order and doesn't leak anywhere.
The domain must not depend on frameworks
A classic problem: a developer writes a domain object and immediately turns it into a SQLAlchemy model — inherits from Base, adds __tablename__ and column mappings. Then it turns out that this object can't be tested without a database, can't be reused without SQLAlchemy, and the schema can't be changed without rewriting the domain.
The solution is a layered architecture with clear rules: UI → Application → Domain → Infrastructure. The domain never imports anything from outside itself.
# The Application Service coordinates the scenario, the domain makes the decisions
class OrderAppService:
def __init__(self, repo: OrderRepository) -> None:
self._repo = repo
def confirm_order(self, id: OrderId) -> None:
order = self._repo.by_id(id)
order.confirm() # the decision is inside the domain
self._repo.save(order)
# The repository interface is declared in the domain
class OrderRepository(Protocol):
def by_id(self, id: OrderId) -> Order: ...
def save(self, order: Order) -> None: ...
# The implementation lives in the infrastructure layer, the domain knows nothing about it
class SqlAlchemyOrderRepository:
def by_id(self, id: OrderId) -> Order:
... # SQLAlchemy
def save(self, order: Order) -> None:
... # SQLAlchemy
Anti-patterns:
# A domain object as an ORM model
class Order(Base):
__tablename__ = "orders"
# ORM right inside the domain
# The Application Service works directly with infrastructure
class OrderAppService:
def __init__(self, session: Session) -> None:
self._session = session # infrastructure inside the application layer
Hidden concepts are worth making visible
When a long, nameless condition shows up in the code, it's a sign that the domain contains an important concept that no one has named.
# Inexpressive logic — what does it mean?
if amount <= limit and not customer.is_blocked() and customer.age() >= 18:
... # allow the operation
Candidates for extraction are policies, roles, time periods, domain events:
# A policy — a named rule
class CreditApprovalPolicy:
def allows(self, customer: Customer, amount: Money) -> bool:
return (
not customer.is_blocked()
and customer.is_adult()
and customer.credit_limit().remaining().is_greater_than(amount)
)
# A period — a domain concept instead of two dates
@dataclass(frozen=True)
class BillingPeriod:
start: date
end: date
def includes(self, day: date) -> bool:
return self.start <= day <= self.end
# An event — an explicit domain fact
@dataclass(frozen=True)
class OrderExpired(DomainEvent):
order_id: OrderId
expired_at: datetime
Explicit types make testing and reuse easier. A named rule can be discussed with an expert, changed, and tested on its own.
Sometimes an explicit concept turns the model upside down — Evans calls this a breakthrough. Before the breakthrough: a discount is just a has_discount: bool flag. After the breakthrough: a discount is a Discount object with rules for how it applies. The code becomes simpler and more expressive.
The API should tell you what is happening
A bad sign is a method whose name says nothing:
order.update_status(2) # what is 2?
order.process(True, False) # what do these booleans do?
A good public API tells you what is happening, not how it's done inside:
order.confirm()
order.cancel_by_customer(reason)
order.mark_as_paid(payment_id)
Evans calls this principle Intention-Revealing Interfaces. Reading the method call, you grasp the meaning of the operation without peeking into the implementation.
Computations must not change state
Another principle from the Supple Design section is Side-Effect-Free Functions. If a method computes something, it must not change something at the same time. This makes the code predictable and easy to test.
# Computation + side effect — a dangerous mix
class PricingService:
def calculate_and_apply_discount(self, order: Order) -> Money:
discount = self._compute_discount(order)
order.total = order.total.subtract(discount) # mutates the order!
return discount
It's better to separate them:
# A pure computation — changes nothing
class TaxCalculator:
def tax_for(self, order: Order) -> Money:
return order.subtotal().multiply(TAX_RATE)
# A command — changes state, returns nothing
class Order:
def apply_discount(self, discount: Discount) -> None:
self._total = discount.apply_to(self._total)
self._events.append(DiscountAppliedEvent(self._id, discount))
Invariants are checked where state changes
An invariant is a rule that must always hold. If an object can end up in an invalid state, sooner or later it will cause a bug that's hard to catch.
# Silently allows a negative balance
class Account:
def withdraw(self, amount: Money) -> None:
self._balance = self._balance.subtract(amount) # what if amount > balance?
Invariants are checked where the change happens:
class Account:
def withdraw(self, amount: Money) -> None:
if amount.is_negative():
raise ValueError("Amount must be positive")
if self._balance.is_less_than(amount):
raise RuntimeError("Insufficient funds")
self._balance = self._balance.subtract(amount)
Evans calls this principle Assertions — assertions about state. Errors are caught close to their source instead of surfacing in an unexpected place.
GoF patterns help express the domain
Strategy, Factory, Specification — technical patterns are justified when they emphasize the meaning of the domain, rather than merely adding layers of abstraction.
# Strategy — for varying behavior
class ShippingPolicy(Protocol):
def cost_for(self, shipment: Shipment) -> Money: ...
class ExpressShipping:
def cost_for(self, shipment: Shipment) -> Money: ...
# Factory — for creation with domain rules
class ShippingPolicyFactory:
def for_order(self, order: Order) -> ShippingPolicy:
return ExpressShipping() if order.is_express() else StandardShipping()
# Specification — for reusable rules
class CanConfirmOrder(Specification[Order]):
def is_satisfied_by(self, order: Order) -> bool:
return order.is_paid() and order.has_items()
The anti-pattern is a pattern for the pattern's sake, with no domain meaning:
# The ConfigManager singleton has nothing to do with the domain
class ConfigManager:
...
CONFIG_MANAGER = ConfigManager()
Refactoring in DDD is not a technical exercise but a tool for deepening the model. Every change should bring the code closer to the language of the domain.
In short
- Knowledge in code — business rules must not hide in SQL queries or in helper objects with no behavior.
- Model in code — the anemic model (an object with fields + a service holding all the logic) is an anti-pattern; behavior lives where the data lives.
- Domain isolation — the domain does not depend on frameworks; dependencies point inward: UI → Application → Domain ← Infrastructure.
- Explicit concepts — policies, roles, periods and events deserve types of their own, rather than living inside
if/else. - Intention-Revealing Interfaces —
confirm()instead ofupdate_status(2); reading the call, you grasp the meaning. - Side-Effect-Free Functions — computations and commands are kept separate; a method either computes or changes state.
- Invariants — checked where the change happens, not later in some random place.
- GoF patterns — Strategy, Factory, Specification are justified when they emphasize the domain, not when they add layers for the sake of layers.
What to read next
- What DDD is and why you need it — the starting point if you haven't read it yet.
- DDD strategic patterns — Bounded Context, Context Map, Ubiquitous Language.
- DDD tactical patterns — Entity, Value Object, Aggregate, Repository.