Event Sourcing in Practice: When It Helps and When It Hurts
A balanced look at event sourcing: where it genuinely shines, the pitfalls that derail implementations, and practical event store and projection patterns.

The Promise and the Trap of Event Sourcing
Event sourcing stores every state change as an immutable event rather than overwriting the current state. Instead of a row that says "account balance: $500," you have a sequence of events: deposited $1000, withdrew $300, deposited $200, withdrew $400. The current state is derived by replaying events.
The promise is complete audit history, temporal queries, and the ability to rebuild state from scratch. The trap is operational complexity, eventual consistency challenges, and the temptation to use it where it does not belong.
Where Event Sourcing Shines
Event sourcing is not a universal pattern. It delivers the most value in domains where the history of changes is as important as the current state.
interface EventSourcingFitAssessment {
domain: string;
fit: "excellent" | "good" | "poor";
reason: string;
keyBenefit: string;
}
const fitAssessments: EventSourcingFitAssessment[] = [
{
domain: "Financial transactions",
fit: "excellent",
reason: "Regulatory requirements demand complete audit trail",
keyBenefit: "Immutable ledger satisfies compliance out of the box",
},
{
domain: "Order management",
fit: "excellent",
reason: "Order lifecycle has distinct, meaningful state transitions",
keyBenefit: "Full order history enables dispute resolution",
},
{
domain: "Collaborative editing",
fit: "good",
reason: "Changes must be mergeable and replayable",
keyBenefit: "Conflict resolution through event replay",
},
{
domain: "User profile CRUD",
fit: "poor",
reason: "Current state is all that matters, changes have no business value",
keyBenefit: "None — adds complexity without value",
},
{
domain: "Static content management",
fit: "poor",
reason: "Low write frequency, query-heavy, history rarely needed",
keyBenefit: "None — a simple database is better",
},
];Event Store Implementation
The event store is the core infrastructure of event sourcing. It must guarantee ordering within a stream, support optimistic concurrency, and enable efficient replay.
interface DomainEvent {
eventId: string;
streamId: string;
eventType: string;
data: Record<string, unknown>;
metadata: {
timestamp: Date;
version: number;
correlationId: string;
causationId: string;
userId?: string;
};
}
interface EventStore {
append(
streamId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void>;
readStream(
streamId: string,
fromVersion?: number
): Promise<DomainEvent[]>;
readAll(
fromPosition?: number,
limit?: number
): Promise<DomainEvent[]>;
}
class PostgresEventStore implements EventStore {
constructor(private readonly db: Database) {}
async append(
streamId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
await this.db.transaction(async (tx) => {
// Optimistic concurrency check
const currentVersion = await tx.queryOne<{ max_version: number }>(
"SELECT COALESCE(MAX(version), 0) as max_version FROM events WHERE stream_id = $1",
[streamId]
);
if (currentVersion.max_version !== expectedVersion) {
throw new ConcurrencyError(
`Expected version ${expectedVersion}, found ${currentVersion.max_version}`
);
}
// Append events
for (let i = 0; i < events.length; i++) {
const event = events[i];
const version = expectedVersion + i + 1;
await tx.execute(
`INSERT INTO events (event_id, stream_id, event_type, data, metadata, version)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
event.eventId,
streamId,
event.eventType,
JSON.stringify(event.data),
JSON.stringify({ ...event.metadata, version }),
version,
]
);
}
});
}
async readStream(
streamId: string,
fromVersion: number = 0
): Promise<DomainEvent[]> {
const rows = await this.db.query(
"SELECT * FROM events WHERE stream_id = $1 AND version > $2 ORDER BY version ASC",
[streamId, fromVersion]
);
return rows.map(this.mapRowToEvent);
}
async readAll(fromPosition = 0, limit = 1000): Promise<DomainEvent[]> {
const rows = await this.db.query(
"SELECT * FROM events WHERE global_position > $1 ORDER BY global_position ASC LIMIT $2",
[fromPosition, limit]
);
return rows.map(this.mapRowToEvent);
}
private mapRowToEvent(row: any): DomainEvent {
return {
eventId: row.event_id,
streamId: row.stream_id,
eventType: row.event_type,
data: row.data,
metadata: row.metadata,
};
}
}Optimistic concurrency prevents two writers from appending conflicting events to the same stream simultaneously. If the expected version does not match the actual version, the operation fails and the caller must retry with the latest state.
Aggregate Reconstruction from Events
Aggregates rehydrate their state by replaying events from the event store. Each event applies a mutation to the aggregate state.
// ❌ Mutable state without event history
class MutableBankAccount {
balance: number = 0;
deposit(amount: number): void {
this.balance += amount;
// How did we get here? Who deposited what and when? No idea.
}
}
// ✅ Event-sourced aggregate
interface AccountEvent extends DomainEvent {
eventType: "AccountOpened" | "MoneyDeposited" | "MoneyWithdrawn" | "AccountClosed";
}
class BankAccount {
private balance = 0;
private status: "active" | "closed" = "active";
private version = 0;
private uncommittedEvents: DomainEvent[] = [];
static fromEvents(events: DomainEvent[]): BankAccount {
const account = new BankAccount();
for (const event of events) {
account.apply(event, false);
}
return account;
}
deposit(amount: number, correlationId: string): void {
if (this.status !== "active") {
throw new Error("Cannot deposit to a closed account");
}
if (amount <= 0) {
throw new Error("Deposit amount must be positive");
}
this.apply(
this.createEvent("MoneyDeposited", { amount }),
true
);
}
withdraw(amount: number, correlationId: string): void {
if (this.status !== "active") {
throw new Error("Cannot withdraw from a closed account");
}
if (amount > this.balance) {
throw new Error("Insufficient funds");
}
this.apply(
this.createEvent("MoneyWithdrawn", { amount }),
true
);
}
private apply(event: DomainEvent, isNew: boolean): void {
switch (event.eventType) {
case "AccountOpened":
this.status = "active";
this.balance = 0;
break;
case "MoneyDeposited":
this.balance += (event.data as { amount: number }).amount;
break;
case "MoneyWithdrawn":
this.balance -= (event.data as { amount: number }).amount;
break;
case "AccountClosed":
this.status = "closed";
break;
}
this.version++;
if (isNew) {
this.uncommittedEvents.push(event);
}
}
private createEvent(
eventType: string,
data: Record<string, unknown>
): DomainEvent {
return {
eventId: crypto.randomUUID(),
streamId: "",
eventType,
data,
metadata: {
timestamp: new Date(),
version: this.version + 1,
correlationId: "",
causationId: "",
},
};
}
getUncommittedEvents(): DomainEvent[] {
return [...this.uncommittedEvents];
}
getBalance(): number {
return this.balance;
}
getVersion(): number {
return this.version;
}
}Projections: Building Read Models
Event sourcing separates writes (events) from reads (projections). Projections consume events and build optimized read models for specific query patterns.
interface Projection {
name: string;
handle(event: DomainEvent): Promise<void>;
rebuild(): Promise<void>;
}
class AccountBalanceProjection implements Projection {
name = "account-balances";
constructor(private readonly db: Database) {}
async handle(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case "AccountOpened":
await this.db.execute(
"INSERT INTO account_balances (account_id, balance, last_updated) VALUES ($1, 0, $2)",
[event.streamId, event.metadata.timestamp]
);
break;
case "MoneyDeposited":
await this.db.execute(
"UPDATE account_balances SET balance = balance + $1, last_updated = $2 WHERE account_id = $3",
[event.data.amount, event.metadata.timestamp, event.streamId]
);
break;
case "MoneyWithdrawn":
await this.db.execute(
"UPDATE account_balances SET balance = balance - $1, last_updated = $2 WHERE account_id = $3",
[event.data.amount, event.metadata.timestamp, event.streamId]
);
break;
}
}
async rebuild(): Promise<void> {
await this.db.execute("TRUNCATE account_balances");
// Replay all events from the beginning
}
}Common Pitfalls
interface EventSourcingPitfall {
mistake: string;
consequence: string;
prevention: string;
}
const pitfalls: EventSourcingPitfall[] = [
{
mistake: "Not versioning event schemas",
consequence: "Old events break when deserialized with new code",
prevention: "Include schema version, use upcasters to transform old events",
},
{
mistake: "Storing too much data in events",
consequence: "Event store grows uncontrollably, replay becomes slow",
prevention: "Store meaningful domain facts, not raw request payloads",
},
{
mistake: "No snapshotting for long-lived aggregates",
consequence: "Loading an aggregate with 10000 events takes seconds",
prevention: "Snapshot every N events and replay only from the last snapshot",
},
{
mistake: "Coupling projections to event structure",
consequence: "Every event schema change requires updating all projections",
prevention: "Use anti-corruption layers between events and projections",
},
];Key Takeaways
Event sourcing is a powerful pattern for domains where the history of changes is valuable—financial systems, order management, collaborative editing. It provides complete audit trails, temporal queries, and the ability to derive new read models from existing events.
However, it introduces significant complexity: eventual consistency between projections and the event store, schema evolution challenges, and the operational overhead of managing event stores and projection infrastructure. Do not use event sourcing for simple CRUD domains where the current state is all that matters.
When you do use it, invest in proper event versioning, snapshotting for long-lived aggregates, and rebuild capabilities for projections. The immutability of events is both the greatest strength and the greatest constraint—you cannot fix a bad event, only compensate for it with new events.


