DEV Community

Aturo Phil
Aturo Phil

Posted on

Event Sourcing for Financial Systems: Why Mutating State Is the Wrong Abstraction

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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']
                )
            )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)