The core idea of event sourcing is that you don’t store the current state of an object; instead, you store a sequence of events that, when replayed, reconstruct the current state.
Let’s see this in action. Imagine a simple BankAccount service. Instead of a table like accounts with columns id, balance, and version, we have an events table.
-- Instead of:
-- CREATE TABLE accounts (
-- id UUID PRIMARY KEY,
-- balance DECIMAL NOT NULL,
-- version INT NOT NULL
-- );
-- We have:
CREATE TABLE account_events (
id SERIAL PRIMARY KEY,
account_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
When a Deposit event occurs for account_id = 'a1b2c3d4-e5f6-7890-1234-567890abcdef', we insert a row:
INSERT INTO account_events (account_id, event_type, payload)
VALUES (
'a1b2c3d4-e5f6-7890-1234-567890abcdef',
'Deposit',
'{"amount": 100.50}'
);
If a Withdrawal event happens later:
INSERT INTO account_events (account_id, event_type, payload)
VALUES (
'a1b2c3d4-e5f6-7890-1234-567890abcdef',
'Withdrawal',
'{"amount": 25.00}'
);
To get the current balance, we don’t query a balance column. We query all events for that account_id and replay them:
SELECT event_type, payload
FROM account_events
WHERE account_id = 'a1b2c3d4-e5f6-7890-1234-567890abcdef'
ORDER BY id;
We then process these events in order: start with a balance of 0, add 100.50 for Deposit, subtract 25.00 for Withdrawal. The final balance is 75.50.
This approach solves the problem of understanding how a state arrived at its current value. It provides a complete, immutable audit log of every change. The system’s state is the history of changes, not just the final outcome. This is incredibly powerful for debugging, auditing, and even for enabling complex business logic that depends on the sequence of actions.
You control the system by defining your events and how they mutate state. For instance, the Deposit event handler would look something like this (in pseudocode):
def handle_deposit(current_balance, event_payload):
new_balance = current_balance + event_payload['amount']
return new_balance
And for Withdrawal:
def handle_withdrawal(current_balance, event_payload):
# Add checks for insufficient funds here
new_balance = current_balance - event_payload['amount']
return new_balance
The "current state" is effectively a materialized view, built by replaying events. This materialized view can be stored separately for performance, but it’s always derivable from the event log.
What most people don’t realize is that event sourcing naturally supports temporal queries. You can ask, "What was the balance of account X on July 1st at 3 PM?" by simply replaying events up to the timestamp of that query. This eliminates the need for separate audit tables or complex historical data management, as the event log is the history.
The next challenge is how to efficiently query this event log for current state without replaying millions of events every time.