So, you've heard about Domain-Driven Design.

Maybe you bought the blue book. Maybe you opened it, saw 500 pages of strategic patterns, bounded contexts, and ubiquitous language, and quietly closed it again. Maybe a colleague keeps saying "but is that really an aggregate?" in code reviews and you've started to suspect they don't fully know either.

Here's the thing nobody tells you upfront: most of what makes DDD useful day-to-day is the tactical layer. A small set of patterns (entities, value objects, aggregates, repositories, domain services) that you can adopt one at a time, in the codebase you already have, without renaming a single Slack channel.

That's what this article is about. Not bounded contexts. Not event storming. Not the religious wars about whether your microservices map to domains. Just the tools you'd reach for on a Tuesday afternoon when you're modeling an order, a money transfer, or a user subscription, and you want the model to survive contact with reality.

The Thing DDD Is Actually Solving

Before any pattern, the problem.

Most backend code starts the same way. You have a database table. You generate a class that mirrors it. You add a few getters and setters. You sprinkle the business rules across controllers, services, helpers, and a util folder nobody admits to creating. Two years later, the rule "you can't refund an order older than 90 days" lives in four places, two of them disagree, and the bug ticket is older than your dog.

That's an anemic model. The class is just a bag of fields. Behavior leaks into whatever happens to need it.

DDD's tactical patterns give you somewhere to put that behavior. Not a new framework. Not a new database. A different mental model: your domain objects own their rules, and the rest of the system is plumbing.

That's it. That's the whole pitch.

Entities: When Identity Matters More Than Fields

An entity is anything in your system that has an identity that survives changes to its data.

A user is an entity. Even if they change their email, their name, their avatar, and their timezone, they're still the same user. user_42 yesterday and user_42 today are equal because the ID matches, not because the fields do.

Compare that to a shipping address. If you change the street, it's not the "same address with a new street." It's a different address. There's no identity to preserve. That one's a value object. We'll get there in a minute.

Entities have two jobs. First, they're equal by identity, not by content. Second, they're the natural home for behavior that mutates them.

TypeScript src/domain/user.ts
class User {
  constructor(
    public readonly id: UserId,
    private email: Email,
    private status: UserStatus,
  ) {}

  changeEmail(newEmail: Email): void {
    if (this.status === UserStatus.Suspended) {
      throw new DomainError("Suspended users can't change email");
    }
    this.email = newEmail;
  }

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

Notice three small things.

The id is readonly, so entities don't get to forget who they are. The state fields (email, status) are private, so outside callers can't reach in and reassign them, which is the whole reason the rule "suspended users can't change email" actually holds. And equals compares IDs, not fields.

That last one matters more than it looks. The moment you write user1 === user2 in JavaScript or user1.equals(user2) in any language and that comparison is field-by-field, you've quietly announced that two users with the same name and email are the same person. They're not. They're two rows.

Value Objects: Equality By Content, Forever Immutable

A value object is the opposite. It has no identity. Two value objects are equal if their fields are equal. And once you create one, you don't change it. You create a new one.

The canonical example is Money. Twenty dollars is twenty dollars. There's no "this twenty dollars vs. that twenty dollars." If you want twenty-five, you don't mutate the twenty. You produce a new value of twenty-five.

This sounds like a small thing. It isn't. Value objects are where the most subtle bugs in a backend system go to die.

Here's the same Money type in three languages so you can pick whichever one your team writes.

class Money {
  constructor(
    public readonly amount: bigint,
    public readonly currency: string,
  ) {
    if (amount < 0n) throw new DomainError("Money can't be negative");
    if (currency.length !== 3) throw new DomainError("Use ISO 4217 codes");
  }

