Skip to content
← All writing

An event-sourced ledger a regulator can replay

6 min read
.md
Cover illustration for An event-sourced ledger a regulator can replay

TL;DR

Store money movements as immutable events and derive balances by replay, so the audit trail is the system of record rather than a log beside it. Pair that with double-entry postings that sum to zero and arbitrary-precision arithmetic, and every balance becomes explainable down to the cent.

An investor emailed to say a payout looked short. He wanted to know what his balance had been on 14 March, and how it got there.

I could tell him what it was that morning. The number was right there in a column. What I could not do was explain it, because every update had overwritten the one before it.

That email is why the lending platform I built stores events instead of balances. Auditors ask the same question. So does the regulator. And a balance column answers none of them.

Stop storing the balance

Every movement of money gets written down and never touched again. A top-up. A withdrawal. An investment. A fee. The balance is not stored anywhere; it is what you get when you add all of those up.

That sounds like extra work until you want the 14 March number. Then it is the only thing that works: you add up the events that had happened by 14 March and stop.

Martin Fowler’s write-up of event sourcing puts the principle plainly. The log is the truth. Everything else is a view you can rebuild.

Question an auditor asksStored balance columnEvent log
What is the balance nowRead one columnFold the events, or read a projection
What was it on 14 MarchUnanswerableFold the events up to 14 March and stop
How did it get thereUnanswerableThe events are the answer
Was this corrected, and whenUnanswerable, the row was overwrittenA reversal entry with its own date and reference
Can you prove none of it was editedNoThe log is append only
public function balanceAt(string $accountId, CarbonImmutable $asOf): string
{
    $events = $this->events
        ->forAggregate($accountId)
        ->where('occurred_at', '<=', $asOf)
        ->orderBy('sequence')
        ->cursor();

    $balance = '0';
    foreach ($events as $event) {
        $balance = bcadd($balance, $event->signedAmount(), 8);
    }

    return $balance;
}

Two details in that snippet are the whole point. The events are ordered by an explicit sequence rather than a timestamp, because two events can share a millisecond and a ledger cannot tolerate ambiguity about which came first. And the arithmetic is bcadd, not +.

Floats do not belong anywhere near money

0.1 + 0.2 is 0.30000000000000004 in every language that uses IEEE 754 doubles. On one transaction that rounding error is invisible. Fold it over a few hundred thousand events with an amortisation schedule and it becomes a reconciliation ticket.

There are two workable options and one trap.

ApproachWhere it worksThe catch
Integer minor unitsSingle currency, fixed 2 decimalsBreaks on instruments needing more precision than the currency’s own
Arbitrary precision decimalInterest, amortisation, FXEvery operation must be explicit, no operator overloading
FloatNowhere in a ledgerSilent, compounding, discovered during audit

I used arbitrary precision throughout, via PHP’s BCMath in the application and PostgreSQL’s NUMERIC in storage. NUMERIC is slower than a float column and stores exactly what you gave it, which for an amortisation schedule is the trade you want.

The interest calculation is where precision actually bites. A monthly instalment computed to two decimals and then multiplied across a 36-month schedule drifts from the same figure computed at eight decimals and rounded once at the end. Decide where rounding happens, write it down, and apply it in exactly one place.

Every posting has two sides

Event sourcing gives you history. It does not stop money appearing from nowhere. Double-entry does that.

Each transaction writes at least two postings, debits and credits, summing to zero. A disbursement debits the investor’s wallet and credits the borrower’s. A service fee debits the borrower and credits the platform’s revenue account. If the sum of all postings in a transaction is not exactly zero, the transaction does not commit.

That invariant is worth enforcing in code rather than trusting:

public function post(array $entries): void
{
    $sum = array_reduce(
        $entries,
        fn (string $carry, LedgerEntry $e) => bcadd($carry, $e->signedAmount(), 8),
        '0'
    );

    if (bccomp($sum, '0', 8) !== 0) {
        throw new UnbalancedTransaction($sum);
    }

    DB::transaction(fn () => $this->writeAll($entries));
}

