← 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 {
  riskFor(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: number;
  total_amount: number;
  status_code: string;
}

Here is what an explicit domain concept looks like:

class CustomerTierResolver {
  resolve(customer: Customer): CustomerTier {
    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 {
  id: number;
  status: OrderStatus;
  // only public fields
}

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

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

class Order {
  private status: OrderStatus;

  confirm(): void {
    if (!this.canBeConfirmed()) {
      throw new Error('Order cannot be confirmed');
    }
    this.status = OrderStatus.CONFIRMED;
  }

  markAsPaid(paymentId: PaymentId): void {
    this.status = OrderStatus.PAID;
    this.events.push(new OrderPaidEvent(this.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, @Column, and TypeORM decorators onto it. Then it turns out that this object can't be tested without a database, can't be reused without NestJS, 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 {
  constructor(private readonly repo: OrderRepository) {}

  async confirmOrder(id: OrderId): Promise<void> {
    const order = await this.repo.byId(id);
    order.confirm();       // the decision is inside the domain
    await this.repo.save(order);
  }
}

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

// The implementation lives in the infrastructure layer, the domain knows nothing about it
class TypeOrmOrderRepository implements OrderRepository {
  async byId(id: OrderId): Promise<Order> { /* TypeORM */ }
  async save(order: Order): Promise<void> { /* TypeORM */ }
}

Anti-patterns:

// A domain object with infrastructure decorators
@Entity('orders')
class Order { /* ORM right inside the domain */ }

// The Application Service works directly with infrastructure
class OrderAppService {
  constructor(private readonly em: EntityManager) {} // 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 {
  allows(customer: Customer, amount: Money): boolean {
    return !customer.isBlocked()
      && customer.isAdult()
      && customer.creditLimit().remaining().isGreaterThan(amount);
  }
}

// A period — a domain concept instead of two dates
class BillingPeriod {
  constructor(
    readonly from: Date,
    readonly to: Date,
  ) {}

  includes(date: Date): boolean {
    return date >= this.from && date <= this.to;
  }

  equals(other: BillingPeriod): boolean {
    return this.from.getTime() === other.from.getTime()
      && this.to.getTime() === other.to.getTime();
  }
}

// An event — an explicit domain fact
class OrderExpired implements DomainEvent {
  constructor(
    readonly orderId: OrderId,
    readonly expiredAt: Date,
  ) {}
}

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 {
  calculateAndApplyDiscount(order: Order): Money {
    const discount = this.computeDiscount(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 {
  taxFor(order: Order): Money {
    return order.subtotal().multiply(TAX_RATE);
  }
}

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

Invariants are checked where the change happens:

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

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

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

// Specification — for reusable rules
class CanConfirmOrder implements Specification<Order> {
  isSatisfiedBy(order: Order): boolean {
    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 readonly 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.