  add(other: Money): Money {
    if (other.currency !== this.currency) {
      throw new DomainError("Can't add different currencies");
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Money can't be negative")
        if len(self.currency) != 3:
            raise ValueError("Use ISO 4217 codes")

    def add(self, other: "Money") -> "Money":
        if other.currency != self.currency:
            raise ValueError("Can't add different currencies")
        return Money(self.amount + other.amount, self.currency)
public record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new DomainException("Money can't be negative");
        if (currency.Length != 3) throw new DomainException("Use ISO 4217 codes");
        Amount = amount;
        Currency = currency;
    }

    public Money Add(Money other) =>
        Currency != other.Currency
            ? throw new DomainException("Can't add different currencies")
            : new Money(Amount + other.Amount, Currency);
}
public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount.signum() < 0) throw new DomainException("Money can't be negative");
        if (currency.length() != 3) throw new DomainException("Use ISO 4217 codes");
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new DomainException("Can't add different currencies");
        }
        return new Money(amount.add(other.amount), currency);
    }
}

Three things show up in every version.

The constructor enforces invariants. You can't get a Money instance into your system that isn't valid. The "currency must be three letters" rule is checked once, at the boundary, instead of being re-checked in every controller that touches money.

The object is immutable. add returns a new Money. It doesn't mutate. That means you can pass Money objects around freely. Two callers can't accidentally stomp on each other's value, because there's no shared mutable state.

And mixed-currency math is impossible. The moment you try to add USD to EUR, the model refuses. That's not a runtime check you remembered to add. It's a property of the type. The bug literally can't compile if you've designed the API right.

This is the move. Wrap primitives in value objects whenever the primitive has rules. Not every string. Not every number. But emails, money, durations, percentages, coordinates, phone numbers, postal codes, color hex strings. Anywhere a string or a number is hiding constraints, a value object pulls them into one place.

The fastest way to internalize this: count how many times your codebase validates an email format. The answer in most backends is "more than once and probably inconsistently." A class Email solves that. Once.

Aggregates: Where The Consistency Boundary Lives

Aggregates are where DDD gets a reputation for being hard. They don't have to be.

An aggregate is a cluster of entities and value objects that you treat as a single unit for the purpose of changes. One entity in the cluster is the aggregate root: the only thing the outside world is allowed to talk to. Everyone else is internal.

The classic example is an order with order lines.

TypeScript src/domain/order.ts
class Order {
  private lines: OrderLine[] = [];

  private constructor(
    public readonly id: OrderId,
    private readonly customerId: CustomerId,
    private status: OrderStatus,
  ) {}

  static place(id: OrderId, customerId: CustomerId): Order {
    return new Order(id, customerId, OrderStatus.Draft);
  }

  addLine(productId: ProductId, quantity: number, unitPrice: Money): void {
    if (this.status !== OrderStatus.Draft) {
      throw new DomainError("Can't add lines to a placed order");
    }
    if (quantity <= 0) {
      throw new DomainError("Quantity must be positive");
    }

    const existing = this.lines.find(l => l.productId.equals(productId));
    if (existing) {
      existing.increaseQuantity(quantity);
      return;
    }

    this.lines.push(new OrderLine(productId, quantity, unitPrice));
  }

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

  confirm(): void {
    if (this.lines.length === 0) {
      throw new DomainError("Can't confirm an empty order");
    }
    this.status = OrderStatus.Placed;
  }
}

A few things are doing real work here.

OrderLine is not exposed. There's no getLines() returning a mutable array. If you need to add a line, you go through addLine. If you need the total, you ask the order. The order owns its lines, and it owns the rules about them: no empty orders, no negative quantities, no edits after confirmation.

The constructor is private. The only way to create an order is Order.place(...), which is a deliberate factory. That stops every controller in the codebase from inventing its own constructor calls and skipping the rules.

The aggregate has one transactional boundary. When you save an Order, you save the order and its lines together, atomically. Outside callers don't know there are lines. They know there's an order with a total().

That last point is the entire reason aggregates exist. The aggregate is your consistency boundary: the thing you guarantee will be saved as a unit, in one transaction, with all its invariants intact.

Architecture diagram of an aggregate boundary: a dashed boundary encloses the Order header, three OrderLine items, and the Order Status; outside the boundary a Customer entity references the Order by ID only, and a database icon labels the cluster as saved in one transaction.

The hardest part of designing aggregates is deciding how big they should be.

Bigger aggregate, stronger consistency, more contention. If your Order aggregate also includes the customer and their entire purchase history, every order placement locks the customer record. Bad.

Smaller aggregate, weaker consistency, less contention, more eventual-consistency code on your end. If your Order only includes the order header and lines live in a separate aggregate, you've moved the "no empty orders" rule out of the model and into a service that has to coordinate two saves.

