Every payment system you have ever debugged has the same shape: a database row with a balance column, a function that reads it, modifies it, and writes it back, and a support ticket from a user whose balance is wrong.
The balance is wrong because the balance column is a lie. It is a compressed summary of every transaction that ever happened, and compression loses information. When something goes wrong, you cannot reconstruct what happened from the current state alone. You check logs, you scroll through audit tables, you ask the application developer what the code used to do three deploys ago.
Event sourcing is the claim that this architecture is backward. The events are the data. The balance is a derived view.
The Core Idea
An event-sourced system records a sequence of facts about what happened. It never updates or deletes records. State is computed by replaying the event sequence from the beginning, or from the last known snapshot.
The difference is not subtle. Consider a payment account:
Mutable state approach:
-- What you store
accounts (id, balance, updated_at)
-- What you lose
-- Why is the balance 47,320 sat?
-- Who changed it?
-- Was there a failed payment that was retried?
-- Did a refund arrive during a network partition?
Event-sourced approach:
-- What you store
account_events (id, account_id, event_type, amount_sat, occurred_at, metadata)
-- What you can answer
-- Complete history of every credit and debit
-- Exact sequence of operations
-- State at any point in time
-- What the balance was before a specific transaction
The balance is not stored. It is computed:
from dataclasses import dataclass, field
from typing import List
from decimal import Decimal
@dataclass
class AccountEvent:
event_type: str # 'deposit', 'withdrawal', 'fee', 'refund'
amount_sat: int
occurred_at: str
metadata: dict = field(default_factory=dict)
def compute_balance(events: List[AccountEvent]) -> int:
balance = 0
for event in events:
if event.event_type in ('deposit', 'refund'):
balance += event.amount_sat
elif event.event_type in ('withdrawal', 'fee'):
balance -= event.amount_sat
return balance
This function is called a projection. It takes a stream of events and produces a current state. You can have multiple projections over the same event stream — one for balance, one for transaction history, one for fee totals — each computed independently.
Why Financial Systems Are Naturally Event-Sourced
A financial ledger is already a sequence of events. This is not a new idea, double-entry bookkeeping has been append-only since the 15th century. Every credit creates a corresponding debit. The ledger is never corrected; if a mistake was made, a correcting entry is added.
Bitcoin makes this explicit at the protocol level. The UTXO set is the current state. The blockchain is the event log. The UTXO set is derived from the blockchain, you cannot have one without the other, and the blockchain is authoritative. If the UTXO set is ever corrupt or uncertain, you recompute it from the blockchain. The events are the source of truth.
A payment system built on this observation has several properties that are difficult to achieve with mutable state:
Auditability without extra work. Regulators and auditors want to know every transaction. In a mutable state system, this requires a separate audit log that must be kept in sync with the main database — two things that can diverge. In an event-sourced system, the audit log is the database.
Temporal queries without effort. "What was the account balance on the 15th?" is a simple operation: replay events up to the 15th and compute the projection. In a mutable state system, this requires either storing snapshots at known points in time or reconstructing state from backup data.
Debugging by replay. When a bug is reported, you replay the event stream through the new code and observe what changes. You can reproduce any past state exactly. In a mutable state system, you can only observe the current state.
Optimistic concurrency without distributed locks. An event store can enforce ordering by requiring events to be appended at a specific expected version. If two writers attempt to append at version 47 simultaneously, one fails. There is no shared mutable state to lock.
Aggregates and the Write Side
In practice, you do not replay every event in the store for every operation. You organize events into streams, one per aggregate, a consistency boundary. An aggregate is the smallest unit of state that must be consistent.
For a payment system, an aggregate might be a channel, a payment, or a wallet. Each has its own event stream. When you process a command, "open a channel", "initiate a payment", you load the aggregate's event stream, replay it to get current state, apply business rules, and if valid, append new events.
from typing import Optional
from dataclasses import dataclass
@dataclass
class ChannelState:
channel_id: str
status: str # 'pending_open', 'open', 'pending_close', 'closed'
capacity_sat: int
local_balance_sat: int
remote_balance_sat: int
pending_htlcs: list
def apply_event(state: Optional[ChannelState], event: dict) -> ChannelState:
"""Pure function: takes current state and an event, returns new state."""
event_type = event['event_type']
if event_type == 'ChannelOpened':
return ChannelState(
channel_id=event['channel_id'],
status='open',
capacity_sat=event['capacity_sat'],
local_balance_sat=event['local_balance_sat'],
remote_balance_sat=event['remote_balance_sat'],
pending_htlcs=[]
)
if event_type == 'HTLCAdded':
# Return new state without mutating the input
new_htlcs = state.pending_htlcs + [event['htlc']]
return ChannelState(
**{k: v for k, v in state.__dict__.items() if k != 'pending_htlcs'},
pending_htlcs=new_htlcs
)
if event_type == 'HTLCSettled':
htlc = next(h for h in state.pending_htlcs if h['id'] == event['htlc_id'])
new_htlcs = [h for h in state.pending_htlcs if h['id'] != event['htlc_id']]
new_local = state.local_balance_sat - htlc['amount_sat']
new_remote = state.remote_balance_sat + htlc['amount_sat']
return ChannelState(
channel_id=state.channel_id,
status=state.status,
capacity_sat=state.capacity_sat,
local_balance_sat=new_local,
remote_balance_sat=new_remote,
pending_htlcs=new_htlcs
)
if event_type == 'ChannelClosed':
return ChannelState(
**{k: v for k, v in state.__dict__.items() if k != 'status'},
status='closed'
)
return state
def load_channel(events: list) -> Optional[ChannelState]:
"""Replay all events to get current channel state."""
state = None
for event in events:
state = apply_event(state, event)
return state
The apply_event function is pure — it takes state and an event, returns new state, has no side effects. This makes it trivially testable and replayable.
The command handler applies business rules before appending events:
def handle_add_htlc(channel_id: str, htlc: dict, event_store) -> None:
# Load current state
events = event_store.load_stream(f"channel-{channel_id}")
channel = load_channel(events)
# Validate
if channel.status != 'open':
raise ValueError(f"Cannot add HTLC to channel in status {channel.status}")
if htlc['amount_sat'] > channel.local_balance_sat:
raise ValueError("Insufficient local balance")
# Emit event — this is the only write
new_event = {
'event_type': 'HTLCAdded',
'channel_id': channel_id,
'htlc': htlc,
'version': len(events) + 1 # optimistic concurrency
}
event_store.append(f"channel-{channel_id}", new_event, expected_version=len(events))
Snapshots
Replaying the full event stream on every operation does not scale once a stream contains thousands of events. The solution is snapshots: periodically compute and store the current state, and on load, start from the most recent snapshot rather than the beginning.
@dataclass
class Snapshot:
stream_id: str
version: int # event version this snapshot was taken at
state: ChannelState
taken_at: str
def load_channel_with_snapshot(
stream_id: str,
event_store,
snapshot_store
) -> ChannelState:
# Try to load most recent snapshot
snapshot = snapshot_store.latest(stream_id)
if snapshot:
# Load only events after the snapshot
events = event_store.load_stream(stream_id, after_version=snapshot.version)
state = snapshot.state
else:
# No snapshot — full replay
events = event_store.load_stream(stream_id)
state = None
for event in events:
state = apply_event(state, event)
return state
Snapshots are a performance optimization, not a replacement for the event log. The event log remains the source of truth. If a snapshot is corrupt, you discard it and rebuild from events.
The Read Side: Projections and Read Models
The write side appends events. The read side subscribes to the event stream and maintains query-optimized read models.
This split — often called CQRS, Command Query Responsibility Segregation — means the data structure you write is different from the data structure you read. The write side is optimized for consistency and correctness. The read side is optimized for query performance.
# A read model maintained by a projection
class PaymentHistoryProjection:
"""
Maintains a query-optimized view of payment history.
Updated by processing events from all channel streams.
"""
def __init__(self, database):
self.db = database
def handle(self, event: dict) -> None:
event_type = event['event_type']
if event_type == 'HTLCSettled':
self.db.execute(
"""
INSERT INTO payment_history
(payment_hash, channel_id, amount_sat, direction, settled_at)
VALUES (?, ?, ?, ?, ?)
""",
(
event['htlc']['payment_hash'],
event['channel_id'],
event['htlc']['amount_sat'],
'outgoing',
event['occurred_at']
)
)
if event_type == 'HTLCReceived':
self.db.execute(
"""
INSERT INTO payment_history
(payment_hash, channel_id, amount_sat, direction, settled_at)
VALUES (?, ?, ?, ?, ?)
""",
(
event['htlc']['payment_hash'],
event['channel_id'],
event['htlc']['amount_sat'],
'incoming',
event['occurred_at']
)
)
Read models are disposable. If you change a projection's logic, you rebuild the read model from scratch by replaying the event stream. The events are durable; the read model is a cache.
Event Versioning: The Hard Problem
Event sourcing has one genuinely difficult problem: event schema evolution. Once you have appended an event to the store, it is immutable. If you later decide the event schema was wrong, you cannot change the stored events.
Greg Young's treatment of this problem identifies several strategies:
Weak schema: Store events as JSON with minimal structure. Add fields freely; never remove or rename. Old event handlers ignore fields they do not understand. New handlers provide defaults for missing fields.
def apply_htlc_added(state: ChannelState, event: dict) -> ChannelState:
htlc = event['htlc']
# New field added in v2 — provide default if missing
routing_fee = htlc.get('routing_fee_sat', 0)
# ... rest of handler
Upcasting: When loading events, transform old versions to the current schema before processing. The event store stores the original; the upcast is applied in memory at load time.
def upcast_htlc_added(event: dict) -> dict:
"""Transform v1 HTLCAdded events to v2 schema."""
if event.get('schema_version', 1) == 1:
event = {**event, 'htlc': {**event['htlc'], 'routing_fee_sat': 0}}
event['schema_version'] = 2
return event
Copy-and-transform: Migrate the entire event store to a new stream with transformed events. Expensive but produces a clean schema. Requires careful coordination in production.
The decision of which strategy to use depends on how frequently your schema changes and how far back your replay needs to go. For systems expected to run for years, weak schema or upcasting is usually the right choice. Copy-and-transform is reserved for major structural changes.
What This Changes About How You Debug
The practical benefit that most developers notice first is not auditability or temporal queries. It is that production bugs become reproducible.
In a mutable state system, a production bug leaves behind only the final state. You have to reconstruct what happened from logs, from user reports, from intuition. Often you cannot reproduce it.
In an event-sourced system, a production bug leaves behind the complete event stream that caused it. You copy that stream into a test environment, replay it against the fixed code, and verify the fix. The bug is reproducible by definition.
This is not a secondary concern. The debugging loop, reproduce, fix, verify, is the core of production engineering. An architecture that makes reproduction possible changes the economics of debugging.
Further Reading
- Martin Fowler, "Event Sourcing" (2005) — https://martinfowler.com/eaaDev/EventSourcing.html
- Greg Young, "CQRS Documents" (2010) — https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf
- Greg Young, GOTO 2014 talk, "Event Sourcing" — https://www.youtube.com/watch?v=8JKjvY4etTY
- Greg Young, "Versioning in an Event Sourced System" (2016) — https://leanpub.com/esversioning
- Jay Kreps, "The Log: What every software engineer should know about real-time data's unifying abstraction" (2013) — https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying
- Martin Kleppmann, "Designing Data-Intensive Applications" (2017), Chapter 11
Top comments (0)