← 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 {
    RiskRating riskFor(OrderId id) {
        // 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 {
    Long id;
    BigDecimal total_amount;
    String status_code;
}

Here is what an explicit domain concept looks like:

class CustomerTierResolver {
    CustomerTier resolve(Customer customer) {
        if (customer.ordersInLastYear() >= 20) return CustomerTier.GOLD;
        if (customer.ordersInLastYear() >= 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 {
    Long id;
    OrderStatus status;
    // only get/set
}

// All the logic sits in a service
class OrderService {
    void confirm(Order order) {
        if (order.getStatus() != OrderStatus.DRAFT) throw new RuntimeException();
        order.setStatus(OrderStatus.CONFIRMED);
    }
}

In Model-Driven Design, behavior lives where the data lives:

class Order {
    private OrderStatus status;

    void confirm() {
        if (!canBeConfirmed())
            throw new IllegalStateException("Order cannot be confirmed");
        status = OrderStatus.CONFIRMED;
    }

    void markAsPaid(PaymentId paymentId) {
        status = OrderStatus.PAID;
        events.add(new OrderPaidEvent(id, paymentId));
    }
}

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 slaps @Entity, @Table, and JPA annotations onto it. Then it turns out that this object can't be tested without a database, can't be reused without Spring, 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 {
    private final OrderRepository repo;

    void confirmOrder(OrderId id) {
        Order order = repo.byId(id);
        order.confirm();       // the decision is inside the domain
        repo.save(order);
    }
}

// The repository interface is declared in the domain
interface OrderRepository {
    Order byId(OrderId id);
    void save(Order order);
}

// The implementation lives in the infrastructure layer, the domain knows nothing about it
class JpaOrderRepository implements OrderRepository {
    public Order byId(OrderId id) { /* JPA */ }
    public void save(Order order) { /* JPA */ }
}

Anti-patterns:

// A domain object with infrastructure annotations
@Entity
@Table(name = "orders")
class Order { /* ORM right inside the domain */ }

// The Application Service works directly with infrastructure
class OrderAppService {
    @PersistenceContext EntityManager em; // 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.compareTo(limit) <= 0 && !customer.isBlocked() && customer.age() >= 18) {
    // allow the operation
}

Candidates for extraction are policies, roles, time periods, domain events:

// A policy — a named rule
class CreditApprovalPolicy {
    boolean allows(Customer customer, Money amount) {
        return !customer.isBlocked()
            && customer.isAdult()
            && customer.creditLimit().remaining().isGreaterThan(amount);
    }
}

// A period — a domain concept instead of two dates
record BillingPeriod(LocalDate from, LocalDate to) {
    boolean includes(LocalDate date) {
        return !date.isBefore(from) && !date.isAfter(to);
    }
}

// An event — an explicit domain fact
record OrderExpired(OrderId orderId, Instant expiredAt) implements DomainEvent {}

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 boolean hasDiscount 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.updateStatus(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.cancelByCustomer(reason);
order.markAsPaid(paymentId);

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 {
    Money calculateAndApplyDiscount(Order order) {
        Money discount = computeDiscount(order);
        order.setTotal(order.getTotal().subtract(discount)); // mutates the order!
        return discount;
    }
}

It's better to separate them:

// A pure computation — changes nothing
class TaxCalculator {
    Money taxFor(Order order) {
        return order.subtotal().multiply(TAX_RATE);
    }
}

// A command — changes state, returns nothing
class Order {
    void applyDiscount(Discount discount) {
        this.total = discount.applyTo(this.total);
        events.add(new DiscountAppliedEvent(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 {
    void withdraw(Money amount) {
        balance = balance.subtract(amount); // what if amount > balance?
    }
}

Invariants are checked where the change happens:

class Account {
    void withdraw(Money amount) {
        if (amount.isNegative()) throw new IllegalArgumentException("Amount must be positive");
        if (balance.isLessThan(amount)) throw new IllegalStateException("Insufficient funds");
        balance = 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
interface ShippingPolicy {
    Money costFor(Shipment shipment);
}

class ExpressShipping implements ShippingPolicy {
    public Money costFor(Shipment shipment) { /* ... */ }
}

// Factory — for creation with domain rules
class ShippingPolicyFactory {
    ShippingPolicy forOrder(Order order) {
        return order.isExpress() ? new ExpressShipping() : new StandardShipping();
    }
}

// Specification — for reusable rules
class CanConfirmOrder implements Specification<Order> {
    public boolean isSatisfiedBy(Order order) {
        return order.isPaid() && order.hasItems();
    }
}

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 {
    static ConfigManager INSTANCE = new 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 updateStatus(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.