Home CS Fundamentals Domain-Driven Design: Strategic & Tactical Patterns for Complex Software
Advanced 4 min · July 13, 2026

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.

N
Naren Founder & Principal Engineer

20+ years shipping production systems from the metal up. Drawn from code that ran under real load.

Follow
Production
production tested
July 13, 2026
last updated
2,165
articles · all by Naren
Before you start⏱ 20-25 min read
  • Basic understanding of object-oriented programming
  • Familiarity with software design patterns
  • Experience with a statically typed language (Java, C#, etc.)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

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.

✦ Definition~90s read
What is Domain-Driven Design?

Domain-Driven Design is a software development approach that focuses on modeling software to match a complex business domain, using strategic patterns like bounded contexts and tactical patterns like aggregates and domain events.

Think of DDD like building a city.
Plain-English First

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.

bounded-contexts-example.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Simulating bounded contexts with separate directories
mkdir -p sales/src/main/java/com/company/sales/domain
mkdir -p shipping/src/main/java/com/company/shipping/domain

# Sales Order entity (simplified)
cat > sales/src/main/java/com/company/sales/domain/Order.java << 'EOF'
package com.company.sales.domain;

public class Order {
    private String id;
    private OrderStatus status;
    private Money total;

    public void markPaid() {
        this.status = OrderStatus.PAID;
        // publish OrderPaid event
    }
}
EOF

# Shipping Order entity
cat > shipping/src/main/java/com/company/shipping/domain/Order.java << 'EOF'
package com.company.shipping.domain;

public class Order {
    private String id;
    private ShippingStatus status;
    private Address shippingAddress;

    public void prepareForShipment() {
        this.status = ShippingStatus.READY_TO_SHIP;
    }
}
EOF

echo "Bounded contexts created with separate Order classes."
Output
Bounded contexts created with separate Order classes.
💡Ubiquitous Language in Practice
📊 Production Insight
In microservices, each service is typically a bounded context. However, beware of 'distributed big ball of mud'—ensure each service has a clear domain boundary and doesn't share databases.
🎯 Key Takeaway
Bounded contexts and ubiquitous language prevent semantic drift and keep the code aligned with the business.

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).

entity-value-object.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Example: Entity and Value Object in Java (pseudocode)
cat > EntityValueObjectExample.java << 'EOF'
// Value Object: Money
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // No setters, immutable
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) throw new IllegalArgumentException();
        return new Money(this.amount.add(other.amount), this.currency);
    }

    @Override
    public boolean equals(Object o) { ... } // based on amount and currency
}

// Entity: Account
public class Account {
    private final AccountId id; // value object for identity
    private Money balance;

    public void deposit(Money amount) {
        this.balance = this.balance.add(amount);
    }
}
EOF
echo "Entity and Value Object example created."
Output
Entity and Value Object example created.
🔥When to Use Value Objects
📊 Production Insight
In distributed systems, value objects can be serialized and sent across services without worrying about identity collisions. Entities require careful handling of identity across contexts.
🎯 Key Takeaway
Entities have identity; value objects are defined by their attributes. Favor value objects to simplify domain logic.

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.

aggregate-example.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Simulating an Order aggregate
cat > OrderAggregate.java << 'EOF'
public class Order {
    private OrderId id;
    private List<OrderLine> lines;
    private Money total;

    public void addProduct(Product product, int quantity) {
        // enforce invariant: quantity > 0
        if (quantity <= 0) throw new IllegalArgumentException();
        OrderLine line = new OrderLine(product, quantity);
        lines.add(line);
        total = total.add(line.getSubtotal());
        // publish OrderLineAdded event
    }

    // No public setter for lines; only through root methods
}
EOF
echo "Aggregate root example created."
Output
Aggregate root example created.
⚠ Aggregate Design Pitfalls
📊 Production Insight
In event-sourced systems, aggregates are rebuilt from event streams. Ensure aggregate boundaries are stable to avoid breaking event replay.
🎯 Key Takeaway
Aggregates enforce consistency boundaries. Keep them small and use the root as the only access point.

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.

