A receivables ledger that was seeded once and then quietly stopped being true
There were two ledger tables in the system, one for money owed to the business and one for money it owed out. Both had rows. The receivables table had forty-five, seeded from the unpaid invoices; the payables table had thirty-nine, seeded from active rents due to site owners. Both screens read them and both screens looked right.
Nothing kept either of them in step with anything. There was no pipeline. Record a payment against an invoice and the invoice moves to part-paid or paid, and the receivable sits there unchanged, still claiming the full amount. The ledger was a photograph of one afternoon.
The failure is not that it was wrong, it is that it could not be checked
An error in a stored aggregate does not announce itself. There is no exception, no failed job, no divergence anybody can see, because the only thing that could contradict the ledger is the thing the ledger was supposed to summarise, and nobody re-derives it once it exists. The screen shows a total, the total is plausible, and it drifts a little further from the truth with every payment.
You find out when somebody chases a client for money that has already been paid. That is a customer conversation, not a bug report.
Derived, not stored
The rebuild removed both tables. Outstanding receivables became a computed view over the invoices — outbound invoices, minus payments recorded against them, aged by due date. Payables became the same computation over inbound invoices. No table, no sync, no drift, and the definition of “outstanding” exists in exactly one place instead of two.
The rule I would state from this: do not persist a number you can compute from rows you already have, unless you can also name the job that keeps it correct and say what happens when that job fails. If the answer is “we will add the sync later”, you have not built a ledger — you have built a cached figure with no invalidation and no expiry.
There is a legitimate opposite case and it should be acknowledged. A stored aggregate is right when the underlying rows are too many to sum on demand, or when the figure must be frozen as of a date for accounting reasons. Both of those are real. Neither applied here: the volumes are small, and the figure people wanted was current, not historical. The stored table bought nothing and cost correctness.
The one that lands the point
The clearest demonstration came from the client-facing list rather than the ledger screen. A column was added showing each client’s outstanding balance, computed live as a correlated subquery against their open invoices, using exactly the same definition as the receivables screen.
Because it was derived, it was sortable and filterable across the whole dataset rather than the current page, and it was correct the moment a payment was recorded. The stored version could not have done any of that without a second sync path — and a second sync path is a second thing to be wrong.
That is a practical argument that usually wins where the correctness argument does not. A derived figure is a query, so it composes with sorting, filtering and pagination. A stored figure is a column on a row, and every question you ask of it that its writer did not anticipate needs new writing.
What I misjudged
I treated the seeded ledgers as work already done and planned around them. They had rows, the screens consumed them, and my mental model was “the ledger exists, the sync is missing”. So the task on the list was “build the sync pipeline”, and it sat there for weeks looking like a moderate piece of work.
It was the wrong task. The right task was to delete both tables, which is smaller, and the reason I did not see that immediately is that deleting something that looks finished feels like going backwards. It is not. A stored aggregate with no sync is not a partly-built feature; it is a liability wearing the costume of one. The half-finished version is more dangerous than nothing, because nothing shows an empty screen and this showed a confident wrong number.
Where it bit again
The same instinct produced a related defect in a demo dataset later. A monthly settlement figure was keyed by period, and the seed wrote quarterly keys where the reading code expected months. Nothing matched, so the dashboard reported that settlements had never been run, and a partnership profit figure came out as zero.
Same family. A value that is computed by one piece of code and consumed by another, with the agreement between them written down nowhere. Deriving on read would not have prevented that one — but reading the consuming code against the data before believing the screen would have, and that is the habit that found it.
When a screen shows an amount, be able to say which rows it came from and when it was last computed. If the honest answer is “some rows, once, a while ago”, the screen is decoration.