DDD in Microservices: Bounded Contexts as Services
Domain-Driven Design provides the strategic patterns for decomposing systems into microservices. Bounded contexts become service boundaries, and context mapping patterns guide inter-service communication.
TL;DR
| DDD Concept | Microservices Equivalent |
|---|---|
| Bounded Context | Service boundary |
| Context Mapping | Service integration patterns |
| Aggregate | Transaction boundary |
| Domain Events | Async messaging between services |
| ACL | API Gateway / Adapter service |
From Bounded Contexts to Services
Service Boundaries from DDD
Rule 1: One Bounded Context = One Service (Usually)
┌─────────────────────────────────────────────────────────┐
│ BOUNDED CONTEXT TO SERVICE MAPPING │
├─────────────────────────────────────────────────────────┤
│ │
│ TYPICAL: 1 Context = 1 Service │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Orders Context │ → │ Orders Service │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ LARGE CONTEXT: 1 Context = Multiple Services │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ │ → │ Order Placement │ │
│ │ Orders Context │ ├──────────────────┤ │
│ │ (very large) │ → │ Order Tracking │ │
│ │ │ ├──────────────────┤ │
│ │ │ → │ Order History │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ NEVER: 1 Service = Multiple Contexts │
│ (violates bounded context principle) │
│ │
└─────────────────────────────────────────────────────────┘
Rule 2: Aggregates Define Transaction Boundaries
// ✅ Single aggregate = single service transaction
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerId, cmd.ShippingAddress);
order.Place();
await _orderRepository.Save(order); // Single transaction
}
}
// ❌ Never: Cross-service transaction
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
using var transaction = _distributedTx.Begin();
await _orderService.CreateOrder(...); // Service 1
await _inventoryService.Reserve(...); // Service 2 - NO!
await _paymentService.Charge(...); // Service 3 - NO!
transaction.Commit(); // Distributed transaction - avoid!
}
}
Rule 3: Use Events for Cross-Service Coordination
// ✅ Event-driven coordination
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerId, cmd.ShippingAddress);
order.Place(); // Emits OrderPlaced event
await _orderRepository.Save(order);
// Event published to message broker
await _eventBus.Publish(new OrderPlacedIntegrationEvent(
order.Id,
order.Lines.Select(l => new OrderLineDto(l.ProductId, l.Quantity))
));
}
}
// Inventory service reacts to event
public class InventoryService
{
public async Task Handle(OrderPlacedIntegrationEvent @event)
{
foreach (var line in @event.Lines)
{
var stock = await _stockRepository.GetByProduct(line.ProductId);
stock.Reserve(line.Quantity, @event.OrderId);
await _stockRepository.Save(stock);
}
}
}
Context Mapping Patterns in Microservices
Customer-Supplier → REST API
// Supplier (Inventory Service) exposes API
[ApiController]
[Route("api/stock")]
public class StockController : ControllerBase
{
[HttpGet("{productId}/availability")]
public async Task<ActionResult<StockAvailability>> CheckAvailability(
Guid productId,
[FromQuery] int quantity)
{
var stock = await _stockService.CheckAvailability(productId, quantity);
return Ok(stock);
}
}
// Customer (Orders Service) consumes API
public class InventoryClient : IInventoryClient
{
private readonly HttpClient _httpClient;
public async Task<bool> CheckAvailability(Guid productId, int quantity)
{
var response = await _httpClient.GetAsync(
$"/api/stock/{productId}/availability?quantity={quantity}");
var availability = await response.Content
.ReadFromJsonAsync<StockAvailability>();
return availability.IsAvailable;
}
}
Anti-Corruption Layer → Adapter Service
// Legacy payment system has ugly API
public class LegacyPaymentResponse
{
public string RESP_CD { get; set; }
public string TXN_ID { get; set; }
public decimal AMT { get; set; }
public string ERR_MSG { get; set; }
}
// ACL translates to clean domain model
public class PaymentGatewayAdapter : IPaymentGateway
{
private readonly LegacyPaymentClient _legacyClient;
public async Task<PaymentResult> ProcessPayment(PaymentRequest request)
{
// Translate to legacy format
var legacyRequest = new LegacyPaymentRequest
{
CUST_ID = request.CustomerId.ToString(),
AMT = request.Amount.Amount,
CCY = request.Amount.Currency.Code,
CARD_TKN = request.PaymentToken
};
var legacyResponse = await _legacyClient.Process(legacyRequest);
// Translate to domain model
return legacyResponse.RESP_CD switch
{
"00" => PaymentResult.Success(
new TransactionId(legacyResponse.TXN_ID),
new Money(legacyResponse.AMT, request.Amount.Currency)),
"51" => PaymentResult.InsufficientFunds(),
"14" => PaymentResult.InvalidCard(),
_ => PaymentResult.Failed(legacyResponse.ERR_MSG)
};
}
}
Open Host Service → API Gateway
// Identity service provides OHS for all consumers
[ApiController]
[Route("api/v1/users")]
public class UsersController : ControllerBase
{
[HttpGet("{userId}")]
[ProducesResponseType(typeof(UserDto), 200)]
public async Task<ActionResult<UserDto>> GetUser(Guid userId)
{
var user = await _userService.GetById(userId);
return Ok(MapToDto(user));
}
[HttpPost("{userId}/validate-token")]
public async Task<ActionResult<TokenValidationResult>> ValidateToken(
Guid userId,
[FromBody] TokenValidationRequest request)
{
var result = await _authService.ValidateToken(userId, request.Token);
return Ok(result);
}
}
Integration Events vs Domain Events
// Domain Event - internal to bounded context
// Rich, uses domain objects
public record OrderPlaced(
OrderId OrderId,
CustomerId CustomerId,
IReadOnlyList<OrderLine> Lines,
Money Total,
Address ShippingAddress
) : IDomainEvent;
// Integration Event - crosses service boundaries
// Simple, uses primitives, versioned
public record OrderPlacedIntegrationEvent
{
public Guid OrderId { get; init; }
public Guid CustomerId { get; init; }
public List<OrderLineDto> Lines { get; init; }
public decimal TotalAmount { get; init; }
public string Currency { get; init; }
public DateTime OccurredAt { get; init; }
// Version for schema evolution
public int Version => 2;
}
public record OrderLineDto(Guid ProductId, int Quantity, decimal UnitPrice);
Publishing Integration Events
public class OrderRepository : IOrderRepository
{
private readonly IEventBus _eventBus;
private readonly IIntegrationEventMapper _mapper;
public async Task Save(Order order)
{
await _dbContext.SaveChangesAsync();
// Convert domain events to integration events
foreach (var domainEvent in order.DomainEvents)
{
var integrationEvent = _mapper.Map(domainEvent);
if (integrationEvent != null)
{
await _eventBus.Publish(integrationEvent);
}
}
order.ClearDomainEvents();
}
}
public class IntegrationEventMapper : IIntegrationEventMapper
{
public IIntegrationEvent? Map(IDomainEvent domainEvent)
{
return domainEvent switch
{
OrderPlaced e => new OrderPlacedIntegrationEvent
{
OrderId = e.OrderId.Value,
CustomerId = e.CustomerId.Value,
Lines = e.Lines.Select(l => new OrderLineDto(
l.ProductId.Value,
l.Quantity,
l.UnitPrice.Amount)).ToList(),
TotalAmount = e.Total.Amount,
Currency = e.Total.Currency.Code,
OccurredAt = DateTime.UtcNow
},
OrderShipped e => new OrderShippedIntegrationEvent
{
OrderId = e.OrderId.Value,
TrackingNumber = e.TrackingNumber.Value,
ShippedAt = e.ShippedAt
},
_ => null // Not all domain events become integration events
};
}
}
Saga Pattern for Cross-Service Operations
When operations span services, use sagas:
public class PlaceOrderSaga : Saga<PlaceOrderSagaData>,
IAmStartedBy<OrderPlacedIntegrationEvent>,
IHandle<InventoryReservedEvent>,
IHandle<InventoryReservationFailedEvent>,
IHandle<PaymentSucceededEvent>,
IHandle<PaymentFailedEvent>
{
public async Task Handle(OrderPlacedIntegrationEvent message)
{
Data.OrderId = message.OrderId;
Data.Lines = message.Lines;
Data.TotalAmount = message.TotalAmount;
// Start saga - reserve inventory
await _bus.Publish(new ReserveInventoryCommand(
message.OrderId,
message.Lines));
}
public async Task Handle(InventoryReservedEvent message)
{
Data.InventoryReserved = true;
// Next step - process payment
await _bus.Publish(new ProcessPaymentCommand(
Data.OrderId,
Data.TotalAmount));
}
public async Task Handle(InventoryReservationFailedEvent message)
{
// Compensation - cancel order
await _bus.Publish(new CancelOrderCommand(
Data.OrderId,
"Insufficient inventory"));
MarkAsComplete();
}
public async Task Handle(PaymentSucceededEvent message)
{
Data.PaymentProcessed = true;
// Complete - confirm order
await _bus.Publish(new ConfirmOrderCommand(Data.OrderId));
MarkAsComplete();
}
public async Task Handle(PaymentFailedEvent message)
{
// Compensation - release inventory and cancel order
await _bus.Publish(new ReleaseInventoryCommand(
Data.OrderId,
Data.Lines));
await _bus.Publish(new CancelOrderCommand(
Data.OrderId,
"Payment failed"));
MarkAsComplete();
}
}
Service Communication Patterns
When to Use Each
| Pattern | Use When |
|---|---|
| Sync (HTTP/gRPC) | Need immediate response, queries |
| Async (Events) | Fire-and-forget, eventual consistency OK |
| Async (Commands) | Request-response but can wait |
Common Mistakes
Mistake 1: Distributed Monolith
// ❌ Services too coupled - distributed monolith
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
// Synchronous calls to many services
var customer = await _customerService.GetCustomer(cmd.CustomerId);
var products = await _catalogService.GetProducts(cmd.ProductIds);
var prices = await _pricingService.GetPrices(cmd.ProductIds, customer);
var available = await _inventoryService.CheckAll(cmd.Lines);
var shipping = await _shippingService.CalculateRates(cmd.Address);
// If any service is down, order fails
}
}
// ✅ Loosely coupled - use local data + events
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
// Use cached/local data where possible
var order = Order.Create(cmd.CustomerId, cmd.ShippingAddress);
foreach (var line in cmd.Lines)
{
// Price captured at order time (not fetched)
order.AddLine(line.ProductId, line.Quantity, line.Price);
}
order.Place();
await _orderRepository.Save(order);
// Other services react to event asynchronously
}
}
Mistake 2: Shared Database
// ❌ Services sharing database
// Orders Service
SELECT * FROM Orders WHERE CustomerId = @id
// Customer Service
SELECT * FROM Customers WHERE Id = @id // Same database!
// ✅ Each service owns its data
// Orders Service - has its own database
// Only stores CustomerId, not full customer data
// Customer Service - separate database
// Owns all customer data
Mistake 3: Anemic Services
// ❌ Service is just CRUD over API
[HttpPost]
public async Task<ActionResult> CreateOrder(CreateOrderDto dto)
{
var order = _mapper.Map<Order>(dto);
await _repository.Add(order);
return Ok();
}
// ✅ Service encapsulates domain logic
[HttpPost]
public async Task<ActionResult> PlaceOrder(PlaceOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerId, cmd.ShippingAddress);
foreach (var line in cmd.Lines)
{
order.AddLine(line.ProductId, line.Quantity, line.Price);
}
order.Place(); // Domain logic here
await _orderRepository.Save(order);
return Ok(order.Id);
}
Staff+ Interview Questions
Q: How do you decide service boundaries?
A: I use DDD bounded contexts as the primary guide:
- Linguistic boundaries - Different ubiquitous language = different service
- Team boundaries - Conway's Law - service per team
- Data ownership - Each service owns its data
- Change patterns - Things that change together stay together
- Consistency requirements - Strong consistency = same service
Q: How do you handle data that multiple services need?
A: Options:
- Event-carried state transfer - Services maintain local copies, updated via events
- API calls - Query when needed (adds coupling)
- Shared data service - For truly shared reference data
- Data duplication - Accept duplication for autonomy
I prefer event-carried state transfer for autonomy.
Q: What's the relationship between aggregates and microservices?
A:
- Aggregate = Transaction boundary (can't split across services)
- Bounded Context = Service boundary (contains 1+ aggregates)
- Service = Deployment unit (maps to bounded context)
One service can have multiple aggregates, but one aggregate can't span services.
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ DDD IN MICROSERVICES QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ MAPPING │
│ • Bounded Context → Service │
│ • Context Map → Service integration │
│ • Aggregate → Transaction boundary │
│ • Domain Events → Integration events │
│ │
│ INTEGRATION PATTERNS │
│ • Customer-Supplier → REST/gRPC API │
│ • ACL → Adapter service │
│ • OHS → API Gateway │
│ • Events → Message broker │
│ │
│ RULES │
│ • No distributed transactions │
│ • Each service owns its data │
│ • Use events for cross-service coordination │
│ • Use sagas for multi-step operations │
│ │
└─────────────────────────────────────────────────────────┘
Next Steps
- Testing DDD Code - Testing in microservices
- Context Mapping - Deep dive on patterns
- System Design: Microservices - Architecture patterns