Domain-Driven Design (DDD) is a software development approach that puts your business domain at the center of every architectural and implementation decision. Its core insight is simple but demanding: the structure of your code should mirror the structure of your business model, and both developers and domain experts should speak the same language when describing the system’s behavior. DDD is most valuable in complex domains—order management, financial workflows, multi-tenant platforms—where the business rules are intricate enough that a superficial data-centric model leads to a big ball of mud. For simple CRUD applications, the overhead of DDD is usually not justified.
Core DDD Concepts
Domain and Subdomains
A domain is the subject area your software addresses—the business problem space. An e-commerce platform’s domain includes everything from catalog browsing and shopping carts to payment processing and logistics.
Complex domains are divided into subdomains, each focused on a distinct business capability:
| Subdomain Type | Description | Examples |
|---|
| Core | Your primary competitive differentiator; invest the most here | Order management, pricing engine |
| Supporting | Necessary but not a differentiator; can build in-house | Notifications, reporting |
| Generic | Commodity functionality; buy or use open source | Authentication, email delivery |
Identifying subdomain types guides where to invest custom DDD modeling effort (core subdomains) versus where to use off-the-shelf solutions (generic subdomains).
Bounded Context
A bounded context is an explicit boundary within which a particular domain model applies. Inside the boundary, every term in the model has a precise and unambiguous meaning. The same word can mean different things in different bounded contexts, and that is by design.
Example: The word “customer” means something different to your OrderContext (a party placing an order, with a shipping address) than to your MarketingContext (a contact record with campaign history and segmentation tags). If you force a single Customer entity to satisfy both contexts, you end up with a bloated model that serves neither well.
A context map documents how your bounded contexts relate to each other:
- Partnership: Two teams coordinate closely; changes are made together.
- Shared Kernel: A small shared model that both contexts agree on and co-own.
- Customer/Supplier: The downstream context (customer) relies on the upstream context (supplier) to provide what it needs; the supplier has a published API.
- Anti-Corruption Layer: The downstream context translates the upstream model to protect its own domain model from external influence.
- Conformist: The downstream context adopts the upstream model as-is (useful when you have no influence over the upstream).
Ubiquitous Language
Ubiquitous language is the shared vocabulary that developers and domain experts build together and use consistently in all conversations, documentation, and code. If the business calls it an “invoice,” your code has an Invoice class—not a BillingDocument or a PaymentRequest. If a business process is called “fulfilling an order,” you have a method order.fulfill(), not order.setStatus("fulfilled").
Maintaining ubiquitous language requires ongoing collaboration. When a domain expert uses a new term or corrects your terminology, update the code to match. Terminology drift between business and engineering is an early warning sign of a deteriorating model.
Building Blocks
Entity vs. Value Object
Entities have a distinct identity that persists across state changes. Two Order objects with the same items but different IDs are different orders—their identity matters more than their current attributes.
Value Objects have no identity; they are defined entirely by their attribute values. Two Money objects both representing “50 USD” are interchangeable. Value objects should be immutable—to change a value, you replace it rather than mutating it.
// Entity — identity matters
class Order {
private final OrderId id; // identity
private OrderStatus status; // mutable state
private Money totalAmount;
public Order(OrderId id, Money totalAmount) {
this.id = id;
this.totalAmount = totalAmount;
this.status = OrderStatus.PENDING;
}
// Two orders with the same ID are the same order, regardless of status
@Override
public boolean equals(Object o) {
if (!(o instanceof Order)) return false;
return this.id.equals(((Order) o).id);
}
}
// Value Object — value matters, no identity
class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.amount.add(other.amount), this.currency);
}
// Equality by value
@Override
public boolean equals(Object o) {
if (!(o instanceof Money)) return false;
Money m = (Money) o;
return this.amount.equals(m.amount) && this.currency.equals(m.currency);
}
}
Examples of value objects: Address, Email, DateRange, Coordinates, Money. Examples of entities: Order, User, Product, Invoice.
Aggregate and Aggregate Root
An aggregate is a cluster of related entities and value objects that must remain consistent with each other. The aggregate root is the single entity within the cluster through which all external access occurs. No outside object holds a direct reference to any non-root member of the aggregate; they can only hold a reference to the root.
The aggregate root is responsible for enforcing all invariants (business rules) within its boundary.
Example: An Order aggregate contains Order (root), OrderItem entities, and a ShippingAddress value object. You access order items only through the Order root; you never inject an OrderItem into another service directly.
class Order {
private final OrderId id;
private List<OrderItem> items = new ArrayList<>();
private OrderStatus status;
public void addItem(ProductId productId, int quantity, Money price) {
if (status != OrderStatus.DRAFT)
throw new IllegalStateException("Cannot modify a confirmed order");
items.add(new OrderItem(productId, quantity, price));
}
public void confirm() {
if (items.isEmpty())
throw new IllegalStateException("Cannot confirm an empty order");
this.status = OrderStatus.CONFIRMED;
// register domain event here
}
}
Rules for aggregates:
- Keep aggregates small. A large aggregate is a performance and contention risk.
- Reference other aggregates by ID only, not by object reference.
- Apply one repository per aggregate root.
- Enforce invariants within the aggregate boundary on every state change.
Domain Events
A domain event is a record of something meaningful that happened in the domain. Events use past tense: OrderConfirmed, PaymentReceived, ShipmentDispatched. They capture what happened, when it happened, and the data relevant to that event.
Domain events decouple bounded contexts from each other. When OrderContext confirms an order, it publishes an OrderConfirmed event. InventoryContext listens to that event and decrements stock; NotificationContext listens and sends a confirmation email. Neither listener knows anything about the other, and OrderContext knows nothing about either listener.
// Domain event
public class OrderConfirmed {
private final OrderId orderId;
private final Instant occurredAt;
private final Money totalAmount;
public OrderConfirmed(OrderId orderId, Money totalAmount) {
this.orderId = orderId;
this.totalAmount = totalAmount;
this.occurredAt = Instant.now();
}
// getters...
}
// Inside Order aggregate
public void confirm() {
if (items.isEmpty()) throw new IllegalStateException("Empty order");
this.status = OrderStatus.CONFIRMED;
DomainEventPublisher.publish(new OrderConfirmed(this.id, this.totalAmount()));
}
Domain events enable eventual consistency between aggregates and bounded contexts without tight coupling.
Repository
The Repository pattern provides collection-like access to aggregates while abstracting away the data storage mechanism. Your domain layer defines repository interfaces; the infrastructure layer provides implementations (JPA, MyBatis, MongoDB driver, etc.).
// Domain layer — defines the contract
interface OrderRepository {
Order findById(OrderId id);
void save(Order order);
List<Order> findByCustomerId(CustomerId customerId);
}
// Infrastructure layer — implements the contract
class JpaOrderRepository implements OrderRepository {
private final OrderJpaDao dao; // Spring Data JPA
@Override
public Order findById(OrderId id) {
return dao.findById(id.value())
.map(OrderMapper::toDomain)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@Override
public void save(Order order) {
dao.save(OrderMapper.toEntity(order));
}
}
Domain logic depends only on the interface; you can swap MySQL for PostgreSQL, or add a caching layer, without touching domain code.
Anti-Corruption Layer
The Anti-Corruption Layer (ACL) is a translation layer between your domain model and an external system or legacy model that you do not control. It prevents the external model’s concepts and terminology from “leaking” into your domain and corrupting its purity.
When you need it: You are integrating with a legacy system whose data model is incompatible with your domain model, but you cannot modify the legacy system (perhaps because it is still serving an older version of the product or another team owns it).
Example: Suppose your new DDD-based system has an Employee domain model with a clean resigned: boolean field, but the legacy system uses a table with an integer is_resigned column (0 or 1) that you cannot rename or alter. The ACL sits at the boundary and translates:
class LegacyEmployeeAdapter {
private final LegacyEmployeeDao legacyDao;
// Translate from legacy model to domain model
public Employee findByEmployeeCode(String code) {
LegacyEmployeeRecord record = legacyDao.findByCode(code);
return Employee.builder()
.id(EmployeeId.of(record.getId()))
.name(record.getEmployeeName())
.resigned(record.getIsResigned() == 1)
.build();
}
// Translate from domain model back to legacy model on write
public void save(Employee employee) {
LegacyEmployeeRecord record = new LegacyEmployeeRecord();
record.setId(employee.getId().value());
record.setIsResigned(employee.isResigned() ? 1 : 0);
legacyDao.save(record);
}
}
The ACL also handles cross-system consistency. If both the legacy and new systems store a “resigned” flag on overlapping tables, you update both within the ACL on each write, maintaining coherence without modifying either model’s core logic.
ACL responsibilities typically include:
- Translating field names and types between models.
- Converting enumerations and status codes.
- Validating and sanitizing inputs from external systems.
- Protecting the domain model from invalid external data.
- Encapsulating database query details (e.g.,
QueryWrapper construction for ORM frameworks).
DDD and Microservices
Bounded contexts and microservices align naturally: one bounded context maps to one (or a small set of) microservices. Each microservice owns its data store and is responsible for the consistency of its own aggregate root. Services communicate through domain events (asynchronous) or lightweight APIs (synchronous), but never through shared databases.
Mapping Patterns
| Integration Pattern | Description | When to Use |
|---|
| Shared Kernel | Two services share a small common model they both own and evolve together | Two teams closely related, sharing core domain objects |
| Customer/Supplier | Downstream service depends on upstream service’s published API contract | Stable upstream; downstream cannot influence upstream’s model |
| Anti-Corruption Layer | Downstream translates the upstream model at its boundary | Upstream is legacy or uses a different paradigm; you want to protect your domain model |
| Open Host Service | Upstream publishes a formal, versioned API for multiple consumers | Many consumers; API needs to be stable |
Data Isolation and Eventual Consistency
Each microservice owns its own data store—no shared databases across service boundaries. Cross-service operations use domain events and eventual consistency rather than distributed transactions. When OrderService confirms an order, it publishes OrderConfirmed; InventoryService consumes the event and decrements stock asynchronously. The two services may be briefly inconsistent, but they eventually converge.
For scenarios that truly require coordination across services (e.g., “reserve inventory AND confirm order atomically”), use the Saga pattern: a sequence of local transactions each publishing an event, with compensating transactions to undo completed steps if a later step fails.
Layered Architecture
DDD recommends a four-layer architecture that enforces a dependency direction (outer layers depend inward; the domain layer has no external dependencies):
┌────────────────────────────────┐
│ User Interface / API │ ← HTTP handlers, gRPC services
├────────────────────────────────┤
│ Application Layer │ ← Use cases, orchestration, no business logic
├────────────────────────────────┤
│ Domain Layer │ ← Entities, aggregates, domain events, interfaces
├────────────────────────────────┤
│ Infrastructure Layer │ ← DB, messaging, external API adapters
└────────────────────────────────┘
- User Interface Layer: Receives HTTP or gRPC requests and translates them into application commands. Returns results as DTOs or view objects (VO).
- Application Layer: Coordinates domain objects to fulfill use cases. Contains no business logic itself—it sequences domain operations. Uses Data Transfer Objects (DTO) for input/output.
- Domain Layer: The heart of the system. Contains all business logic in entities, aggregates, value objects, domain services, and domain events. Defines repository interfaces but does not implement them.
- Infrastructure Layer: Implements repository interfaces (using JPA, MyBatis, etc.), adapters for external services, and message brokers. Contains Persistent Objects (PO) that map to database tables.
When you adopt DDD with microservices, start by identifying your bounded contexts from the business domain—not from your existing database schema or service structure. Premature decomposition based on technical boundaries (e.g., “one service per table”) leads to chatty, tightly coupled services that are harder to maintain than a well-structured monolith.