Event sourcing is one of those patterns that sounds elegant in conference talks but becomes treacherous in production. I have been part of three major event sourcing implementations, and each one taught me something the tutorials never mentioned. Here are the pitfalls that cost us the most time and how to avoid them.
Schema Evolution Is Your Biggest Challenge
Events are immutable — you cannot change history. But your understanding of the domain will evolve, and so will your event schemas. If you do not plan for schema evolution from day one, you will end up in a painful migration scenario within months.
// Version 1 — seemed fine at the time
public record OrderPlaced(Guid OrderId, string CustomerName, decimal Total);
// Version 2 — we need structured customer info and currency
public record OrderPlacedV2(
Guid OrderId,
Guid CustomerId,
string CustomerName,
decimal Total,
string CurrencyCode);
// Upcaster transforms V1 events to V2 shape during replay
public class OrderPlacedUpcaster : IEventUpcaster<OrderPlaced, OrderPlacedV2>
{
public OrderPlacedV2 Upcast(OrderPlaced original) => new(
original.OrderId,
CustomerId: Guid.Empty, // Unknown for historical events
original.CustomerName,
original.Total,
CurrencyCode: "EUR"); // Default assumption for old events
}
The upcaster approach works but has limits. Eventually, you may need to do a full stream migration, replaying all events through the new schema and writing a new stream. Plan your infrastructure to support this.
Event Replay Performance Degrades Over Time
When an aggregate has thousands of events, replaying them to reconstitute state becomes slow. Snapshots are the standard solution, but they introduce their own complexity — you need to version snapshots, handle snapshot corruption, and decide on a snapshot frequency.
public class OrderAggregate
{
public int Version { get; private set; }
public static OrderAggregate LoadFromSnapshot(
OrderSnapshot snapshot,
IEnumerable<IDomainEvent> eventsSinceSnapshot)
{
var aggregate = new OrderAggregate();
aggregate.RestoreFromSnapshot(snapshot);
foreach (var @event in eventsSinceSnapshot)
aggregate.Apply(@event);
return aggregate;
}
private void RestoreFromSnapshot(OrderSnapshot snapshot)
{
// Restore state from snapshot
Version = snapshot.Version;
// ... restore other fields
}
}
Eventual Consistency Confuses Users
With CQRS and event sourcing, the read model is eventually consistent. Users submit a command, get a success response, then immediately query the read model and see stale data. This is technically correct but creates a terrible user experience.
We solved this with a simple pattern: after a successful command, return the expected new state directly from the command handler, and let the client use that until the read model catches up. It is pragmatic, not pure, and users stop filing bug reports.
Event Ordering Across Aggregates Is Not Guaranteed
Within a single aggregate stream, event order is guaranteed. Across streams, it is not. If your business logic depends on cross-aggregate event ordering, you have a design problem. Either merge the aggregates (if the consistency boundary truly requires it) or use a process manager that explicitly coordinates the ordering.
The biggest takeaway: event sourcing is not an all-or-nothing decision. Use it where you genuinely need an audit trail, temporal queries, or complex state reconstruction. For straightforward CRUD, a traditional state-based model is simpler, cheaper, and perfectly adequate.
Comments (2)
I tried this approach and it works perfectly!
Interesting thought, thanks for adding that.
Well written and easy to follow. Keep it up!
Leave a Comment