Domain-Driven Design (DDD)
How to apply DDD to model complex domains, define bounded contexts, and build software aligned with the business.
What problem it solves
In complex systems, the biggest challenge is not technical but communicational. Developers speak one language, business experts speak another, and the software ends up being a flawed translation of both. The result: data models that donβt reflect business reality, logic scattered across the wrong layers, and changes that require modifying half the system.
Domain-Driven Design attacks this problem at the root: it proposes that software should directly model the business domain, using the same language that domain experts use. It is not a framework or a library β it is a design approach that puts the domain at the center of every architectural decision.
Symptoms that you need DDD
- The technical team and the business team donβt understand each other
- Data models are database tables disguised as objects
- A change in a business rule requires modifying multiple services
- There are no clear boundaries between the responsibilities of each part of the system
- Business logic is scattered across controllers, services, and repositories
- The names in the code donβt match the terms the business uses
A concrete example of the problem
Imagine an e-commerce system where the business team talks about βorders,β βreturns,β and βrefunds,β but the code has classes like DataProcessor, ItemHandler, and TransactionManager. When the business says βwe need to change the returns policy,β nobody knows which files to touch because there is no Return class or a Refunds module.
β Sin DDD β CΓ³digo desconectado del negocio:
src/
βββ handlers/
β βββ DataProcessor.java
β βββ ItemHandler.java
β βββ TransactionManager.java
βββ models/
β βββ Record.java
β βββ Entry.java
βββ utils/
βββ Helper.java
β
Con DDD β CΓ³digo alineado con el dominio:
src/
βββ pedidos/
β βββ Pedido.java
β βββ LΓneaDePedido.java
β βββ RepositorioPedidos.java
βββ devoluciones/
β βββ DevoluciΓ³n.java
β βββ PolΓticaDevoluciΓ³n.java
β βββ ServicioReembolso.java
βββ envΓos/
βββ EnvΓo.java
βββ TransportistaAdapter.java
How it works
DDD is divided into two major areas: strategic design (how to divide the system) and tactical design (how to model each part).
Strategic design
Ubiquitous Language
This is the shared vocabulary between developers and domain experts. If the business talks about βorders,β βshipments,β and βreturns,β the code must use exactly those terms β not βorders_table,β βshipping_record,β or βreturn_entity.β
β CΓ³digo sin lenguaje ubicuo:
class DataRecord { processItem() }
β
CΓ³digo con lenguaje ubicuo:
class Pedido { confirmarEnvΓo() }
The ubiquitous language is documented, maintained, and used in conversations, code, tests, and documentation. It is not just a naming convention β it is an active agreement between business and development that evolves with the domain.
How to build the ubiquitous language:
- Run Event Storming sessions with domain experts and developers
- Identify the key terms the business uses and document them in a shared glossary
- Use those exact terms in the code, the tests, the documentation, and conversations
- When ambiguity arises (the same word means different things), itβs a sign that you need separate bounded contexts
Bounded Contexts
A bounded context is an explicit boundary within which a domain model has consistent meaning. The same word can mean different things in different contexts:
- βCustomerβ in the Sales context: someone who buys products
- βCustomerβ in the Support context: someone who opens tickets
- βCustomerβ in the Billing context: an entity with tax data
βββββββββββββββββββββββ βββββββββββββββββββββββ
β Contexto: Ventas β β Contexto: Soporte β
β β β β
β Cliente β β Cliente β
β ββ nombre β β ββ nombre β
β ββ historialCompra β β ββ ticketsAbiertos β
β ββ descuento β β ββ nivelPrioridad β
β β β β
β Pedido β β Ticket β
β ββ lΓneas β β ββ descripciΓ³n β
β ββ total β β ββ estado β
βββββββββββββββββββββββ βββββββββββββββββββββββ
Each bounded context has its own model, its own database, and its own team (ideally). Communication between contexts happens through well-defined interfaces.
Practical rules for identifying bounded contexts:
- If two teams need different models of the same concept β separate contexts
- If a change in one area shouldnβt affect another β separate contexts
- If the ubiquitous language changes β youβre crossing a context boundary
- A bounded context isnβt necessarily a microservice, but itβs a natural candidate to become one
Context Mapping
Defines how bounded contexts relate to each other:
| Relationship | Description | Example |
|---|---|---|
| Shared Kernel | Two contexts share a subset of the model | Currency types shared between Sales and Billing |
| Customer-Supplier | One context provides data that another consumes | Inventory (supplier) provides stock to Orders (customer) |
| Conformist | A context adapts to anotherβs model without negotiation | Your system adapts to the model of an external providerβs API |
| Anti-Corruption Layer | A context translates the external model into its own language | An adapter that translates responses from a legacy system |
| Open Host Service | A context exposes an open protocol for others to consume | Public REST API with OpenAPI documentation |
| Published Language | A documented shared language is used (JSON Schema, Protobuf) | Event contracts in Avro format |
Tactical design
Entities
Objects with a unique identity that persists over time. Two entities with the same attributes but a different ID are different.
Pedido #1234 β Pedido #5678
(aunque ambos tengan los mismos productos y el mismo total)
Entities encapsulate behavior, not just data. An Order is not a DTO with getters and setters β it has methods that reflect domain operations:
// β Modelo anΓ©mico (anti-patrΓ³n)
pedido.setEstado("confirmado");
pedido.setFechaConfirmacion(new Date());
// β
Modelo rico con comportamiento
pedido.confirmar(); // internamente valida reglas y cambia estado
Value Objects
Objects defined by their attributes, with no identity of their own. Two value objects with the same attributes are equal. They are immutable.
DirecciΓ³n("Calle 1", "Ciudad A") == DirecciΓ³n("Calle 1", "Ciudad A")
Dinero(100, "USD") == Dinero(100, "USD")
Value objects are more common than they seem. Many concepts we model as strings or primitive numbers should be value objects:
// β Tipos primitivos
String email = "user@example.com";
double precio = 99.99;
// β
Value objects con validaciΓ³n
Email email = new Email("user@example.com"); // valida formato
Dinero precio = new Dinero(99.99, "USD"); // evita mezclar monedas
Aggregates
A cluster of entities and value objects that are treated as a unit of consistency. Each aggregate has a root (Aggregate Root) that is the only external point of access.
βββββββββββββββββββββββββββββββ
β Aggregate: Pedido β
β βββββββββββββββββββββββββ β
β β Pedido (Aggregate Root)β β
β β ββ id β β
β β ββ estado β β
β β ββ fechaCreaciΓ³n β β
β βββββββββββββββββββββββββ β
β ββββββββββββ ββββββββββββ β
β β LΓneaPed β β LΓneaPed β β
β β producto β β producto β β
β β cantidad β β cantidad β β
β β precio β β precio β β
β ββββββββββββ ββββββββββββ β
β ββββββββββββββββββββββββ β
β β DirecciΓ³nEnvΓo (VO) β β
β ββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββ
Key rules for aggregates:
- Transactions should only modify one aggregate at a time
- References between aggregates are made by ID, not by direct reference
- If you need to modify several aggregates, use Domain Events
- Keep aggregates small β a large aggregate is a sign of incorrect boundaries
Domain Events
They represent something that happened in the domain. They are expressed in the past tense and in business language.
PedidoConfirmado { pedidoId, fecha, total }
PagoRecibido { pagoId, pedidoId, monto }
EnvΓoDespachado { envΓoId, pedidoId, transportista }
Events allow bounded contexts to communicate in a decoupled way. They are the foundation of Event-Driven Architecture and of the Saga Pattern for coordinating distributed transactions.
Example of an event-driven flow:
Contexto Ventas:
pedido.confirmar()
β publica PedidoConfirmado
Contexto Inventario:
escucha PedidoConfirmado
β reserva stock
β publica StockReservado
Contexto EnvΓos:
escucha StockReservado
β programa envΓo
β publica EnvΓoProgramado
Repositories
Abstractions that encapsulate data access. The domain doesnβt know whether the data comes from a database, an API, or a file. The repository speaks the language of the domain.
β pedidoDAO.selectByStatusAndDate(status, date)
β
repositorioPedidos.buscarPendientesDesde(fecha)
Domain Services
Operations that donβt naturally belong to any entity or value object. They contain business logic that involves multiple aggregates.
ServicioDePrecios.calcularDescuento(cliente, pedido, promociΓ³n)
ServicioDeEnvΓo.seleccionarTransportista(pedido, destino)
Practical example: E-commerce with DDD
Letβs see how DDD is applied to a real e-commerce system:
Step 1: Identify bounded contexts
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β CatΓ‘logo β β Pedidos β β Pagos β
β β β β β β
β Producto β β Pedido β β TransacciΓ³n β
β CategorΓa β β LΓneaDePedido β β MΓ©todoDePago β
β Precio β β DirecciΓ³nEnvΓo β β Reembolso β
β Inventario β β EstadoPedido β β EstadoPago β
ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ
β β β
β Eventos β Eventos β
ββββββββββββββββββββββ΄βββββββββββββββββββββ
Step 2: Define aggregates in the Orders context
Aggregate Root: Pedido
βββ id: PedidoId (Value Object)
βββ cliente: ClienteId (referencia por ID, no por objeto)
βββ lΓneas: List<LΓneaDePedido>
β βββ productoId: ProductoId
β βββ cantidad: Cantidad (Value Object, > 0)
β βββ precioUnitario: Dinero (Value Object)
βββ direcciΓ³n: DirecciΓ³nEnvΓo (Value Object)
βββ estado: EstadoPedido (enum)
βββ total(): Dinero (calculado)
Invariantes del agregado:
- Un pedido debe tener al menos una lΓnea
- El total nunca puede ser negativo
- Solo se puede confirmar un pedido en estado "borrador"
- Solo se puede cancelar un pedido que no ha sido enviado
Step 3: Implement behavior in the aggregate
class Pedido {
confirmar() {
if (this.estado !== 'borrador')
throw new Error('Solo se puede confirmar un pedido en borrador');
if (this.lΓneas.length === 0)
throw new Error('El pedido debe tener al menos una lΓnea');
this.estado = 'confirmado';
this.fechaConfirmaciΓ³n = new Date();
this.registrarEvento(new PedidoConfirmado(this.id, this.total()));
}
cancelar(motivo) {
if (this.estado === 'enviado' || this.estado === 'entregado')
throw new Error('No se puede cancelar un pedido ya enviado');
this.estado = 'cancelado';
this.motivoCancelaciΓ³n = motivo;
this.registrarEvento(new PedidoCancelado(this.id, motivo));
}
}
Advantages
- Business-technology alignment: The code directly reflects the domain, reducing the communication gap
- Clear boundaries: Bounded contexts define explicit boundaries that make independent evolution easier
- Rich model: Entities contain behavior, not just data, avoiding anemic models
- Organizational scalability: Each bounded context can be developed by an independent team
- Controlled evolution: Changes in one context donβt affect others as long as the interfaces are maintained
- Foundation for microservices: Bounded contexts are natural candidates to become microservices
- Clearer testing: Tests reflect real business rules, not technical implementations
Trade-offs / Disadvantages
- Steep learning curve: DDD introduces many concepts that take time to master
- Overhead in simple domains: For basic CRUDs, DDD adds unnecessary complexity
- Requires access to domain experts: Without constant collaboration with the business, the model drifts
- More initial code: Entities, value objects, repositories, and events require more code than a direct approach
- Risk of over-engineering: Itβs easy to apply all the tactical patterns when you only need a few
- Intentional duplication: Each bounded context may have its own representation of similar concepts
- Investment in discovery: Event Storming and modeling sessions require time and active participation from the business
When to use
- Complex domains with many business rules
- Systems that evolve frequently due to business changes
- Large teams that need to work in parallel with clear boundaries
- Projects where communication between business and development is critical
- Systems being migrated from a monolith to microservices
- When the cost of an incorrect model is high (finance, healthcare, logistics)
When to avoid
- Simple CRUD applications with no significant business logic
- Prototypes or MVPs where speed matters more than structure
- Small teams without access to domain experts
- Systems with well-understood, stable domains that donβt change
- Projects with a very limited budget or time for the design phase
Common technologies and implementations
| Category | Options |
|---|---|
| DDD frameworks | Axon Framework (Java), EventSourcing.NetCore (C#), Ecotone (PHP) |
| Event Sourcing | EventStoreDB, Axon Server, Marten (.NET) |
| CQRS | MediatR (.NET), Axon (Java), custom implementations |
| Messaging | Apache Kafka, RabbitMQ, Amazon EventBridge |
| Modeling | Event Storming, Domain Storytelling, Context Mapping |
| Persistence | PostgreSQL, MongoDB (per bounded context) |
Relationship with other patterns
DDD is the conceptual foundation on which many other distributed architecture patterns are built:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Domain-Driven Design β
β (Bounded Contexts + Ubiquitous Language) β
ββββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββββββ
β β β
βΌ βΌ βΌ
ββββββββββββ ββββββββββββ ββββββββββββββββ
βMicroserv.β β Event β β Shared β
β Pattern β β Driven β β Kernel β
ββββββββββββ ββββββββββββ ββββββββββββββββ
β β
βΌ βΌ
ββββββββββββ ββββββββββββ
β Saga β β Outbox β
β Pattern β β Pattern β
ββββββββββββ ββββββββββββ
- Microservices Pattern: DDDβs bounded contexts are the guide for defining the boundaries of each microservice
- Event-Driven Architecture: DDDβs Domain Events are the foundation of asynchronous communication between services
- Saga Pattern: Coordinates transactions that cross multiple bounded contexts using events and compensations
- Outbox Pattern: Guarantees that Domain Events are published reliably together with data changes
- Anti-Corruption Layer (ACL): Implements the Context Mapping relationship that protects your domain from external models
- Shared Kernel: Implements the Context Mapping relationship where two contexts share a subset of the model
- API Gateway / BFF: Provide entry points that can map external requests to the internal bounded contexts
Common mistakes when applying DDD
| Mistake | Consequence | Solution |
|---|---|---|
| Applying DDD to the entire system | Over-engineering in simple areas | Use DDD only in the core domain, CRUD for the rest |
| Bounded contexts that are too large | Distributed monolith | Split by real subdomains, not by technical layers |
| Bounded contexts that are too small | Chatty services | Group functionality that changes together |
| Ignoring the ubiquitous language | Code disconnected from the business | Regular sessions with domain experts |
| Anemic model | Logic scattered across services | Move behavior into the entities |
| Aggregates that are too large | Concurrency problems | Keep aggregates small, use events between them |
Next steps
With DDD as the foundation for modeling complex domains, the natural next step is to understand how to handle transactions that cross multiple bounded contexts. Thatβs what the Saga Pattern is for, coordinating distributed operations with compensations. Also explore the Microservices Pattern to understand how bounded contexts turn into independent services.