Event sourcing in one minute
Most systems today treat the database as a source of truth, storing only the current state and discarding how you got there. Event sourcing flips this: instead of storing what things are now, you store everything that happened. In this pattern, every change to your domain is captured as an immutable event, and your current state is simply the result of replaying all those events from the beginning. Let me walk you through how this works in practice and when it actually makes sense.
Every change is an immutable event
In traditional systems, when a user updates their profile, you modify a row in a table. The old state is lost. With event sourcing, you don't update anything—you append an immutable record of what changed.
Consider a ride-hailing platform. Instead of updating a trip record directly, you create events:
TripRequested— passenger booked a rideDriverAssigned— driver accepted the tripTripStarted— pickup completedTripCompleted— passenger dropped offPaymentProcessed— payment cleared
Each event is a fact. It happened. It cannot be undone. You don't delete it, you don't modify it. If a mistake occurs, you emit a new event that corrects or contextualizes the previous one.
Here's what that looks like in code:
interface DomainEvent {
eventId: string;
aggregateId: string;
eventType: string;
timestamp: Date;
version: number;
data: Record<string, unknown>;
}
const tripRequestedEvent: DomainEvent = {
eventId: crypto.randomUUID(),
aggregateId: "trip-12345",
eventType: "TripRequested",
timestamp: new Date(),
version: 1,
data: {
passengerId: "user-789",
pickupLocation: { lat: 6.9271, lng: 80.7789 },
dropoffLocation: { lat: 6.9125, lng: 80.8355 },
},
};
const driverAssignedEvent: DomainEvent = {
eventId: crypto.randomUUID(),
aggregateId: "trip-12345",
eventType: "DriverAssigned",
timestamp: new Date("2024-01-15T10:05:00Z"),
version: 2,
data: {
driverId: "driver-456",
estimatedArrival: 240, // seconds
},
};
Each event is stored in an append-only log, typically in a table like events with columns for aggregate_id, event_type, data (JSON), version, and created_at. The immutability guarantee means you never worry about concurrent updates destroying data—the log is the single source of truth.
Current state = replaying events
To know the current state of a trip, you don't query a trips table. You load all events for that trip ID, then replay them in order, building up the state as you go.
Here's how you'd reconstruct a trip:
interface Trip {
id: string;
passengerId: string;
driverId: string | null;
status: "requested" | "assigned" | "started" | "completed";
pickupLocation: { lat: number; lng: number };
dropoffLocation: { lat: number; lng: number };
estimatedArrival: number | null;
version: number;
}
async function getTripState(tripId: string): Promise<Trip> {
const events = await eventStore.getEventsByAggregateId(tripId);
let trip: Trip = {
id: tripId,
passengerId: "",
driverId: null,
status: "requested",
pickupLocation: { lat: 0, lng: 0 },
dropoffLocation: { lat: 0, lng: 0 },
estimatedArrival: null,
version: 0,
};
for (const event of events) {
switch (event.eventType) {
case "TripRequested":
trip.passengerId = event.data.passengerId;
trip.pickupLocation = event.data.pickupLocation;
trip.dropoffLocation = event.data.dropoffLocation;
trip.status = "requested";
break;
case "DriverAssigned":
trip.driverId = event.data.driverId;
trip.estimatedArrival = event.data.estimatedArrival;
trip.status = "assigned";
break;
case "TripStarted":
trip.status = "started";
break;
case "TripCompleted":
trip.status = "completed";
break;
}
trip.version = event.version;
}
return trip;
}
This is the core insight: state is derived, not stored. If you need to fix a bug in your state logic, you can rebuild the entire system's state by replaying all events with corrected rules. No data loss, no migration scripts—just recompute.
For performance, you don't replay from scratch every time. You create snapshots—checkpoints of state at version 100, 200, 500. Then to get the current state, you load the latest snapshot and replay only the events since that snapshot. This is essential at scale.
Perfect audit history
One of the biggest wins of event sourcing is compliance and debugging. You have a complete, immutable record of everything that happened. Every change is timestamped, versioned, and attributable.
For a financial trading platform I built, this was invaluable. Regulators wanted to know: what orders were placed, when, by whom, and what changed them? With event sourcing, you answer that in seconds. Every OrderPlaced, OrderCancelled, OrderAmendedPrice, OrderFilled event is there, with precise timestamps and actor information.
You can also implement temporal queries—show me what this order looked like at 3:45 PM. Replay events up to that timestamp. Want to debug a race condition? Replay the exact sequence of events for that user and watch it unfold.
In a SaaS billing context, this is gold. If a customer disputes a charge, you have the event history: subscription created, plan upgraded, payment attempt, retry, success. No guessing, no reconstructing.
Event sourcing doesn't just solve current-state queries; it solves the question "how did we get here?" that traditional databases can never really answer.
However, you must handle sensitive data carefully. If you store credit card numbers or passwords in events, you've created a compliance nightmare. Typically, you only store the outcome ("PaymentSucceeded") not the sensitive input. Use encryption, access controls, and event filters to protect PII.
Adds complexity — use selectively
Event sourcing is powerful, but it's not a free lunch. It introduces real complexity that you need to be aware of before adopting it.
Consistency and eventual consistency: In a traditional system, you update state and read it immediately. With event sourcing, your read model (the current state you query) may lag behind events being written. If you need strong consistency everywhere, event sourcing makes it harder. If eventual consistency is acceptable—and for most domains it is—you're fine.
Event schema evolution: Once events are in your log, you can't change them. If you need to add a field to an event, you're stuck. You have to create a new event type or use versioning strategies. This is manageable but requires discipline.
Testing and debugging: Replaying 10,000 events to get to a state is slow during tests. You need snapshots, test factories, and in-memory event stores to keep feedback loops fast.
Tooling and infrastructure: You need a proper event store (Postgres with a well-designed schema, EventStoreDB, or cloud-managed solutions). You need to manage event publishing, event ordering per aggregate, and idempotency for distributed systems.
Here's a practical decision tree: use event sourcing if you have any of these requirements:
- Regulatory or compliance auditing
- Complex domain logic where understanding history is critical
- Need for temporal queries or point-in-time reconstruction
- Multiple read models feeding different views
- High write concurrency where traditional locking is a bottleneck
Don't use it for:
- Simple CRUD applications
- Systems where queries are write-heavy and reads are minimal
- Teams unfamiliar with distributed systems concepts
- Domains where eventual consistency is unacceptable everywhere
Common mistakes
- Treating events as API contracts: Events are internal facts, not API payloads. Don't version-lock your API to event structure. Use mappers to decouple them.
- Storing mutable data in events: If you store a user object that later changes, your event becomes inconsistent. Store immutable, primitive values. Reference IDs, not objects.
- Ignoring event ordering guarantees: Across different aggregates, events can happen in any order. Within one aggregate, order must be preserved. Don't mix these up in your queries.
- Rebuilding entire state on every request: This will tank performance. Snapshot and cache aggressively. Use read models (separate tables optimized for queries) alongside your event log.
- Forgetting about snapshots at scale: If you have millions of events per aggregate, replaying from scratch is unusable. Snapshot regularly. Test snapshot logic thoroughly.
Key takeaways
- Event sourcing stores immutable events instead of current state; state is derived by replaying events in order.
- You gain a perfect audit trail and the ability to answer historical questions that traditional databases can't.
- The trade-off is added complexity: eventual consistency, schema evolution challenges, and more infrastructure work.
- Use event sourcing selectively—for domains with audit needs, complex histories, or multiple read model requirements. Skip it for simple CRUD.
- In production, always snapshot and cache derived state. Replaying millions of events every request is a recipe for performance disaster.
- Keep events immutable and primitive. Don't store mutable references or sensitive data directly. Use event versioning and mapping layers for evolution.






Comments
Loading comments…