Traditional CRUD systems treat the current state as the source of truth. When you update a database record, the old value is lost forever. Event Sourcing flips this paradigm on its head: the history of events is the source of truth, and current state is merely a projection derived from that history.
This isn't just a technical pattern—it's a philosophical shift in how we think about data, time, and causality in software systems.
The Philosophical Shift: From State to Events
In traditional systems, we ask: "What is the current state?"
In Event Sourcing, we ask: "What events led to this state?"
This shift has profound implications:
- Immutability: Events are facts that happened; they cannot be changed
- Auditability: Complete history of all changes is preserved
- Temporal queries: We can ask "what was the state at time T?"
- Causality: We understand why things are the way they are

Traditional CRUD: The Lossy Approach
Let's see what we lose with traditional state-based persistence:
interface BankAccount {
id: string;
balance: number;
overdraftLimit: number;
lastModified: Date;
}
class TraditionalBankAccountService {
async withdraw(accountId: string, amount: number): Promise<void> {
const account = await this.db.findOne({ id: accountId });
if (account.balance < amount) {
throw new Error('Insufficient funds');
}
await this.db.update(
{ id: accountId },
{ balance: account.balance - amount, lastModified: new Date() }
);
}
}
Problems with this approach:
- Lost history: Previous balance values are gone
- No audit trail: Can't prove what happened when
- Limited debugging: Can't replay events to find bugs
- Concurrency issues: Update conflicts are hard to resolve
Event Sourcing: Capturing the Timeline
Now, let's model the same domain with Event Sourcing:
type AccountEvent =
| { type: 'AccountOpened'; accountId: string; initialDeposit: number; timestamp: Date; userId: string }
| { type: 'MoneyDeposited'; amount: number; timestamp: Date; userId: string; transactionId: string }
| { type: 'MoneyWithdrawn'; amount: number; timestamp: Date; userId: string; transactionId: string }
| { type: 'OverdraftLimitChanged'; newLimit: number; timestamp: Date; userId: string; reason: string };
class BankAccount {
private events: AccountEvent[] = [];
private balance: number = 0;
private overdraftLimit: number = 0;
private isOpen: boolean = false;
constructor(private readonly accountId: string) {}
openAccount(initialDeposit: number, userId: string): void {
if (this.isOpen) {
throw new Error('Account already open');
}
this.applyEvent({
type: 'AccountOpened',
accountId: this.accountId,
initialDeposit,
timestamp: new Date(),
userId
});
}
withdraw(amount: number, userId: string, transactionId: string): void {
if (!this.isOpen) {
throw new Error('Account not open');
}
if (this.balance - amount < -this.overdraftLimit) {
throw new Error('Insufficient funds');
}
this.applyEvent({
type: 'MoneyWithdrawn',
amount,
timestamp: new Date(),
userId,
transactionId
});
}
deposit(amount: number, userId: string, transactionId: string): void {
if (!this.isOpen) {
throw new Error('Account not open');
}
this.applyEvent({
type: 'MoneyDeposited',
amount,
timestamp: new Date(),
userId,
transactionId
});
}
private applyEvent(event: AccountEvent): void {
this.events.push(event);
switch(event.type) {
case 'AccountOpened':
this.isOpen = true;
this.balance = event.initialDeposit;
break;
case 'MoneyDeposited':
this.balance += event.amount;
break;
case 'MoneyWithdrawn':
this.balance -= event.amount;
break;
case 'OverdraftLimitChanged':
this.overdraftLimit = event.newLimit;
break;
}
}
static fromEvents(accountId: string, events: AccountEvent[]): BankAccount {
const account = new BankAccount(accountId);
events.forEach(event => account.applyEvent(event));
return account;
}
getBalance(): number { return this.balance; }
getEvents(): readonly AccountEvent[] { return this.events; }
getUncommittedEvents(): readonly AccountEvent[] {
return this.events;
}
}