The check is cheap and it has caught real bugs, usually a fee calculated on the wrong base amount. Without it those bugs surface weeks later as a balance nobody can explain.

How do you fix a mistake if events are immutable?

You do not edit the event. You post a reversing entry and then the correct one, which is what accountants have done since long before we had databases.

This is the part engineers push back on hardest, because editing a row is right there and it would take one query. The reason to refuse is that an edited event destroys the only evidence that the mistake happened. When the regulator asks why the balance moved, “we corrected an error on 3 April with reversal TXN-8841” is an answer. A silently amended row is not.

Practically this means your event types need a reversal shape from day one. Retrofitting one is painful because existing projections do not expect negative-signed events of that type.

What event sourcing costs

I would not reach for this on a system that does not need it, and most systems do not.

Reads are the obvious cost: folding thousands of events for a balance you display on every page load is not viable, so you build read models, and now you have a projection to keep in sync and rebuild when it drifts. Schema evolution is worse. An event written two years ago must still deserialise today, which means versioned serialisers and a rule against ever changing an existing event’s shape.

CostWhy it appearsWhat it forces
Read performanceFolding thousands of events per page load is not viableRead models, which then need syncing and rebuilding when they drift
Schema evolutionAn event written two years ago must still deserialise todayVersioned serialisers, and a rule against ever changing an existing event’s shape
CorrectionsYou cannot edit an eventA reversal shape designed in from day one, because retrofitting breaks existing projections
Developer pushbackEditing a row is right there and takes one queryDiscipline, and a clear reason it is refused

The rule I use: if the history is a regulatory requirement or the product itself, event sourcing pays for itself. If you want an audit log, write an audit log.

Sources

Common questions

Why store events instead of a balance column?

Because a balance column can tell you what the number is and never how it got there. Every update overwrites the one before it. Storing each movement of money as an immutable event means a historical balance is just the events up to that date, added up and stopped.

Why must ledger events be ordered by sequence rather than timestamp?

Two events can share a millisecond, and a ledger cannot tolerate ambiguity about which came first. An explicit monotonic sequence removes that ambiguity; a timestamp does not.

Can you use floating point for money in a ledger?

No. 0.1 plus 0.2 is 0.30000000000000004 in any language using IEEE 754 doubles. On one transaction the error is invisible; folded over a few hundred thousand events with an amortisation schedule it becomes a reconciliation ticket. Use integer minor units or arbitrary-precision decimals.

What does double-entry add on top of event sourcing?

Event sourcing gives you history. It does not stop money appearing from nowhere. Double-entry does, by requiring every transaction to write at least two postings that sum to exactly zero, refusing the commit otherwise. That check is cheap and catches real bugs, usually a fee calculated on the wrong base amount.

How do you correct a mistake when events are immutable?

Post a reversing entry and then the correct one, which is what accountants did long before databases. Editing the event destroys the only evidence the mistake happened. A correction on 3 April with a reversal reference is an answer for a regulator; a silently amended row is not.

When is event sourcing not worth it?

Most of the time. It costs you read performance, so you build projections that need keeping in sync, and it costs you schema evolution, because an event written two years ago must still deserialise today. If the history is a regulatory requirement or the product itself, it pays for itself. If you just want an audit log, write an audit log.

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 17 min read

    The product was the easy part

    What a SaaS needs before it can charge anyone: credit billing in Stripe, invoicing and sales tax, email unsubscribe law, terms and privacy, and an admin panel.

  • 8 min read

    The job was never the code

    Most of my code is now written by an agent, and my output went up rather than down. That is not a story about typing speed. It is about what the job always was underneath the typing.

  • 5 min read

    Metering tokens when the bill is the product

    Billing per message is easy and wrong: one user sends a sentence, another uploads a report. Metering the tokens you actually spend is harder, and the hard parts are idempotency, allowance checks and what to do mid-conversation.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.