noah jobse

Caching a ledger without ever serving a wrong number

#fintech #ledger

Financial dashboards are slow for a simple reason: almost every number on them is an aggregation over the whole ledger. Profit for the month, cash on hand, what's overdue, each one is computed by scanning journal entries and adding them up. Do that on every page load and the dashboard crawls.

The obvious fix is a cache. The obvious fear is a cache that serves a wrong number, and in finance a stale figure is a correctness bug you can't ship, the kind that quietly costs someone real money. I worked on a financial system where I had to make these views fast without ever letting them lie, and the useful part of the solution came from reading the domain instead of reaching for a fancier cache.

Why the usual caches are wrong here

Two reflexes, both bad for money:

A TTL cache (hold each figure for, say, ten minutes) is trivial to add and serves a stale number for the whole window. It also recomputes everything when the window lapses, whether anything changed or not. For a dashboard someone makes decisions on, "correct within ten minutes" is just "wrong, sometimes."

Flush everything on any write is always fresh and wildly wasteful: a single entry posted today blows away last March's cached numbers, which did not move, and the next person to look at March pays to recompute it from scratch.

Both treat the ledger as if any number could change at any time. It can't, and that's the whole opening.

The domain already solved invalidation

A double-entry ledger closes periods. Once a month is closed, it's immutable: the system refuses to post into it. Closing March is a deliberate act, and after it, March's figures can never change unless someone explicitly reopens the month, which is rare and loud.

Sit with what that means for a cache. Last month cannot change. Neither can any month before it. The only part of the ledger that can still move is the current open period, and every entry that touches it goes through one write path (every post funnels through a single function).

So the strategy writes itself:

  • Cache every closed period permanently. Flush a closed month only on the rare reopen.
  • Invalidate only the open period on a write. A new entry can affect this month and nothing else.

History is cached forever at near-zero cost. The only thing that ever recomputes is the live month. The framework gives you a cache; the domain gives you the invalidation rule for free.

Closed months June through September are cached once; October is the live open month, and a new entry invalidates only October.Closed periods are immutable, so each one is cached once. Only the open month recomputes, and only on a write.

The subtlety that bites you: as-of figures

Period figures (profit for a month) are bounded and immutable once the month closes, so they cache cleanly. But some figures are "as of now": the balance sheet, cash on hand. Those are cumulative from the beginning of time. Computing one live means scanning the entire ledger from inception on every load. Fine on a young book, slow on one with years of history behind it.

The fix keeps the same idea one layer deeper. Snapshot each closed month's closing balances as well, since those are period-bounded and immutable too. Then an as-of figure is the latest cached closing balance plus the small, live, open-month delta. Always fresh, because the part that can change is computed live. Always fast, because the history comes from cache and nothing scans the whole ledger.

Balance as of now equals the latest cached closing balance plus the live open-month delta.An as-of figure is the latest cached close plus the small live delta: fast from cache, fresh on the open part.

Put the cache somewhere a deploy can't wipe it

There's a quieter trap in where the cache lives. The path of least resistance is the framework's built-in cache store. The problem is that store is shared, and routine operations clear it: plenty of deploys run a cache clear as a step. The moment that happens, your "permanent" closed-month history evaporates and every dashboard recomputes from scratch. Still correct, but slow, and the design you built quietly stopped paying off.

A correctness-critical, long-lived cache belongs in its own isolated place, a dedicated table nothing else touches. It survives deploys and unrelated flushes, and you can select from it and look at exactly what's cached, when, and whether a row looks wrong. For a financial cache, being able to inspect it is worth the extra table.

Invalidate at the one write path, after commit

Because every entry goes through a single write path, you hook invalidation there, as an after-commit observer on the journal entry. Two properties matter:

  • It's a catch-all. It fires no matter which code wrote the entry, so a future importer or background job can't forget to invalidate. (One honest caveat: a raw bulk insert skips model events, so any bulk path has to invalidate explicitly. Worth a comment in the code so the next person knows.)
  • After commit, specifically. If a transaction rolls back, you never want a half-written state touching the cache. Let the database commit first, then invalidate.

Period close materializes that month's snapshot. Reopen flushes it, the one event that's allowed to touch a "permanent" row.

You can't trust the cache until a test proves it can't lie

Everything above is a story about why the cache should be correct. A cache you can't prove will eventually lie, so the story isn't enough. The gate I'd put on any cache like this is a cached-vs-live equivalence test: for every scenario, compute the report both from cache and fresh from the ledger, and assert they're identical. If they ever diverge, the build fails. That single test is what earns you the right to trust the fast path in production.

Around it sit the invalidation tests: post an entry and the open month updates; close a month and its figure freezes and serves from cache; reopen and it flushes and recomputes; roll a transaction back and the cache is untouched.

The lesson

The leverage here came from noticing what the domain already makes immutable. Closed accounting periods can't change, so the expensive history never changes, so you cache it once and move on. Before reaching for TTLs and event buses, it's worth asking the same question of whatever you're building: what in this domain is already immutable? That's usually your free cache key, and the correctness argument comes with it.