Guide

Domain-driven design explained

Domain-driven design (DDD) is a software design approach that puts the business problem — the domain — at the center of architecture. Instead of mirroring database tables with anemic data classes and scattering rules across controllers, DDD models the problem the way domain experts talk about it: with a shared ubiquitous language, explicit bounded contexts, and rich objects that enforce their own invariants. Eric Evans introduced the ideas in 2003; they remain the backbone of how teams draw microservice boundaries, structure event-sourced systems, and keep complex products coherent as they scale. This guide covers strategic DDD (contexts and maps), tactical building blocks (entities, value objects, aggregates), application vs domain services, anti-corruption layers, common failure modes, and when a lighter model is the smarter choice.

The anemic domain model problem

Many codebases look like this: a Order class with public getters and setters, a OrderService that validates status transitions, and a OrderController that orchestrates everything. Business rules live in services because the model is just a bag of fields — an anemic domain model. That works for CRUD admin panels but breaks down when rules multiply:

  • The same validation is duplicated in API handlers, batch jobs, and message consumers.
  • Changing "when can an order be cancelled?" requires grep across layers.
  • Tests mock entire databases instead of exercising pure domain logic.
  • Product language ("shipment", "fulfillment", "reservation") diverges from class names.

DDD responds by making the domain model behavior-rich: an Order aggregate knows it cannot ship after cancellation; a Money value object refuses to add currencies that do not match. Application services coordinate use cases; they do not re-implement business rules that belong inside the domain.

Strategic DDD: bounded contexts and context maps

Strategic DDD answers: where do we draw boundaries in a large system? A bounded context is a explicit boundary within which a particular domain model and ubiquitous language are consistent. The word Customer in billing (credit limit, invoices) is not the same concept as Customer in support (tickets, SLAs) — forcing one shared Customer table across both creates coupling and ambiguous rules.

Each bounded context owns its model, persistence, and API. Contexts integrate through well-defined contracts — REST, gRPC, or domain events on a message bus — not by sharing database tables. A context map diagrams these relationships: upstream/downstream, shared kernel, conformist, anti-corruption layer, open-host service. The map is a living artifact product and engineering refine together.

Ubiquitous language

Inside each context, developers and domain experts use the same vocabulary in conversations, user stories, code, and tests. If the warehouse team says "pick list", the code has PickList, not OrderLineBatch. When language drifts, the model drifts — schedule periodic glossary reviews after major feature launches.

Tactical building blocks

Tactical DDD provides patterns for structuring code inside a bounded context. Not every project needs all of them; pick what matches complexity.

Entities

An entity has a stable identity that persists over time. Two Order rows with different IDs are different orders even if line items match. Identity equality matters; attribute equality does not.

Value objects

A value object is defined entirely by its attributes and is immutable. Money(USD, 19.99), EmailAddress("user@example.com"), GeoCoordinate(37.77, -122.42) — replace the whole object when values change; do not mutate in place. Value objects encapsulate validation (email format, non-negative amounts) so invalid states cannot be constructed.

Aggregates and aggregate roots

An aggregate is a cluster of entities and value objects treated as one consistency boundary. The aggregate root is the only entry point — external code references the root ID, not inner entities. Example: Order (root) contains OrderLine items; you add lines through order.addLine(...), not by inserting into a lines table from a controller.

Aggregates enforce invariants inside transactions: total price matches sum of lines; quantity cannot go negative. Keep aggregates small — loading a 10,000-line order on every update is a performance trap. Prefer eventual consistency between aggregates via domain events rather than one giant transactional graph.

Repositories

A repository abstracts persistence for aggregate roots: OrderRepository.findById(id) returns a rehydrated Order aggregate, not a raw ORM entity graph. Repositories belong to the domain interface; SQL or document store implementations live in infrastructure. One repository per aggregate root — not one generic repository per table.

Domain events

When something meaningful happens inside the domain — OrderPlaced, PaymentCaptured — publish a domain event. Other aggregates or bounded contexts react asynchronously. This pairs naturally with event sourcing and CQRS, but events are useful even without full event stores: they decouple side effects (send email, update search index) from the core write path.

Domain services vs application services

A domain service holds logic that does not naturally belong on one entity — calculating shipping cost from weight and zone, or transferring funds between two accounts. An application service implements a use case: load aggregates, invoke domain methods, commit transactions, publish events. Application services stay thin; if you see 200-line methods with nested if-statements, rules probably belong deeper in the domain.

Anti-corruption layers

When your context consumes a legacy or third-party API whose model does not match yours, insert an anti-corruption layer (ACL): a translation boundary that converts external DTOs into your domain types and hides ugly semantics. Without an ACL, foreign field names and status codes leak into your ubiquitous language — "SAP status 7" becomes a magic number in business logic. The ACL is the only place that knows about the external system; upstream domain code speaks your language only.

ACLs complement adapter patterns from software design patterns but emphasize linguistic translation, not just interface wrapping.

DDD and microservices

A common heuristic: one bounded context maps to one deployable service (or one module within a modular monolith). Splitting along technical layers ("user service", "database service") recreates distributed monolith pain. Splitting along domain seams ("catalog", "checkout", "inventory") aligns team ownership and reduces cross-service chatter — though you still need sagas, idempotency, and outbox patterns for distributed workflows.

Start with a modular monolith: enforce bounded context package boundaries in one repo and one database schema per context (separate schemas, not separate tables shared everywhere). Extract to microservices only when independent scaling or team autonomy justifies operational cost.

When to use DDD — and when not to

Situation Recommendation
Complex business rules, many stakeholders, long-lived product Full strategic + tactical DDD — contexts, aggregates, events
Simple CRUD internal tool, few rules Transaction script or active record; skip aggregates
Early startup validating product-market fit Ubiquitous language + light bounded contexts; defer event sourcing
Integrating legacy ERP with modern app Anti-corruption layer mandatory; isolate legacy model
High-throughput read path, complex reporting Consider CQRS — separate write model from read projections
Team unfamiliar with DDD Start with value objects + one aggregate; grow patterns incrementally

Common mistakes

  • Big bang rewrite — declaring "we do DDD now" and freezing features for a six-month model redesign. Evolve one bounded context at a time.
  • Aggregate too large — one root per customer including every order, address, and preference ever. Split by transaction boundary and load lazily.
  • Anemic aggregates — classes named OrderAggregate that are still just getters/setters with services doing the work. Behavior must live inside.
  • Shared database across contexts — two services writing the same tables bypasses boundaries; use APIs or events instead.
  • Over-eventing — publishing OrderLineQuantityChanged for every keystroke. Events represent business facts stakeholders care about.
  • DDD as religion — forcing factories, specifications, and sagas into a todo app. Match pattern weight to problem weight.

Production checklist

  • Bounded contexts documented in a context map reviewed with product.
  • Ubiquitous language glossary maintained; code names match it.
  • Each aggregate root enforces invariants; external code cannot bypass the root.
  • Repositories return aggregates, not anemic ORM entities leaking into controllers.
  • Cross-context integration via contracts (API/events), not shared tables.
  • Anti-corruption layers wrap legacy and third-party integrations.
  • Domain events published after successful commit (outbox pattern for reliability).
  • Tests cover domain logic without database — fast unit tests on value objects and aggregates.

Key takeaways

  • Domain-driven design aligns software structure with how the business actually works.
  • Bounded contexts define where models and language are consistent; context maps show integration.
  • Aggregates protect invariants; the root is the only external entry point.
  • Value objects make invalid states unrepresentable through immutability and validation.
  • Adopt DDD incrementally — match pattern depth to domain complexity, not the other way around.

Related reading