Event Sourcing, When It Earns Its Keep
Event sourcing is probably the most over-applied pattern in distributed systems. It sounds appealing: an immutable audit log, time travel, the ability to rebuild any read model from scratch. In practice, most teams apply it to problems that a standard relational database with a created_at / updated_at column would handle perfectly well.
The question isn't "should we use event sourcing?" — it's "does our domain have properties that make event sourcing obviously superior to a state-based approach?"
What Event Sourcing Actually Is
In a traditional system, you save the current state of an aggregate. In event sourcing, you save the sequence of events that led to the current state. The current state is derived by replaying those events.
State-based: you store { status: 'Shipped', shippedAt: '...' }.
Event-sourced: you store [OrderCreated, ItemAdded, ItemAdded, OrderConfirmed, OrderShipped] and replay them to derive the current state.
Reconstituting Aggregates from Events
public class Order {
private OrderId id;
private OrderStatus status;
private List<LineItem> lineItems = new ArrayList<>();
private Money total;
// Private constructor — only reconstitution and factory methods create instances
private Order() {}
// Factory: creates a new order and records the creation event
public static Order create(OrderId id, CustomerId customerId) {
Order order = new Order();
order.apply(new OrderCreated(id, customerId, Instant.now()));
return order;
}
// Reconstitution: replay events from the event store
public static Order reconstitute(List<DomainEvent> history) {
Order order = new Order();
history.forEach(order::apply);
return order;
}
public void addItem(ProductId productId, Quantity qty, Money unitPrice) {
apply(new ItemAddedToOrder(this.id, productId, qty, unitPrice));
}
public void confirm() {
if (lineItems.isEmpty()) throw new DomainException("Cannot confirm empty order");
apply(new OrderConfirmed(this.id, Instant.now()));
}
// Apply mutates state; always private routing from above
private void apply(DomainEvent event) {
switch (event) {
case OrderCreated e -> {
this.id = e.orderId();
this.status = OrderStatus.DRAFT;
}
case ItemAddedToOrder e -> {
this.lineItems.add(new LineItem(e.productId(), e.quantity(), e.unitPrice()));
}
case OrderConfirmed e -> {
this.status = OrderStatus.CONFIRMED;
}
default -> throw new UnhandledEventException(event);
}
this.uncommittedEvents.add(event);
}
}// Event store repository
public class EventSourcedOrderRepository implements OrderRepository {
private final EventStore eventStore;
public Optional<Order> findById(OrderId id) {
List<DomainEvent> history = eventStore.loadEvents(id.value());
if (history.isEmpty()) return Optional.empty();
return Optional.of(Order.reconstitute(history));
}
public void save(Order order) {
List<DomainEvent> newEvents = order.pullUncommittedEvents();
eventStore.appendEvents(order.getId().value(), order.getVersion(), newEvents);
}
}When Event Sourcing Earns Its Keep
Audit requirement with temporal queries. "What did this account look like at 3pm on Tuesday?" is trivial with event sourcing — replay to that point in time. With state-based storage, you'd need a shadow history table or a full audit log built on top.
Complex business workflows with reversals. Accounting entries, financial transactions, inventory reservations. These domains naturally think in terms of transactions (debits and credits) rather than current state. Event sourcing aligns with domain language.
Multiple read models from the same write model. If you're already doing CQRS with projections, an event store is a natural backing for the write side. You can add new read models later by replaying history — no data migration needed.
Debugging production bugs. Replay the exact sequence of events that caused an order to enter an invalid state. You can't do this with only the final state.
When Event Sourcing Is Overkill
// Does this domain need event sourcing?
interface UserProfile {
userId: string;
name: string;
email: string;
preferences: UserPreferences;
}
// Probably not. The current state is all you need.
// A simple UPDATE and updated_at timestamp is sufficient.
// Adding an event store here is archeological overhead.Warning signs you're applying it too eagerly:
- Your events are just "field X changed from A to B" — you're storing diffs, not business facts.
- The domain expert doesn't think in terms of events — they just want to know the current state.
- You have no requirement to replay events for new read models or audit purposes.
- Your team has never operated an event store in production before.
The Operational Cost
Event sourcing shifts complexity from schema migration to event migration. With state-based storage, adding a new field is an ALTER TABLE. With event sourcing, adding a new field means:
- Adding an upcaster for existing events that pre-date the field.
- Versioning the event schema.
- Testing that reconstitution from old events still produces valid state.
// Upcaster: migrates v1 events to v2 format during replay
public class ItemAddedToOrderV1ToV2Upcaster implements EventUpcaster {
@Override
public DomainEvent upcast(DomainEvent event) {
if (event instanceof ItemAddedToOrderV1 v1) {
// V1 had no currency, assume USD for historical events
return new ItemAddedToOrderV2(
v1.orderId(), v1.productId(), v1.quantity(),
new Money(v1.unitPrice(), Currency.USD)
);
}
return event;
}
}Upcasting is manageable but adds a maintenance surface that doesn't exist in state-based systems.
Snapshots: Addressing the Replay Problem
Long-lived aggregates accumulate thousands of events. Replaying all of them to reconstitute state on every command is slow. Snapshots solve this: periodically save the current state alongside the event log. On reconstitution, load the snapshot, then replay only events after the snapshot version.
Key Takeaways
- Event sourcing stores the history of what happened, not the current state — the current state is always derived by replaying events.
- It earns its keep when you have audit/temporal query requirements, complex reversible workflows, or need to derive multiple read models from one write model.
- It's overkill when events are just field-change diffs, the domain expert thinks in current state, or there's no replay use case.
- The operational tradeoff: no schema migrations, but event schema versioning and upcasting instead — understand this before committing.
- Snapshots are mandatory for long-lived aggregates with deep event histories — build them in from the start, not as an afterthought.
- Start state-based, add event sourcing when audit requirements or temporal queries make it obviously worth the complexity.