The rule of thumb: draw the aggregate boundary at the smallest cluster that still keeps your real invariants enforceable in one transaction. No more.

For an order with lines, that's order + lines. The customer is referenced by ID, not embedded. The product is referenced by ID, not embedded. Anything outside the boundary you reference, never own.

Repositories: Pretending The Database Doesn't Exist

If aggregates are the model's voice, repositories are how the rest of the system gets to talk to them without knowing they live in Postgres.

A repository is a collection-like interface for one aggregate. It hides everything about persistence: tables, joins, ORMs, queries. From the domain's point of view, you ask the repository for an order by ID, and you get an order. You hand the repository an order, and it's saved. That's the whole API.

TypeScript src/domain/order-repository.ts
export interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

That's the interface. It lives in the domain. It says nothing about SQL, NoSQL, ORM mappings, or which library you're using.

The implementation lives somewhere else, in your infrastructure layer.

TypeScript src/infrastructure/postgres-order-repository.ts
export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly db: Database) {}

  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.db.query(
      "SELECT * FROM orders WHERE id = $1",
      [id.value],
    );
    if (!row) return null;

    const lines = await this.db.query(
      "SELECT * FROM order_lines WHERE order_id = $1",
      [id.value],
    );

    return OrderMapper.toDomain(row, lines);
  }

  async save(order: Order): Promise<void> {
    await this.db.transaction(async tx => {
      const snapshot = OrderMapper.toPersistence(order);
      await tx.upsert("orders", snapshot.header);
      await tx.replaceAll("order_lines", { order_id: order.id.value }, snapshot.lines);
    });
  }
}

The domain has no idea this exists. The application service that needs an order asks for an OrderRepository. At wiring time, the container hands it the Postgres implementation, the in-memory implementation in tests, or a Redis-cached version in production, none of which the domain notices.

A few things repositories should not do.

They should not return half-loaded aggregates. If you load an Order, you load it whole, lines included. The aggregate is a unit; the repository's job is to honor that unit.

They should not have one-method-per-query that leaks domain logic. findOrdersWhereCustomerHasUnpaidInvoicesAndShippingAddressIsInternational is not a repository method. That's a query, and it belongs in a read-side query service, not in the write-side repository. (If you've heard the term CQRS, this is the seam where it bites.)

They should not return anemic DTOs. The whole point is that callers get back a real Order object with its behavior intact.

Layered architecture diagram showing where each DDD pattern lives: an Application Layer with use cases at the top, a Domain Layer in the middle with entities, value objects, aggregates, domain services, and the Repository interface, and an Infrastructure Layer at the bottom with the Repository implementation pointing to a database; arrows show both the application and infrastructure layers depending inward on the domain.

That arrow direction in the diagram is the most important detail of the whole pattern. Infrastructure depends on domain. Never the other way around. If your domain layer imports from your ORM, you don't have a domain layer. You have an ORM with extra steps.

Domain Services: When The Behavior Doesn't Belong To One Object

Sometimes a piece of business logic genuinely doesn't belong to any single entity or value object. It involves two of them. Or three. Or it's a calculation over external data that doesn't fit cleanly inside a domain object.

That's what domain services are for.

Money transfer between two accounts is the classic example. The rule is "debit one, credit the other, atomically, as long as the source has enough balance." Whose method is that? Account.transfer(other) is awkward. Why does the source account get to mutate the destination? Account.receive(money) and Account.send(money) separately leaves the atomicity floating. The honest answer is that the operation lives between two accounts.

TypeScript src/domain/transfer-service.ts
export class TransferService {
  transfer(source: Account, destination: Account, amount: Money): void {
    if (source.id.equals(destination.id)) {
      throw new DomainError("Can't transfer to the same account");
    }
    source.withdraw(amount);
    destination.deposit(amount);
  }
}

A few important things about domain services.

They're stateless. The service is just behavior. No fields, no persisted state. You can construct a fresh one for every call and nothing changes.