The Event Store: Append-Only Persistence
The Event Store is a specialized database optimized for appending events and reading event streams:
interface StoredEvent {
streamId: string;
eventId: string;
eventType: string;
eventData: any;
metadata: {
timestamp: Date;
userId: string;
correlationId?: string;
};
version: number;
}
class EventStore {
private streams: Map<string, StoredEvent[]> = new Map();
async append(
streamId: string,
events: AccountEvent[],
expectedVersion: number
): Promise<void> {
const stream = this.streams.get(streamId) || [];
if (stream.length !== expectedVersion) {
throw new Error(`Concurrency conflict: expected version ${expectedVersion}, got ${stream.length}`);
}
const newEvents = events.map((event, index) => ({
streamId,
eventId: crypto.randomUUID(),
eventType: event.type,
eventData: event,
metadata: {
timestamp: event.timestamp,
userId: event.userId,
},
version: expectedVersion + index + 1
}));
stream.push(...newEvents);
this.streams.set(streamId, stream);
}
async getEvents(streamId: string, fromVersion: number = 0): Promise<AccountEvent[]> {
const stream = this.streams.get(streamId) || [];
return stream
.filter(e => e.version > fromVersion)
.map(e => e.eventData);
}
async getAllEvents(streamId: string): Promise<AccountEvent[]> {
return this.getEvents(streamId, 0);
}
}
Time Travel: Querying Historical State
One of Event Sourcing's superpowers is temporal queries—the ability to reconstruct state at any point in time:
class TemporalEventStore extends EventStore {
async getEventsUpTo(streamId: string, upTo: Date): Promise<AccountEvent[]> {
const allEvents = await this.getAllEvents(streamId);
return allEvents.filter(e => e.timestamp <= upTo);
}
async replayToTime(streamId: string, targetTime: Date): Promise<BankAccount> {
const events = await this.getEventsUpTo(streamId, targetTime);
return BankAccount.fromEvents(streamId, events);
}
async getDailySnapshots(streamId: string, year: number, month: number): Promise<Map<Date, number>> {
const snapshots = new Map<Date, number>();
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const endOfDay = new Date(year, month, day, 23, 59, 59);
const account = await this.replayToTime(streamId, endOfDay);
snapshots.set(endOfDay, account.getBalance());
}
return snapshots;
}
}
const eventStore = new TemporalEventStore();
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const accountYesterday = await eventStore.replayToTime('account-123', yesterday);
console.log('Balance yesterday:', accountYesterday.getBalance());
const dailyBalances = await eventStore.getDailySnapshots('account-123', 2025, 0);
dailyBalances.forEach((balance, date) => {
console.log(`${date.toDateString()}: $${balance}`);
});

CQRS: The Natural Companion Pattern
Event Sourcing naturally leads to Command Query Responsibility Segregation (CQRS)—separating writes (commands) from reads (queries).
Write Side: Commands and Events
interface WithdrawMoneyCommand {
accountId: string;
amount: number;
userId: string;
transactionId: string;
}
class AccountCommandHandler {
constructor(private eventStore: EventStore) {}
async handle(command: WithdrawMoneyCommand): Promise<void> {
const events = await this.eventStore.getAllEvents(command.accountId);
const account = BankAccount.fromEvents(command.accountId, events);
account.withdraw(command.amount, command.userId, command.transactionId);
const newEvents = account.getUncommittedEvents();
await this.eventStore.append(command.accountId, newEvents, events.length);
}
}
Read Side: Projections
interface AccountSummary {
accountId: string;
currentBalance: number;
totalDeposits: number;
totalWithdrawals: number;
transactionCount: number;
lastActivity: Date;
}
class AccountSummaryProjection {
private summaries: Map<string, AccountSummary> = new Map();
handleEvent(accountId: string, event: AccountEvent): void {
let summary = this.summaries.get(accountId);
if (!summary) {
summary = {
accountId,
currentBalance: 0,
totalDeposits: 0,
totalWithdrawals: 0,
transactionCount: 0,
lastActivity: new Date(0)
};
}
switch(event.type) {
case 'AccountOpened':
summary.currentBalance = event.initialDeposit;
summary.totalDeposits = event.initialDeposit;
summary.transactionCount = 1;
summary.lastActivity = event.timestamp;
break;
case 'MoneyDeposited':
summary.currentBalance += event.amount;
summary.totalDeposits += event.amount;
summary.transactionCount++;
summary.lastActivity = event.timestamp;
break;
case 'MoneyWithdrawn':
summary.currentBalance -= event.amount;
summary.totalWithdrawals += event.amount;
summary.transactionCount++;
summary.lastActivity = event.timestamp;
break;
}
this.summaries.set(accountId, summary);
}
getSummary(accountId: string): AccountSummary | undefined {
return this.summaries.get(accountId);
}
getAllSummaries(): AccountSummary[] {
return Array.from(this.summaries.values());
}
}

