Skip to content
← All writing

An event-sourced ledger a regulator can replay

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

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.

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.

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

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

AboutRSS
  • 6 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.

  • 4 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.

  • 4 min read

    Route cheap models to orchestration, not to the work

    Most agent platforms pick one model and use it everywhere. Splitting model selection by role rather than by task is where the cost curve actually bends.

Get in touch

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

Your details are used only to reply to this message.