← Back to the section

In most projects, business logic gradually smears across services, controllers, and helper classes. After a year, it becomes unclear where the rules live, who is responsible for what, and why a change in one place breaks another.

DDD tactical patterns give you a structure for organizing this logic. Each pattern answers a specific question: what is an object with a unique identity, what is just a value, where the consistency boundary sits, and how parts of the system communicate through events.

Entity — an object with identity

Picture a user in the system. They have a name and an email — those can change. But the user stays the same user even after changing everything. Their identity is an identifier, not a set of fields.

An Entity is an object that has a unique identifier. Two objects with the same fields but different IDs are different entities.

Key properties:

  • It has a stable ID (it does not change during the object's lifetime).
  • Its state can change — but only through methods that check business rules.
  • Equality is by ID, not by fields.
  • It holds behavior, not just data.

Common examples: User, Order, Invoice, Shipment, Contract.

A frequent mistake is the "anemic" Entity: a class with getters and setters but no rules. Then business logic leaks into services, and the object becomes a data structure.

export class User {
    private readonly id: string;
    private email: Email;
    private name: string;
    private active: boolean;

    private constructor(id: string, email: Email, name: string) {
        this.id = id;
        this.email = email;
        this.name = name;
        this.active = true;
    }

    static create(id: string, email: Email, name: string): User {
        if (!id) throw new Error('id is required');
        if (!email) throw new Error('email is required');
        if (!name || name.trim() === '') throw new Error('name is required');
        return new User(id, email, name);
    }

    changeEmail(newEmail: Email): void {
        if (!this.active) throw new Error('Inactive user cannot change email');
        if (!newEmail) throw new Error('email is required');
        this.email = newEmail;
    }

    deactivate(): void {
        this.active = false;
    }

    equals(other: User): boolean {
        return this.id === other.id;
    }
}

The changeEmail method is not just a setter. It checks a business rule: an inactive user cannot change their email. That is the key difference from a plain record.

Value Object — an object without identity

Money is a good example. Two 100-ruble banknotes are the same — we do not care whether it is "the same" banknote or a different one. What matters is the amount and the currency.

A Value Object is an object that has no ID of its own. Equality is defined by the values of its fields. Once created, it does not change (it is immutable).

Key properties:

  • No ID.
  • Immutable: instead of changing it, you create a new object.
  • Equality by all significant fields.
  • It checks its own invariants at creation time.

Common examples: Money, Email, PhoneNumber, Address, DateRange.

Why Value Objects matter: they remove "primitive obsession" — where a plain string travels around everywhere instead of an Email type. Format rules, validation, and operations live inside the type instead of being scattered across the code.

export type Currency = 'RUB' | 'USD' | 'EUR';

export class Money {
    private readonly amount: number;
    private readonly currency: Currency;

    constructor(amount: number, currency: Currency) {
        if (amount == null || Number.isNaN(amount)) throw new Error('amount is required');
        if (currency == null) throw new Error('currency is required');
        this.amount = Math.round(amount * 100) / 100;
        this.currency = currency;
    }

    add(other: Money): Money {
        if (this.currency !== other.currency) {
            throw new Error('Currency mismatch');
        }
        return new Money(this.amount + other.amount, this.currency);
    }

    equals(other: Money): boolean {
        return this.amount === other.amount && this.currency === other.currency;
    }
}

The add method returns a new object — the original does not change. The currency-compatibility check is built into the operation instead of sitting somewhere outside.

Aggregate — the consistency boundary

Here is a real problem: an order has lines. Can you modify a line directly, bypassing the order? Formally — yes. But then the order ends up in an invalid state: for example, the total is not recalculated, or a line was added to an already confirmed order.

An Aggregate is a cluster of Entity and Value Object with a single consistency boundary. From the outside, only the root (the Aggregate Root) is visible — you can change the aggregate's state only through it.

Rules:

  • All changes inside the aggregate go through the root.
  • You cannot hold direct references to another aggregate's internal objects — only IDs.
  • An aggregate should be small. A "god aggregate" with dozens of objects inside is an anti-pattern.
export type OrderStatus = 'DRAFT' | 'CONFIRMED';

export class Order {
    private readonly id: OrderId;
    private readonly customerId: CustomerId;
    private readonly lines: OrderLine[] = [];
    private status: OrderStatus = 'DRAFT';

    private constructor(id: OrderId, customerId: CustomerId) {
        this.id = id;
        this.customerId = customerId;
    }

    static create(id: OrderId, customerId: CustomerId): Order {
        return new Order(id, customerId);
    }

    addLine(productId: ProductId, qty: number, price: Money): void {
        this.ensureDraft();
        if (qty <= 0) throw new Error('qty must be > 0');
        this.lines.push(new OrderLine(productId, qty, price));
    }

    confirm(): void {
        this.ensureDraft();
        if (this.lines.length === 0) throw new Error('Order has no lines');
        this.status = 'CONFIRMED';
    }

    total(): Money {
        return this.lines
            .map((line) => line.subtotal())
            .reduce((sum, subtotal) => sum.add(subtotal), Money.zero('RUB'));
    }

    private ensureDraft(): void {
        if (this.status !== 'DRAFT') {
            throw new Error('Order is not editable');
        }
    }
}

OrderLine is an internal object of the aggregate. From the outside, nobody touches it directly — only through Order methods.

Domain Event — what happened in the domain

When an order is paid, you need to: send an email, notify the warehouse, credit bonus points. One way is to call all of that right inside the pay() method. But then Order knows about email, the warehouse, and bonus points — which breaks the boundaries of responsibility.

A Domain Event is an object that records a fact: something significant happened in the domain. The aggregate publishes the event, and other parts of the system react on their own.

Characteristics:

  • Immutable — a fact in the past cannot be changed.
  • Named in the past tense: OrderPaid, UserRegistered, not PayOrder.
  • Carries context: orderId, amount, paidAt — not just a marker.
  • The aggregate accumulates events and hands them over after being saved.
export abstract class DomainEvent {
    readonly eventId: string = crypto.randomUUID();
    readonly occurredAt: Date = new Date();
}

export class OrderPaid extends DomainEvent {
    constructor(
        readonly orderId: string,
        readonly amount: number,
    ) {
        super();
    }
}

export abstract class AggregateRoot {
    private readonly domainEvents: DomainEvent[] = [];

    protected registerEvent(event: DomainEvent): void {
        this.domainEvents.push(event);
    }

    getDomainEvents(): ReadonlyArray<DomainEvent> {
        return [...this.domainEvents];
    }

    clearEvents(): void {
        this.domainEvents.length = 0;
    }
}

The aggregate calls registerEvent(new OrderPaid(...)) when its state changes. After the aggregate is saved, the repository picks up the accumulated events and publishes them — this guarantees that events go out only after a successful write.

Internal events live inside a single bounded context, often in one transaction. External ones cross boundaries through a message broker (Kafka, RabbitMQ) and require serialization.

Repository — access to aggregates

As a rule, when you need to save an order, people write SQL right inside the service. Queries spill beyond the business logic, get tied to a specific database, and swapping storage becomes expensive.

A Repository is an abstraction over the storage of aggregates. It creates the illusion of an in-memory collection of objects: find, save. The interface lives in the domain layer, the implementation in the infrastructure.

Key rules:

  • One repository — one aggregate.
  • It loads and saves the aggregate as a whole.
  • Methods in domain terms: findById, save — not SQL.
  • Interface in the domain, implementation in the infrastructure (dependency inversion).
// Interface — in the domain
export interface OrderRepository {
    findById(id: OrderId): Promise<Order | null>;
    save(order: Order): Promise<void>;
}

// Implementation — in the infrastructure, publishes events after saving
export class OrderRepositoryImpl implements OrderRepository {
    constructor(
        private readonly dao: OrderDao,
        private readonly eventPublisher: EventPublisher,
    ) {}

    async save(order: Order): Promise<void> {
        await this.dao.save(order);
        for (const event of order.getDomainEvents()) {
            await this.eventPublisher.publish(event);
        }
        order.clearEvents();
    }
}

The difference from a DAO: a Repository works with the aggregate as a whole and thinks in the language of the domain. A DAO works with a table and thinks in the language of storage.

Domain Service — logic between aggregates

Sometimes an operation does not belong to any single aggregate. Transferring money between accounts: you need to withdraw from one and deposit into another. Neither the sender's Account nor the receiver's Account should know about each other.

A Domain Service is the place for business logic that coordinates several aggregates or domain objects.

The rule: first try to place the logic in an Entity or Aggregate Root. A Domain Service is for when the logic truly does not belong to any of them.

export class TransferService {
    transfer(from: Account, to: Account, amount: Money): void {
        from.withdraw(amount);
        to.deposit(amount);
    }
}

A Domain Service lives in the domain layer and knows only about domain objects. Do not confuse it with an Application Service — that one lives in the application layer and orchestrates: load the aggregate, invoke the domain logic, save.

Factory — creating aggregates

Sometimes creating an aggregate is not just a new. You need to check business rules, generate an ID, assemble the object from several parts.

A Factory encapsulates the logic of creating an aggregate. If the constructor can handle it — you do not need a factory.

export class OrderFactory {
    createNew(customer: Customer): Order {
        if (customer.isBlocked()) {
            throw new Error('Blocked customer cannot place orders');
        }
        return Order.create(OrderId.generate(), customer.id);
    }
}

Here a business rule ("a blocked customer cannot place orders") is checked before the aggregate is created. The Order constructor itself knows nothing about Customer — that is a separation of responsibilities.

In short

  • Entity — an object with a unique ID; equality by ID; holds behavior and checks invariants.
  • Value Object — no ID, immutable, equality by values; removes "primitive obsession".
  • Aggregate — a cluster of objects with a single consistency boundary; only the root is visible from the outside.
  • Domain Event — an immutable fact in the past tense; the aggregate accumulates events, the repository publishes them after saving.
  • Repository — an abstraction over the storage of aggregates; interface in the domain, implementation in the infrastructure.
  • Domain Service — business logic that does not belong to any aggregate; a last resort, not a first choice.
  • Factory — creating an aggregate with complex logic; if the constructor can handle it, you do not need one.
  • You do not need to apply all the patterns at once. Start with Entity and Value Object, add the rest as needed.