Event Sourcing: Knowing When NOT To Use It

Concept
Event sourcing is an architectural pattern in which the state of an entity is derived entirely from a sequence of immutable, append-only events, rather than being stored as a current-value snapshot in a mutable row. Instead of UPDATE accounts SET balance = 950 WHERE id = 'acc-1', you persist BalanceDebited { AccountId: "acc-1", Amount: 50, Timestamp: ... }. The current state is reconstructed by replaying all events from the beginning of time — or from the last snapshot.
The pattern promises four things: a complete, tamper-evident audit log; the ability to replay events to rebuild projections in any shape; support for temporal queries ("what was the state at T-minus 3 days?"); and decoupled event-driven integration. For specific domains, these promises are valuable. For most domains, they come with hidden costs that are systematically underestimated during the design phase.
The core tension is this: event sourcing optimises for write-side auditability at the cost of read-side complexity and operational overhead. The read side must be explicitly built, maintained, and rebuilt. This is work that a traditional CRUD system gets for free from the database's current state.
Constraints
Projection Rebuild Costs
Every projection (read model) is a function that folds an event stream into a queryable state. When your business logic changes — and it always does — you must rebuild that projection from scratch. For an event store with 100 million events across all aggregates, a full rebuild means:
- Sequential event stream consumption (can be parallelised by aggregate partition, but still bounded by I/O throughput)
- Deterministic event handler execution for every event
- Writing the rebuilt projection to a new store (or a shadow table) before cutting traffic over
In production EventStoreDB deployments, teams report rebuild times ranging from 30 minutes to 18 hours, depending on event volume, handler complexity, and infrastructure. During a rebuild, you are operating on a stale read model. This is not a hypothetical: any schema change to a projection forces this.
Snapshot Strategy Overhead
Snapshots mitigate the cost of replaying from event zero. A snapshot captures the aggregate's state at a specific event sequence number, allowing replay to start from that snapshot forward. The snapshot itself must be:
- Versioned: if the snapshot schema changes (as it will when your aggregate evolves), old snapshots become invalid.
- Consistently stored: a snapshot write and the corresponding event position must be atomic, or you risk a snapshot that lags behind events.
- Invalidated on schema change: you cannot automatically migrate a serialised snapshot aggregate the way you can run a SQL migration.
The operational contract for snapshots is: for every aggregate that accumulates more than N events (commonly 50–500), a background process must periodically snapshot and the application must handle the case where no snapshot exists or the snapshot version is incompatible.
Temporal Coupling Through Event Schema
Events are public contracts. Once you publish OrderPlaced { v1 } and consumers have subscribed to it, you cannot change the schema without either: a) versioning the event (maintaining deserialisation for v1 and a new v2 handler), b) running a migration that replays and re-publishes events in the new format, or c) breaking downstream consumers.
This is temporal coupling at the schema level. Traditional REST APIs have temporal coupling at the interface boundary. Event-sourced systems have it baked into every persisted domain event, forever. The longer your event store runs, the larger the backward-compatibility surface you must maintain.
Aggregate Boundaries and the Event Explosion Problem
Event sourcing encourages fine-grained aggregates with explicit boundaries. When aggregate boundaries are wrong — which is common in early design — correcting them requires migrating the event store. You cannot "move" events between aggregates without a painful extract-transform-load (ETL) operation against your immutable store. With a relational database, a schema migration is painful but tractable; with an event store, the events are the schema.
Trade-offs
Where Event Sourcing Is the Right Choice
Event sourcing is genuinely valuable in precisely three scenarios:
Regulatory audit requirements with legal immutability constraints: Financial services, healthcare, and payment systems where you must prove what happened and who authorised it — not just what the current state is. The audit log is not a by-product; it is the primary deliverable.
Complex temporal business queries: Systems where "what was the state at point T" is a first-class query, not an afterthought. Insurance claim processing, financial reporting, and compliance forensics fall here.
Event-driven integration across bounded contexts: When your aggregate's state transitions are themselves the integration API — other services subscribe to
OrderShipped,PaymentProcessed— and you need guaranteed delivery with replay capability.
Where Event Sourcing Is Actively Harmful
- CRUD-heavy domains with simple state: A product catalogue, user profile store, or content management system. The state is what matters; the history of mutations is not a business requirement. Adding event sourcing here adds projection rebuilds, snapshot management, and event versioning overhead for zero business value.
- Teams without DDD discipline: Event sourcing requires well-defined aggregate boundaries and ubiquitous language. Without this foundation, you will produce an event store full of anemic, technically-named events (
RecordUpdated,FieldChanged) that provide no semantic value and are impossible to build meaningful projections from. - Systems with high event mutation rate: Any aggregate that generates thousands of events per minute (e.g., a real-time telemetry sensor aggregate) will require aggressive snapshotting and expensive projection rebuilds. The append-only store will grow at an unmanageable rate.
- Small teams without operational tooling maturity: Event stores require custom operational tooling: event replay scripts, snapshot management jobs, projection health monitors. If your team cannot invest in this tooling, you will accumulate operational debt rapidly.
Code
The following C# code demonstrates a snapshot-aware event stream replay, which is the core read-side reconstruction pattern. Note the explicit version checking and fallback-to-full-replay path.
public class AccountAggregateRepository
{
private readonly IEventStore _eventStore;
private readonly ISnapshotStore _snapshotStore;
private readonly ILogger<AccountAggregateRepository> _logger;
private const int SnapshotThreshold = 100; // snapshot every 100 events
public AccountAggregateRepository(
IEventStore eventStore,
ISnapshotStore snapshotStore,
ILogger<AccountAggregateRepository> logger)
{
_eventStore = eventStore;
_snapshotStore = snapshotStore;
_logger = logger;
}
public async Task<AccountAggregate> LoadAsync(Guid accountId, CancellationToken ct)
{
var snapshotEnvelope = await _snapshotStore.LoadLatestAsync<AccountSnapshot>(
accountId.ToString(), ct);
long startFromVersion = 0;
AccountAggregate account;
if (snapshotEnvelope != null && snapshotEnvelope.SchemaVersion == AccountSnapshot.CurrentSchemaVersion)
{
account = AccountAggregate.RestoreFromSnapshot(snapshotEnvelope.Snapshot);
startFromVersion = snapshotEnvelope.StreamPosition + 1;
_logger.LogDebug(
"Loaded snapshot for account {AccountId} at stream position {Position}",
accountId, snapshotEnvelope.StreamPosition);
}
else
{
if (snapshotEnvelope != null)
{
_logger.LogWarning(
"Snapshot schema version mismatch for account {AccountId}. " +
"Expected v{Expected}, found v{Found}. Replaying from event zero.",
accountId,
AccountSnapshot.CurrentSchemaVersion,
snapshotEnvelope.SchemaVersion);
}
account = new AccountAggregate();
startFromVersion = 0;
}
var events = await _eventStore.ReadStreamAsync(
streamName: $"account-{accountId}",
fromVersion: startFromVersion,
ct: ct);
foreach (var domainEvent in events)
{
account.Apply(domainEvent);
}
_logger.LogDebug(
"Replayed {EventCount} events for account {AccountId}",
events.Count, accountId);
// Opportunistic snapshotting: if we replayed more than threshold events past last snapshot
if (events.Count >= SnapshotThreshold)
{
await TakeSnapshotAsync(account, ct);
}
return account;
}
private async Task TakeSnapshotAsync(AccountAggregate account, CancellationToken ct)
{
var snapshot = account.TakeSnapshot();
var envelope = new SnapshotEnvelope<AccountSnapshot>(
snapshot,
account.Version,
AccountSnapshot.CurrentSchemaVersion);
await _snapshotStore.SaveAsync(account.Id.ToString(), envelope, ct);
_logger.LogInformation(
"Snapshot taken for account {AccountId} at version {Version}",
account.Id, account.Version);
}
}
Below is an event versioning handler that demonstrates how to support both v1 and v2 of the MoneyTransferred event without breaking the replay of historical events. This is the pattern that accumulates over time — every evolved event adds a branch here.
public class MoneyTransferredEventHandler : IHandleEvent<MoneyTransferredV1>, IHandleEvent<MoneyTransferredV2>
{
private readonly IProjectionWriter<AccountSummaryProjection> _writer;
private readonly ILogger<MoneyTransferredEventHandler> _logger;
public MoneyTransferredEventHandler(
IProjectionWriter<AccountSummaryProjection> writer,
ILogger<MoneyTransferredEventHandler> logger)
{
_writer = writer;
_logger = logger;
}
// Handles legacy events still present in the event store from pre-2023 data
public async Task HandleAsync(MoneyTransferredV1 @event, CancellationToken ct)
{
_logger.LogDebug(
"Handling legacy MoneyTransferredV1 for account {AccountId}", @event.AccountId);
// V1 had no fee tracking — we default fees to zero for historical accuracy
await _writer.UpdateAsync(@event.AccountId, projection =>
{
projection.Balance -= @event.Amount;
projection.TransactionCount++;
projection.LastActivityAt = @event.OccurredAt;
projection.TotalFeesCharged += 0m; // V1 had no fee field
}, ct);
}
// Handles events from 2024 onward with fee tracking
public async Task HandleAsync(MoneyTransferredV2 @event, CancellationToken ct)
{
_logger.LogDebug(
"Handling MoneyTransferredV2 for account {AccountId}", @event.AccountId);
await _writer.UpdateAsync(@event.AccountId, projection =>
{
projection.Balance -= (@event.Amount + @event.ProcessingFee);
projection.TransactionCount++;
projection.LastActivityAt = @event.OccurredAt;
projection.TotalFeesCharged += @event.ProcessingFee;
}, ct);
}
}
The second handler is 15 lines of code. Multiply it by 40 event types over 4 years of schema evolution. This is the accumulating cost of temporal coupling — a cost that does not show up in sprint velocity until a junior engineer is asked to add a new projection and discovers they must understand the history of every event type the system has ever produced.