They live in the domain layer, not the application layer. There's a different thing called an application service. That's where you'd handle the use case ("transfer 100 USD from Alice to Bob"), load both accounts from repositories, call this domain service, then save them. The domain service itself knows nothing about loading or saving. It just enforces the rule.

They should be rare. If you find yourself writing six domain services for an aggregate, that's a smell. The behavior is probably trying to live on the entities and value objects, and your services are stealing it. The first question whenever you reach for a domain service should be: is there really no entity that owns this?

A Word On Domain Events

You'll see "domain events" listed alongside the patterns above. They're worth a brief mention even though they're not strictly the same shape.

A domain event is a named thing that happened in the domain. OrderPlaced. PaymentFailed. EmailChanged. The aggregate produces them when its state changes in a meaningful way.

TypeScript
class Order {
  private events: DomainEvent[] = [];

  confirm(): void {
    if (this.lines.length === 0) {
      throw new DomainError("Can't confirm an empty order");
    }
    this.status = OrderStatus.Placed;
    this.events.push(new OrderPlaced(this.id, this.total()));
  }

  pullEvents(): DomainEvent[] {
    const out = this.events;
    this.events = [];
    return out;
  }
}

After saving the aggregate, the application layer pulls the events and dispatches them: to other parts of the system, to a message broker, to an in-process handler that sends a confirmation email.

The point isn't event sourcing or any specific infrastructure. The point is that the domain gets to announce what happened in domain terms, and the rest of the system gets to listen without the domain knowing who's listening.

You can adopt domain events without adopting any of the other heavy machinery. They're a clean seam.

When DDD Is Overkill

It's worth saying this clearly so nobody walks away from this article and starts retrofitting aggregates into a 200-line internal admin tool.

Tactical DDD pays for itself when:

  • The domain has real rules, not just "this field must not be null", but "you can only refund within 90 days, except for enterprise customers, except during the holiday window, except if the payment method was a gift card." Rules with shape.
  • The domain outlives the framework. You expect the codebase to be around for years, with multiple developers, and you don't want the rules to leak across files.
  • The team has enough seniority to keep the patterns honest. Half-applied DDD (entities without identity equality, "repositories" that are just DAOs, "value objects" that are mutable) is worse than no DDD at all. It introduces the ceremony without the benefit.

It does not pay for itself when:

  • The system is genuinely CRUD. Forms in, rows out, no rules beyond NOT NULL and UNIQUE. Use the framework's scaffolding and move on.
  • The team is two developers shipping a prototype to validate a market. You don't know the domain yet. The patterns will calcify the wrong model.
  • The domain logic is already obviously somewhere else: a third-party service, a workflow engine, a rules engine. Don't re-implement it for the satisfaction of having a Money class.

The patterns are a tool, not a virtue. The point is to keep your business rules close to the data they govern, in a form that survives change. If a plain function and a Postgres row already do that, you don't need any of this.

How To Adopt This In An Existing Codebase

You don't rewrite the application. You don't introduce a "DDD package" and start sliding files into it. You add the patterns one at a time, where they earn their keep.

A reasonable order, for what it's worth:

Start with value objects. They're the lowest-risk pattern, and they have the highest immediate payoff. Pick one primitive that has rules (money, email, a date range) and wrap it. Watch how many duplicate validation calls disappear.

Then promote one entity to own its behavior. Pick the loudest one: the one whose rules are scattered across the most files. Move the rules into methods on the class. Make the fields private. Stop letting controllers reach in.

Then draw an aggregate boundary around it and the things it owns. Decide what's inside and what's referenced by ID.

Then introduce a repository in front of it. The implementation can keep using your ORM under the hood; the domain doesn't care.

Add a domain service only when you find behavior that genuinely doesn't belong to one entity. If you can't think of one, you probably don't need one yet.

Domain events come last, when you start to feel the pain of cross-cutting reactions ("when an order is placed, send an email, update analytics, notify the warehouse, ...") and you're tired of editing the same method every time a new reaction is added.

That's the whole onboarding plan. Five patterns, in order, applied where they help. No bounded contexts. No event storming workshop. No DDD certification.

Just better-shaped code, one piece at a time.

And the next time someone in a code review asks "but is that really an aggregate?", you'll have an actual answer. Which, honestly, is most of the battle.

Good luck modeling.