← Back to the section

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 Interfacesconfirm() instead of update_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.