domain-event-example.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Simulating domain event publication
cat > DomainEventExample.java << 'EOF'
public class Order {
    private List<DomainEvent> events = new ArrayList<>();

    public void pay() {
        // business logic
        this.status = OrderStatus.PAID;
        // record event
        events.add(new OrderPaidEvent(this.id, LocalDateTime.now()));
    }

    public List<DomainEvent> getEvents() { return events; }
}

// Event class
public class OrderPaidEvent implements DomainEvent {
    private final String orderId;
    private final LocalDateTime occurredOn;
    // constructor, getters
}
EOF
echo "Domain event example created."
Output
Domain event example created.
💡Event Naming Convention
📊 Production Insight
Be careful with event versioning. As your domain evolves, events may change. Plan for backward compatibility or use a schema registry.
🎯 Key Takeaway
Domain events decouple aggregates and bounded contexts. Use them to propagate state changes reliably.

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.

repository-factory.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Repository interface
cat > OrderRepository.java << 'EOF'
public interface OrderRepository {
    Order findById(OrderId id);
    void save(Order order);
    void delete(OrderId id);
}
EOF

# Factory
cat > OrderFactory.java << 'EOF'
public class OrderFactory {
    public static Order createOrder(CustomerId customerId, List<OrderLine> lines) {
        Order order = new Order(OrderId.generate(), customerId);
        for (OrderLine line : lines) {
            order.addLine(line);
        }
        return order;
    }
}
EOF
echo "Repository and Factory examples created."
Output
Repository and Factory examples created.
🔥Repository vs DAO
📊 Production Insight
In microservices, each service has its own repository. Avoid cross-service queries; instead, use API composition or CQRS.
🎯 Key Takeaway
Repositories and factories isolate domain logic from infrastructure. Use them to manage persistence and complex creation.

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.

anti-corruption-layer.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Anti-Corruption Layer example (pseudocode)
cat > AntiCorruptionLayer.java << 'EOF'
public class LegacyCrmCustomerAdapter {
    private LegacyCrmApi api;

    public Customer toCustomer(LegacyCrmCustomer legacy) {
        // translate legacy model to domain model
        return new Customer(
            new CustomerId(legacy.getId()),
            new FullName(legacy.getFirstName(), legacy.getLastName()),
            new Email(legacy.getEmailAddress())
        );
    }
}
EOF
echo "Anti-Corruption Layer example created."
Output
Anti-Corruption Layer example created.
⚠ Avoid Shared Kernel Overuse
📊 Production Insight
When integrating with third-party APIs, always use an ACL. This protects your domain from changes in external systems and makes testing easier.
🎯 Key Takeaway
Context maps and anti-corruption layers help integrate bounded contexts without contaminating your domain model.
● Production incidentPOST-MORTEMseverity: high

The Shipping Disaster: When Bounded Contexts Collide

Symptom
Customers received shipping notifications for orders that were still pending payment.
Assumption
The developer assumed that the 'Order' entity in the sales context was the same as in the shipping context.
Root cause
The sales and shipping contexts shared the same database table for orders, but had different lifecycle states. The shipping service polled for 'ready to ship' orders but didn't check payment status because that field was in a different bounded context.
Fix
Introduced a bounded context boundary: the shipping context now listens to a domain event 'OrderPaid' from the sales context. It only processes orders after receiving that event, using its own local representation of the order.
Key lesson
  • 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.
