Domain-Driven Design: Strategic & Tactical Patterns for Complex Software
Master Domain-Driven Design (DDD) with strategic patterns like bounded contexts and tactical patterns like aggregates.
20+ years shipping production systems from the metal up. Drawn from code that ran under real load.
- ✓Basic understanding of object-oriented programming
- ✓Familiarity with software design patterns
- ✓Experience with a statically typed language (Java, C#, etc.)
Domain-Driven Design (DDD) is a software development approach that aligns code with business domain models. Strategic patterns (bounded contexts, ubiquitous language) manage large-scale complexity, while tactical patterns (entities, value objects, aggregates, domain events) structure the core domain logic. DDD helps teams build maintainable, flexible systems by focusing on the core domain and using a common language between developers and domain experts.
Think of DDD like building a city. The strategic patterns are the city planning: you decide where residential, commercial, and industrial zones go (bounded contexts) and everyone agrees on street names (ubiquitous language). The tactical patterns are the building codes: how to construct houses (entities), what materials are interchangeable (value objects), and how to manage a block of houses as a single unit (aggregates). Without this planning, you'd have chaotic, hard-to-maintain sprawl.
Imagine you're building a system for an e-commerce platform. The business team talks about 'orders,' 'inventory,' and 'shipping.' The development team writes code with tables like Order, Product, and Shipment. But soon, the meaning of 'order' differs between departments: sales considers an order 'placed' when payment is confirmed, while logistics considers it 'ready to ship' only after inventory allocation. This semantic gap leads to bugs, miscommunication, and fragile code. Domain-Driven Design (DDD) solves this by putting the domain model at the center of software design. DDD, introduced by Eric Evans in his seminal book, provides a set of strategic and tactical patterns to tackle complex business logic. Strategic patterns help you decompose a large system into bounded contexts, each with its own ubiquitous language. Tactical patterns give you building blocks like entities, value objects, aggregates, and domain events to model the core domain precisely. This tutorial will guide you through both strategic and tactical DDD patterns with practical examples, a real-world production incident, and debugging techniques. By the end, you'll be able to apply DDD to build software that truly reflects the business domain.
Strategic Patterns: Bounded Contexts and Ubiquitous Language
Strategic patterns in DDD help you manage large-scale complexity by dividing the system into bounded contexts. A bounded context is a semantic boundary within which a particular model is defined and applicable. Each bounded context has its own ubiquitous language—a shared language between developers and domain experts that is consistent within that context. For example, in an e-commerce system, the 'Sales' context might define 'Order' as a pending payment, while the 'Shipping' context defines 'Order' as a package ready to dispatch. These are different concepts, and they should be modeled separately. To implement bounded contexts, you can use separate modules, services, or even microservices. Communication between contexts happens through events or APIs. The key is to avoid sharing the same database or domain objects across contexts. This prevents coupling and allows each context to evolve independently. Practical example: In a Java project, you might have packages like com.company.sales and com.company.shipping, each with its own Order class. The sales Order has a status field with values like PENDING_PAYMENT, while the shipping Order has status values like READY_TO_SHIP. They communicate via a domain event OrderPaid that the sales context publishes and the shipping context subscribes to.
Tactical Patterns: Entities and Value Objects
Tactical patterns provide building blocks for modeling the domain. Two fundamental patterns are Entities and Value Objects. An Entity is an object with a distinct identity that runs through time and different states. For example, a Customer entity has a unique ID and can change its name or address. Two customers with the same attributes are still different if they have different IDs. A Value Object, on the other hand, is defined only by its attributes. It has no identity. For example, an Address value object is considered equal to another Address if all fields match. Value objects are immutable and can be shared. In practice, use value objects for concepts like money, dates, or coordinates. Entities are used for things that need to be tracked individually, like orders, products, or users. When modeling, prefer value objects over entities when identity is not important. This reduces complexity and improves performance. Example: In a banking system, an Account is an entity (each account has a unique number), while Money is a value object (two instances of $100 are interchangeable).
Tactical Patterns: Aggregates and Aggregate Roots
An Aggregate is a cluster of domain objects that can be treated as a single unit. Each aggregate has an Aggregate Root, which is the only entry point for accessing the aggregate. The root ensures consistency and enforces invariants. For example, an Order aggregate might consist of the Order entity (root) and a collection of OrderLine value objects. All operations on the order lines must go through the Order root. This ensures that business rules like 'order total must be positive' are always enforced. Aggregates define transactional boundaries: changes to an aggregate are committed atomically. This simplifies concurrency and consistency. When designing aggregates, keep them small and focused. A common mistake is to make aggregates too large, leading to performance bottlenecks and contention. Instead, identify which objects change together and group them. Use domain events to communicate changes between aggregates. Example: In a blogging platform, a Post aggregate might include Post (root), Comment entities, and Tag value objects. Adding a comment goes through post.addComment(), which validates the comment and updates the post's comment count.
Domain Events: Communicating Between Aggregates
Domain events capture something that happened in the domain that domain experts care about. They are immutable records of past events. For example, OrderPlaced, PaymentReceived, InventoryDepleted. Domain events are used to communicate between aggregates within the same bounded context or across contexts. They enable eventual consistency and decoupling. When an aggregate changes state, it publishes one or more domain events. Other aggregates or services can subscribe to these events and react accordingly. This pattern is central to CQRS and event sourcing. Implementation: Typically, you store events in an event store or publish them to a message broker. To ensure reliability, use the outbox pattern: persist events in the same database transaction as the aggregate, then a separate process publishes them. Example: When an Order is paid, it publishes OrderPaid event. The Shipping context listens and creates a shipment. This avoids tight coupling between sales and shipping.
Repositories and Factories: Managing Persistence and Creation
Repositories provide a way to retrieve and persist aggregates. They abstract the underlying data store, allowing the domain to remain persistence-ignorant. Each aggregate root typically has its own repository. For example, OrderRepository has methods like findById(OrderId id) and save(Order order). Repositories return fully reconstructed aggregates. Factories encapsulate the creation of complex aggregates or value objects. They ensure that all invariants are satisfied at creation time. For example, an OrderFactory might create an Order with a given customer ID and initial line items. Factories can be static methods or separate classes. Together, repositories and factories keep the domain model clean by moving infrastructure concerns out of the domain.
Strategic Design: Context Mapping and Anti-Corruption Layers
When multiple bounded contexts interact, you need context maps to describe the relationships. Common patterns include Partnership (two contexts cooperate), Shared Kernel (shared subset of model), Customer-Supplier (one context provides data to another), and Conformist (one context conforms to another's model). An Anti-Corruption Layer (ACL) is a protective boundary that translates between two contexts, preventing the other context's model from leaking into your own. For example, if your sales context needs customer data from a legacy CRM, you implement an ACL that translates the CRM's customer model into your sales context's Customer entity. This keeps your domain model clean and independent. Context mapping is crucial for integrating with external systems or legacy code.
The Shipping Disaster: When Bounded Contexts Collide
- Always define explicit bounded contexts with clear interfaces (events, APIs).
- Never share database tables across bounded contexts; duplicate data as needed.
- Use domain events to communicate state changes between contexts.
- Ensure each context has its own ubiquitous language; avoid ambiguous terms like 'order' without qualification.
- Test integration points between contexts with contract tests.
grep -r 'Order' src/ | sort | uniq -ccat BOUNDED_CONTEXTS.md| File | Command / Code | Purpose |
|---|---|---|
| bounded-contexts-example.sh | mkdir -p sales/src/main/java/com/company/sales/domain | Strategic Patterns |
| entity-value-object.sh | cat > EntityValueObjectExample.java << 'EOF' | Tactical Patterns |
| aggregate-example.sh | cat > OrderAggregate.java << 'EOF' | Tactical Patterns |
| domain-event-example.sh | cat > DomainEventExample.java << 'EOF' | Domain Events |
| repository-factory.sh | cat > OrderRepository.java << 'EOF' | Repositories and Factories |
| anti-corruption-layer.sh | cat > AntiCorruptionLayer.java << 'EOF' | Strategic Design |
Key takeaways
Common mistakes to avoid
4 patternsSharing the same database across bounded contexts.
Making aggregates too large (e.g., an Order aggregate containing all order history).
Using entities for everything, even when identity is not needed.
Neglecting the ubiquitous language and letting technical terms dominate.
Interview Questions on This Topic
Explain the concept of a bounded context and why it's important.
Frequently Asked Questions
20+ years shipping production systems from the metal up. Drawn from code that ran under real load.
That's Software Engineering. Mark it forged?
4 min read · try the examples if you haven't