DDD and Microservices Alignment — Deep Dive
Level: Intermediate
Pre-reading: 02 · Domain-Driven Design · 03 · Microservices Patterns
DDD is the Language of Microservices
Microservices without DDD often result in arbitrary service boundaries, tight coupling, and distributed monoliths. DDD provides the vocabulary and techniques to draw the right boundaries.
| Challenge | DDD Solution |
|---|---|
| Where do I draw service boundaries? | Bounded contexts |
| How do services communicate? | Context mapping, domain events |
| How do I model data per service? | Aggregates, entities, value objects |
| How do I handle distributed transactions? | Saga pattern, eventual consistency |
Bounded Context = Microservice Boundary
Each microservice typically implements one bounded context. The context boundary becomes the service API boundary.
graph TD
subgraph Order Bounded Context
OS[Order Service]
ODB[(Order DB)]
end
subgraph Payment Bounded Context
PS[Payment Service]
PDB[(Payment DB)]
end
subgraph Inventory Bounded Context
IS[Inventory Service]
IDB[(Inventory DB)]
end
OS -->|API| PS
OS -->|Events| IS
Alignment Table
| DDD Concept | Microservices Equivalent |
|---|---|
| Bounded Context | Service boundary |
| Ubiquitous Language | API vocabulary, schema names |
| Context Map | Service dependency diagram |
| Domain Event | Integration event (Kafka, etc.) |
| Aggregate | Unit of consistency within service |
| Repository | Service's data access layer |
| Anti-Corruption Layer | API adapter for external services |
Domain Events as Integration Events
Within a bounded context, domain events are internal facts. When published across service boundaries, they become integration events.
sequenceDiagram
participant OA as Order Aggregate
participant OS as Order Service
participant K as Kafka
participant IS as Inventory Service
participant IA as Inventory Aggregate
OA->>OS: OrderPlaced (domain event)
OS->>K: Publish OrderPlaced (integration event)
K->>IS: Consume OrderPlaced
IS->>IA: reserveInventory()
Domain Event vs Integration Event
| Aspect | Domain Event | Integration Event |
|---|---|---|
| Scope | Within bounded context | Across service boundaries |
| Consumer | Same service handlers | Other services |
| Schema | Internal; can change freely | Published language; versioned |
| Transport | In-memory, same transaction | Message broker (Kafka, SQS) |
Integration Event Design
// Integration event (published to Kafka)
public record OrderPlacedEvent(
String eventId,
String orderId,
String customerId,
BigDecimal totalAmount,
String currency,
List<OrderLineDto> items,
Instant occurredAt,
int version // Schema version
) {}
Best practices:
- Include all data consumers need (event-carried state transfer)
- Version the schema (Avro with Schema Registry)
- Use past-tense naming
- Include timestamp and unique event ID
Context Mapping to Service Integration
Context map patterns translate directly to service integration strategies:
| Context Map Pattern | Service Integration |
|---|---|
| Open Host Service | REST API with OpenAPI spec |
| Published Language | Avro/Protobuf schemas in Schema Registry |
| Customer-Supplier | API with SLA; consumer feedback loop |
| Conformist | Accept external API as-is |
| Anti-Corruption Layer | Adapter service or translation layer |
| Shared Kernel | Shared library (use sparingly) |
Anti-Corruption Layer in Services
graph LR
subgraph Your Service
DS[Domain Service]
ACL[ACL Adapter]
end
EXT[External/Legacy Service]
DS --> ACL
ACL --> EXT
// ACL isolates your domain from external API
public class LegacyOrderAdapter implements OrderGateway {
private final LegacyOrderClient legacyClient;
@Override
public Order fetchOrder(OrderId orderId) {
// External API returns messy legacy format
LegacyOrderResponse legacy = legacyClient.getOrder(orderId.value());
// Translate to your clean domain model
return new Order(
new OrderId(legacy.getOrdNo()),
mapStatus(legacy.getStatCd()),
mapLines(legacy.getItms())
);
}
}
Aggregate Per Service
Each service owns its aggregates. Aggregates enforce consistency within the service; events coordinate across services.
graph TD
subgraph Order Service
OA[Order Aggregate]
OA --> OL[OrderLines]
OA --> SA[ShippingAddress]
end
subgraph Inventory Service
IA[Inventory Aggregate]
IA --> SI[StockItem]
IA --> RS[Reservation]
end
OA -->|OrderPlaced event| IA
No Shared Aggregates
// WRONG: Inventory service accessing Order aggregate
public class InventoryService {
private final OrderRepository orderRepository; // Crosses boundary!
public void reserveForOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId); // BAD
// ...
}
}
// RIGHT: Inventory service receives event with needed data
public class InventoryEventHandler {
@KafkaListener(topics = "orders")
public void handleOrderPlaced(OrderPlacedEvent event) {
// Event contains all needed data
inventory.reserve(event.items());
}
}
Subdomains to Service Strategy
| Subdomain Type | Service Strategy |
|---|---|
| Core | Build custom microservice; invest heavily |
| Supporting | Build or buy; standard microservice |
| Generic | Use SaaS or managed service |
graph TD
subgraph Core Domain
PRICE[Pricing Service - Custom]
REC[Recommendation Service - Custom]
end
subgraph Supporting Domain
ORD[Order Service]
INV[Inventory Service]
end
subgraph Generic Domain
AUTH[Auth0 - SaaS]
EMAIL[SendGrid - SaaS]
PAY[Stripe - SaaS]
end
Saga Pattern for Cross-Service Transactions
DDD's eventual consistency model maps to the Saga pattern in microservices.
Choreography (Event-Driven)
graph LR
O[Order Service] -->|OrderPlaced| K[Kafka]
K -->|OrderPlaced| I[Inventory Service]
I -->|InventoryReserved| K
K -->|InventoryReserved| P[Payment Service]
P -->|PaymentCharged| K
Orchestration (Coordinator)
graph TD
S[Saga Orchestrator]
S -->|ReserveInventory| I[Inventory Service]
I -->|Reserved| S
S -->|ChargePayment| P[Payment Service]
P -->|Charged| S
S -->|CreateShipment| SH[Shipping Service]
Database Per Service
Each bounded context (service) owns its data. No shared databases.
| Pattern | Description |
|---|---|
| Database per service | Each service has private DB; no direct access by others |
| API Composition | Query data by calling service APIs |
| CQRS | Read models optimized separately from write models |
| Event-carried state | Events contain data; consumers store local copies |
Data Duplication is Okay
graph LR
subgraph Order Service
O[Order]
O --> CN[Customer Name - copied]
end
subgraph Customer Service
C[Customer - source of truth]
end
C -->|CustomerUpdated event| O
Services may store copies of data they need. The source of truth publishes events when data changes.
Ubiquitous Language in APIs
Your API should use domain language, not technical jargon.
| Bad (Technical) | Good (Domain) |
|---|---|
POST /orders/create |
POST /orders (placing an order) |
PUT /orders/{id}/status?value=3 |
POST /orders/{id}/cancel |
GET /order-records |
GET /orders |
{"type": 1, "amt": 100} |
{"orderType": "standard", "amount": {"value": 100, "currency": "USD"}} |
Command Naming
| Command | REST Mapping |
|---|---|
PlaceOrder |
POST /orders |
CancelOrder |
POST /orders/{id}/cancel |
ShipOrder |
POST /orders/{id}/shipments |
AddOrderLine |
POST /orders/{id}/lines |
Migration: Monolith to Microservices with DDD
Step 1: Identify Bounded Contexts in Monolith
Use Event Storming or domain analysis to find context boundaries within the existing codebase.
Step 2: Create Modules
Enforce boundaries within the monolith first (modular monolith).
Step 3: Extract Services
One bounded context at a time, extract to a separate service using Strangler Fig pattern.
graph TD
subgraph Monolith
M1[Order Module]
M2[Inventory Module]
M3[Payment Module]
end
subgraph Extracted
S1[Order Service]
end
M1 -.->|Extract| S1
M2 --> M3
S1 -->|API| M2
Step 4: Replace Synchronous with Events
As services mature, replace synchronous calls with event-driven integration for looser coupling.
Common Alignment Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Technical decomposition | User service, database service, etc. | Decompose by bounded context |
| Shared database | Tight coupling; no autonomy | Database per service |
| Synchronous chains | Cascading failures | Async events where possible |
| No clear ownership | Conflicting changes | One team per bounded context |
| Ignoring context boundaries | Distributed monolith | Respect ubiquitous language per context |
How do you decide between one service per bounded context vs. multiple?
Start with one service per bounded context — this is the default. Split a bounded context into multiple services only if: (1) parts scale differently, (2) parts have different deployment needs, (3) the context is too large for one team. Avoid going smaller than a bounded context — you'll lose the consistency boundary benefits.
How do you handle queries that span multiple services?
(1) API Composition: Gateway or BFF calls multiple services and aggregates. (2) CQRS with read models: Build denormalized read models from events. (3) Event-carried state: Services store copies of data they need. Choice depends on consistency needs and query complexity. CQRS is most flexible for complex queries.
What's the relationship between aggregates and microservices?
A microservice (bounded context) contains one or more aggregates. Each aggregate is a consistency boundary within the service. The service's API exposes operations on its aggregates. Cross-service consistency uses events, not transactions. Think: bounded context = service, aggregate = consistency unit within service.