Production debug guideSymptom to Action4 entries
Symptom · 01
Business logic is scattered across multiple services, and changes in one area break another.
Fix
Identify bounded contexts by analyzing business capabilities and team structures. Use event storming to discover aggregates and domain events.
Symptom · 02
Developers and domain experts disagree on the meaning of key terms (e.g., 'customer').
Fix
Establish a ubiquitous language per bounded context. Create a glossary and enforce it in code reviews.
Symptom · 03
Aggregate roots become bloated, causing performance issues or transactional conflicts.
Fix
Review aggregate boundaries. Ensure aggregates are small and consistent. Consider splitting into smaller aggregates with eventual consistency via domain events.
Symptom · 04
Domain events are lost or duplicated, leading to inconsistent state.
Fix
Implement an outbox pattern: persist events in the same transaction as the aggregate, then publish reliably. Use idempotent handlers.
★ Quick Debug Cheat Sheet for DDDCommon symptoms and immediate actions for DDD issues.
Ambiguous terminology causing bugs
Immediate action
Call a meeting with domain experts to define terms
Commands
grep -r 'Order' src/ | sort | uniq -c
cat BOUNDED_CONTEXTS.md
Fix now
Rename classes and methods to match the ubiquitous language
Aggregate too large, slow transactions+
Immediate action
Identify which parts of the aggregate change independently
Commands
git log --oneline -- src/domain/aggregates/OrderAggregate.java
grep -r 'OrderAggregate' src/ --include='*.java' | wc -l
Fix now
Split aggregate into smaller ones, use domain events for consistency
Domain events not being processed+
Immediate action
Check event publishing and subscription logs
Commands
kubectl logs -l app=event-publisher --tail=50
curl -X GET http://event-store/api/events?status=pending
Fix now
Implement outbox pattern and retry mechanism
PatternTypePurposeExample
Bounded ContextStrategicDefines semantic boundarySales context vs Shipping context
Ubiquitous LanguageStrategicShared language within contextOrder means 'pending payment' in Sales
EntityTacticalObject with identityCustomer, Order
Value ObjectTacticalObject defined by attributesMoney, Address
AggregateTacticalCluster of objects with rootOrder with OrderLines
Domain EventTacticalRecords something that happenedOrderPlaced, PaymentReceived
RepositoryTacticalPersistence for aggregatesOrderRepository
FactoryTacticalComplex object creationOrderFactory
Anti-Corruption LayerStrategicProtects model from externalLegacyCrmAdapter
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
bounded-contexts-example.shmkdir -p sales/src/main/java/com/company/sales/domainStrategic Patterns
entity-value-object.shcat > EntityValueObjectExample.java << 'EOF'Tactical Patterns
aggregate-example.shcat > OrderAggregate.java << 'EOF'Tactical Patterns
domain-event-example.shcat > DomainEventExample.java << 'EOF'Domain Events
repository-factory.shcat > OrderRepository.java << 'EOF'Repositories and Factories
anti-corruption-layer.shcat > AntiCorruptionLayer.java << 'EOF'Strategic Design

Key takeaways

1
Domain-Driven Design aligns software with business domain through strategic and tactical patterns.
2
Strategic patterns (bounded contexts, context maps) manage large-scale complexity and team coordination.
3
Tactical patterns (entities, value objects, aggregates, domain events) provide building blocks for a rich domain model.
4
Always use a ubiquitous language within each bounded context to ensure shared understanding.
5
Keep aggregates small and use domain events for eventual consistency between aggregates.

Common mistakes to avoid

4 patterns
×

Sharing 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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the concept of a bounded context and why it's important.
Q02JUNIOR
How do you decide whether to model something as an Entity or a Value Obj...
Q03SENIOR
What is an Aggregate Root and what rules govern its design?
Q04SENIOR
Describe a scenario where you would use an Anti-Corruption Layer.
Q01 of 04SENIOR

Explain the concept of a bounded context and why it's important.

ANSWER
A bounded context is a semantic boundary within which a particular domain model is defined and applicable. It's important because it prevents ambiguity and allows different parts of a system to evolve independently. For example, 'Order' in sales context may have different meaning than in shipping context. By defining explicit boundaries, you avoid coupling and miscommunication.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between strategic and tactical DDD?
02
When should I use DDD?
03
Can DDD be used with microservices?
04
What is the outbox pattern?
N
Naren Founder & Principal Engineer

20+ years shipping production systems from the metal up. Drawn from code that ran under real load.

Follow
Verified
production tested
July 13, 2026
last updated
2,165
articles · all by Naren
🔥

That's Software Engineering. Mark it forged?

4 min read · try the examples if you haven't

Previous
Event-Driven Architecture: Patterns and Implementation
20 / 23 · Software Engineering
Next
Git Advanced: Rebase, Cherry-Pick, Bisect, Submodules