Event Sourcing vs Traditional CRUD
A traditional CRUD table tells you the current state of a row and nothing about how it got there. Event sourcing inverts that: it stores every change that ever happened, and current state becomes something you compute, not something you store. That inversion solves real problems and creates a few new ones — worth understanding both before reaching for it.
CRUD: state in place, history nowhere
A normal orders table update overwrites the row:
UPDATE orders SET status = 'shipped', shipped_at = now() WHERE id = 4821;
After this runs, the previous status is gone unless something else — an audit log table, application-level logging, a trigger — captured it separately, as an afterthought bolted onto the actual data model. If a customer asks "why does my order show as shipped when I never got a shipping notification," and the answer requires knowing it briefly flipped to shipped and back due to a bug three days ago, a CRUD table simply doesn't have that information anymore.
Event sourcing: the log is the source of truth
Instead of storing current state, you store an append-only sequence of events, and current state is derived by replaying them:
// events, in order, for order 4821
[
{ type: 'OrderPlaced', orderId: 4821, items: [...], at: '2026-03-20T10:00:00Z' },
{ type: 'PaymentReceived', orderId: 4821, amount: 4999, at: '2026-03-20T10:01:12Z' },
{ type: 'OrderShipped', orderId: 4821, carrier: 'ups', at: '2026-03-22T14:30:00Z' },
]
Current state is a fold over that list:
function projectOrder(events) {
return events.reduce((state, event) => {
switch (event.type) {
case 'OrderPlaced':
return { ...state, id: event.orderId, items: event.items, status: 'placed' };
case 'PaymentReceived':
return { ...state, status: 'paid', paidAmount: event.amount };
case 'OrderShipped':
return { ...state, status: 'shipped', carrier: event.carrier };
default:
return state;
}
}, {});
}
const currentOrder = projectOrder(eventsForOrder(4821));
Nothing here is deleted or overwritten. The full history — every state the order ever passed through, and exactly when — is inherent to the model, not a separate system you have to remember to maintain.
What you actually gain
- A real audit trail, for free. "What happened, and in what order" is the primary data, not a derived log you have to keep in sync with the "real" table.
- Time travel. You can project state as of any point in the past by replaying events only up to that timestamp — genuinely useful for debugging ("what did this look like right before the bug report came in") and for compliance requirements that ask exactly that question.
- New read models without touching write logic. Need a new view of the data — total revenue by day, orders per carrier — for a dashboard that didn't exist when the system was built? Write a new projection function over the existing event stream. The events themselves never change.
- Decoupled write and read paths. The events are the write model; projections are read models built from them, often asynchronously. This naturally leads into CQRS (Command Query Responsibility Segregation), where writes and reads can scale and evolve independently.
What it actually costs
- Every query needs a projection. "What's the current status of order 4821" isn't a row lookup anymore — it's replaying however many events that order has accumulated, unless you cache the projected state (a "snapshot") and only replay events since the last snapshot.
// snapshot every 50 events instead of replaying from the start each time
function projectWithSnapshot(orderId, snapshot, eventsSinceSnapshot) {
return eventsSinceSnapshot.reduce(applyEvent, snapshot ?? initialState());
}
- Schema evolution is harder, not easier. You can never change what an old event meant — code written today still needs to correctly interpret an
OrderPlacedevent from three years ago, in whatever shape it had then. Adding a new event type is easy; changing an existing one's structure means versioning it (OrderPlacedV2) and keeping the old handler around indefinitely. - Deletion is awkward on purpose. "Delete this customer's data" (a real requirement under GDPR) fights directly against "events are immutable and never deleted." Real systems handle this with crypto-shredding (encrypt personal fields with a per-customer key, then delete the key to make the data unrecoverable) rather than actually removing events from the log.
- Debugging requires thinking in sequences, not snapshots. "What's wrong with this row" becomes "what's wrong with this sequence of 40 events and the projection logic that folds them" — a genuinely different debugging mindset that takes a team time to get comfortable with.
Where it's worth it
Event sourcing earns its cost where the history has real business value on its own — financial ledgers, order lifecycles, anything with audit or compliance requirements, or systems where "what changed and why" is a first-class question users actually ask. A users table tracking display_name and avatar_url almost never needs this; nobody's asking for the audit trail of someone's profile picture.
A middle ground: outbox / audit tables without full event sourcing
You don't have to choose all-or-nothing. A common practical pattern is a normal CRUD table for current state, plus an append-only order_events table populated in the same transaction as every write:
BEGIN;
UPDATE orders SET status = 'shipped' WHERE id = 4821;
INSERT INTO order_events (order_id, type, payload, at)
VALUES (4821, 'OrderShipped', '{"carrier":"ups"}', now());
COMMIT;
This gets you the audit trail and time-travel-for-debugging benefits without making every read a replay — current state is still a direct row lookup. You lose the "read models are just new projections" flexibility, but for most applications that's a fair trade for how much simpler it is to build and reason about.
Takeaway
CRUD stores where you are; event sourcing stores how you got there and derives where you are from it. The audit trail, time travel, and flexible read models are real and valuable — but they come with harder schema evolution, awkward deletion, and a fundamentally different debugging model. Reach for full event sourcing when history is the point of the system, not just a nice-to-have; otherwise, an audit table alongside normal CRUD usually gets you most of the benefit for a fraction of the complexity.