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.
| Approach | Where it works | The catch |
|---|---|---|
| Integer minor units | Single currency, fixed 2 decimals | Breaks on instruments needing more precision than the currency’s own |
| Arbitrary precision decimal | Interest, amortisation, FX | Every operation must be explicit, no operator overloading |
| Float | Nowhere in a ledger | Silent, 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
- Event Sourcing, Martin Fowler
- PostgreSQL numeric types, PostgreSQL documentation
- BCMath arbitrary precision mathematics, PHP manual
Elson Tan