DDD + Event Sourcing: A Natural Fit
Domain-Driven Design and Event Sourcing are natural companions. DDD's domain events become the source of truth, and aggregates become event-driven state machines. This chapter bridges the two approaches.
TL;DR
| Concept | Traditional DDD | DDD + Event Sourcing |
|---|---|---|
| State Storage | Current state in DB | Events in event store |
| Domain Events | Side effect, optional | Source of truth |
| Aggregate | Mutable state | Event-driven state machine |
| History | Lost on update | Complete audit trail |
Why Combine DDD and Event Sourcing?
Traditional DDD
// State-based aggregate
public class Order : AggregateRoot<OrderId>
{
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public void Place()
{
Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id, Total)); // Event as side effect
}
}
// Repository saves current state
await _orderRepository.Save(order); // UPDATE Orders SET Status = 'Placed'
DDD + Event Sourcing
// Event-sourced aggregate
public class Order : EventSourcedAggregate
{
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public void Place()
{
// Event IS the change
Apply(new OrderPlaced(Id, Total, DateTime.UtcNow));
}
// State derived from events
private void When(OrderPlaced @event)
{
Status = OrderStatus.Placed;
}
}
// Repository appends events
await _orderRepository.Save(order); // APPEND to event stream
Event-Sourced Aggregate Pattern
Base Class
public abstract class EventSourcedAggregate
{
private readonly List<IDomainEvent> _uncommittedEvents = new();
public Guid Id { get; protected set; }
public int Version { get; private set; } = -1;
public IReadOnlyList<IDomainEvent> UncommittedEvents => _uncommittedEvents;
// Apply new event (command handling)
protected void Apply(IDomainEvent @event)
{
// Update state
When(@event);
// Track for persistence
_uncommittedEvents.Add(@event);
Version++;
}
// Replay historical events (reconstitution)
public void LoadFromHistory(IEnumerable<IDomainEvent> history)
{
foreach (var @event in history)
{
When(@event);
Version++;
}
}
// Route event to handler
protected abstract void When(IDomainEvent @event);
public void ClearUncommittedEvents()
{
_uncommittedEvents.Clear();
}
}
Complete Example: Order Aggregate
public class Order : EventSourcedAggregate
{
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Address ShippingAddress { get; private set; }
public Money Total { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
// For reconstitution
private Order() { }
// Factory method
public static Order Create(CustomerId customerId, Address shippingAddress)
{
var order = new Order();
order.Apply(new OrderCreated(
OrderId: Guid.NewGuid(),
CustomerId: customerId.Value,
ShippingAddress: shippingAddress,
CreatedAt: DateTime.UtcNow));
return order;
}
// Commands
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException("Cannot add lines to non-draft order");
Apply(new OrderLineAdded(
OrderId: Id,
ProductId: productId.Value,
Quantity: quantity,
UnitPrice: unitPrice.Amount,
Currency: unitPrice.Currency.Code));
}
public void Place()
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException("Only draft orders can be placed");
if (!_lines.Any())
throw new InvalidOperationException("Cannot place empty order");
Apply(new OrderPlaced(
OrderId: Id,
CustomerId: CustomerId.Value,
Total: Total.Amount,
Currency: Total.Currency.Code,
PlacedAt: DateTime.UtcNow));
}
public void Ship(TrackingNumber trackingNumber)
{
if (Status != OrderStatus.Placed)
throw new InvalidOrderStateException("Only placed orders can be shipped");
Apply(new OrderShipped(
OrderId: Id,
TrackingNumber: trackingNumber.Value,
ShippedAt: DateTime.UtcNow));
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
throw new InvalidOrderStateException("Cannot cancel shipped/delivered order");
Apply(new OrderCancelled(
OrderId: Id,
Reason: reason,
CancelledAt: DateTime.UtcNow));
}
// Event handlers - update state
protected override void When(IDomainEvent @event)
{
switch (@event)
{
case OrderCreated e:
Id = e.OrderId;
CustomerId = new CustomerId(e.CustomerId);
ShippingAddress = e.ShippingAddress;
Status = OrderStatus.Draft;
Total = Money.Zero(Currency.USD);
break;
case OrderLineAdded e:
var line = new OrderLine(
new ProductId(e.ProductId),
e.Quantity,
new Money(e.UnitPrice, Currency.FromCode(e.Currency)));
_lines.Add(line);
RecalculateTotal();
break;
case OrderPlaced e:
Status = OrderStatus.Placed;
break;
case OrderShipped e:
Status = OrderStatus.Shipped;
break;
case OrderCancelled e:
Status = OrderStatus.Cancelled;
break;
}
}
private void RecalculateTotal()
{
Total = _lines
.Select(l => l.UnitPrice.MultiplyBy(l.Quantity))
.Aggregate(Money.Zero(Currency.USD), (sum, price) => sum.Add(price));
}
}
Events
public record OrderCreated(
Guid OrderId,
Guid CustomerId,
Address ShippingAddress,
DateTime CreatedAt
) : IDomainEvent;
public record OrderLineAdded(
Guid OrderId,
Guid ProductId,
int Quantity,
decimal UnitPrice,
string Currency
) : IDomainEvent;
public record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
string Currency,
DateTime PlacedAt
) : IDomainEvent;
public record OrderShipped(
Guid OrderId,
string TrackingNumber,
DateTime ShippedAt
) : IDomainEvent;
public record OrderCancelled(
Guid OrderId,
string Reason,
DateTime CancelledAt
) : IDomainEvent;
Repository Pattern for Event Sourcing
public interface IEventSourcedRepository<T> where T : EventSourcedAggregate
{
Task<T?> GetById(Guid id, CancellationToken ct = default);
Task Save(T aggregate, CancellationToken ct = default);
}
public class OrderRepository : IEventSourcedRepository<Order>
{
private readonly IEventStore _eventStore;
public async Task<Order?> GetById(Guid id, CancellationToken ct = default)
{
var streamName = $"order-{id}";
var events = await _eventStore.ReadStream(streamName, ct);
if (!events.Any())
return null;
var order = new Order(); // Private constructor via reflection or factory
order.LoadFromHistory(events);
return order;
}
public async Task Save(Order order, CancellationToken ct = default)
{
if (!order.UncommittedEvents.Any())
return;
var streamName = $"order-{order.Id}";
var expectedVersion = order.Version - order.UncommittedEvents.Count;
await _eventStore.AppendToStream(
streamName,
expectedVersion,
order.UncommittedEvents,
ct);
order.ClearUncommittedEvents();
}
}
Projections (Read Models)
Build read models from events:
public class OrderSummaryProjection : IEventHandler<OrderCreated>,
IEventHandler<OrderPlaced>,
IEventHandler<OrderShipped>
{
private readonly IOrderSummaryRepository _repository;
public async Task Handle(OrderCreated @event)
{
var summary = new OrderSummary
{
Id = @event.OrderId,
CustomerId = @event.CustomerId,
Status = "Draft",
Total = 0,
CreatedAt = @event.CreatedAt
};
await _repository.Insert(summary);
}
public async Task Handle(OrderPlaced @event)
{
await _repository.Update(@event.OrderId, summary =>
{
summary.Status = "Placed";
summary.Total = @event.Total;
summary.PlacedAt = @event.PlacedAt;
});
}
public async Task Handle(OrderShipped @event)
{
await _repository.Update(@event.OrderId, summary =>
{
summary.Status = "Shipped";
summary.TrackingNumber = @event.TrackingNumber;
summary.ShippedAt = @event.ShippedAt;
});
}
}
Key Differences from Traditional DDD
1. Events Are the Source of Truth
// Traditional: State is truth, events are notifications
order.Status = OrderStatus.Placed; // This IS the change
AddDomainEvent(new OrderPlaced(...)); // This notifies about the change
// Event Sourced: Events are truth, state is derived
Apply(new OrderPlaced(...)); // This IS the change
// State is recalculated from events
2. No Direct State Mutation
// Traditional: Direct mutation
public void Place()
{
Status = OrderStatus.Placed; // Direct assignment
}
// Event Sourced: Mutation via events
public void Place()
{
Apply(new OrderPlaced(...)); // Event causes mutation in When()
}
private void When(OrderPlaced @event)
{
Status = OrderStatus.Placed; // Mutation happens here
}
3. Complete History
// Traditional: Only current state
var order = await _repository.GetById(orderId);
// order.Status = "Shipped"
// No history of how it got there
// Event Sourced: Full history
var events = await _eventStore.ReadStream($"order-{orderId}");
// OrderCreated → OrderLineAdded → OrderPlaced → OrderShipped
// Complete audit trail
When to Use DDD + Event Sourcing
Good Fit
- Audit requirements - Need complete history
- Complex domain - Rich business rules
- Temporal queries - "What was the state on date X?"
- Event-driven architecture - Events are first-class
- CQRS - Separate read and write models
Not Ideal
- Simple CRUD - Overkill
- No audit needs - Extra complexity for no benefit
- Team inexperience - Steep learning curve
- Reporting-heavy - Projections add complexity
Connection to Event Sourcing Guide
This chapter provides the DDD perspective. For deep implementation details, see our Event Sourcing Guide:
| Topic | See Chapter |
|---|---|
| Event Store Implementation | Ch 04: Event Store |
| Projections | Ch 05: Projections |
| Snapshots | Ch 06: Snapshots |
| Sagas | Ch 12: Process Managers |
| Schema Evolution | Ch 09: Operations |
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ DDD + EVENT SOURCING QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ KEY CONCEPTS │
│ • Events are source of truth │
│ • State derived from event replay │
│ • Aggregates are event-driven state machines │
│ • Complete history preserved │
│ │
│ AGGREGATE PATTERN │
│ • Apply() - emit new event │
│ • When() - update state from event │
│ • LoadFromHistory() - reconstitute from events │
│ │
│ BENEFITS │
│ • Perfect audit trail │
│ • Temporal queries │
│ • Rebuildable projections │
│ • Natural CQRS fit │
│ │
│ TRADE-OFFS │
│ • More complex than state-based │
│ • Requires projection management │
│ • Schema evolution challenges │
│ │
└─────────────────────────────────────────────────────────┘
Next Steps
- Event Sourcing Guide - Deep implementation details
- DDD in Microservices - Event sourcing across services
- Testing DDD Code - Testing event-sourced aggregates