Skip to main content

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

ConceptTraditional DDDDDD + Event Sourcing
State StorageCurrent state in DBEvents in event store
Domain EventsSide effect, optionalSource of truth
AggregateMutable stateEvent-driven state machine
HistoryLost on updateComplete 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:

TopicSee Chapter
Event Store ImplementationCh 04: Event Store
ProjectionsCh 05: Projections
SnapshotsCh 06: Snapshots
SagasCh 12: Process Managers
Schema EvolutionCh 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