Multiple Projections: Different Views of the Same Data
With CQRS, you can create multiple specialized projections from the same event stream:
class FraudDetectionProjection {
async handleEvent(accountId: string, event: AccountEvent): Promise<void> {
if (event.type === 'MoneyWithdrawn') {
const recentWithdrawals = await this.getRecentWithdrawals(accountId);
if (this.detectSuspiciousPattern(recentWithdrawals, event)) {
await this.raiseAlert(accountId, event);
}
}
}
private detectSuspiciousPattern(history: any[], current: any): boolean {
return false;
}
}
class AnalyticsProjection {
private dailyMetrics: Map<string, number> = new Map();
handleEvent(accountId: string, event: AccountEvent): void {
if (event.type === 'MoneyWithdrawn' || event.type === 'MoneyDeposited') {
const dateKey = event.timestamp.toISOString().split('T')[0];
const current = this.dailyMetrics.get(dateKey) || 0;
const amount = event.type === 'MoneyDeposited'
? event.amount
: -event.amount;
this.dailyMetrics.set(dateKey, current + amount);
}
}
}
Event Versioning: Handling Schema Evolution
As systems evolve, event schemas change. Event Sourcing requires strategies for handling old event formats:
interface MoneyWithdrawnV1 {
type: 'MoneyWithdrawn';
amount: number;
timestamp: Date;
}
interface MoneyWithdrawnV2 {
type: 'MoneyWithdrawn';
version: 2;
amount: number;
timestamp: Date;
userId: string;
}
class EventUpcaster {
upcast(event: any): AccountEvent {
if (event.type === 'MoneyWithdrawn' && !event.version) {
return {
...event,
version: 2,
userId: 'SYSTEM'
};
}
return event;
}
}

Challenges and Tradeoffs
Event Sourcing is powerful but comes with complexity:
1. Storage Growth
Events accumulate forever (or until archived). Mitigation strategies:
class SnapshotStore {
async saveSnapshot(streamId: string, version: number, state: any): Promise<void> {
}
async getLatestSnapshot(streamId: string): Promise<{ version: number; state: any } | null> {
return null;
}
}
async function rehydrateWithSnapshot(streamId: string): Promise<BankAccount> {
const snapshot = await snapshotStore.getLatestSnapshot(streamId);
if (snapshot) {
const account = BankAccount.fromSnapshot(streamId, snapshot.state);
const remainingEvents = await eventStore.getEvents(streamId, snapshot.version);
return BankAccount.fromEvents(account, remainingEvents);
}
const allEvents = await eventStore.getAllEvents(streamId);
return BankAccount.fromEvents(streamId, allEvents);
}
2. Eventual Consistency
Projections may lag behind events:
class EventuallyConsistentQuery {
async getAccountBalance(accountId: string): Promise<{
balance: number;
asOf: Date;
isConsistent: boolean;
}> {
const projection = accountProjection.getSummary(accountId);
const lastEvent = await eventStore.getLastEvent(accountId);
return {
balance: projection.currentBalance,
asOf: projection.lastActivity,
isConsistent: projection.lastActivity >= lastEvent.timestamp
};
}
}
3. Learning Curve
Event Sourcing requires a different mental model. Teams need training and practice.
4. Querying Complexity
Ad-hoc queries require projections. You can't just SELECT * FROM users WHERE ...

When to Use Event Sourcing
Event Sourcing shines in domains where:
✅ Audit requirements are strict (finance, healthcare, legal)
✅ History matters (undo/redo, temporal queries)
✅ Complex workflows (sagas, process managers)
✅ Event-driven architecture (microservices, CQRS)
✅ High write throughput (append-only is fast)
❌ Avoid when:
- Simple CRUD suffices
- Team lacks experience
- Reporting needs are unpredictable
- Real-time consistency is critical
Conclusion: Time as a First-Class Citizen
Event Sourcing represents a fundamental shift in how we model state:
- Events over state: History is the source of truth
- Immutability: Facts cannot be changed, only added
- Temporal queries: State at any point in time
- Perfect audit: Complete causality chain
- Flexibility: Multiple projections from one stream
By treating time as a first-class citizen, Event Sourcing provides capabilities that traditional CRUD systems simply cannot match:
- Time travel for debugging and analysis
- Replay for testing and migration
- Audit trails for compliance
- Event-driven integration
The cost is increased complexity, but for domains where history matters, Event Sourcing provides unparalleled power.
The question isn't whether Event Sourcing is good or bad—it's whether your domain benefits from treating time as immutable history rather than ephemeral state.

Have you implemented Event Sourcing in production? What challenges did you face? Share your experiences in the comments.