Skip to content

From event-driven balances to a double-entry ledger

How SpendCentric moved account balances into a double-entry ledger, committed money effects with their source rows, and retired its event-driven balance pipeline.

14 min read

SpendCentric’s account balances are now backed by a double-entry ledger. Each new money movement is recorded in the same SQL Server transaction as its source row. The journal entry, its postings and the stored ledger balance are committed together. Balance reads no longer depend on an event handler catching up after the transaction has been saved.

The platform is built on .NET with EF Core. Its existing transaction API and client were retained through the cutover. History was backfilled, balances were switched to the ledger, and production report reads followed. The handlers that maintained the old balance were then removed.

The outcome is a balance that can be explained. Every balance is the sum of postings tied to the transaction, or transfer group, and the version that produced them. Every correction to an amount, account, date or deletion stays in the history as a restatement or reversal. A crash after the commit can no longer lose a balance change, and a replay of the same source version can no longer apply one twice. The design below covers the transaction boundary, the write guards and the staged cutover.

Balances carried on events

Creating a transaction was one application service call:

  1. Open a database transaction and insert the transaction row.
  2. Commit.
  3. Dispatch a created event in process, carrying the account and the transaction’s balance impact.
  4. In a handler, load the account, add the impact to its stored balance and save, in a unit of work of its own.

An update dispatched the difference between the new and the old impact, and a delete dispatched the negated impact. The dispatcher ran handlers inline, after the commit, with no transactional outbox. A concurrency conflict on the account row was retried, and a balance update that still failed was logged as a warning.

Row commits row is durable Event dispatched after the commit Handler adds the delta Balance was 100.00 Delivered once Process stops after commit Handler runs twice process stops runs again 60.00 correct 100.00 delta lost 20.00 delta applied twice Expense of 40.00. Nothing records that a delta was owed or already applied.
Three illustrative runs of the same expense of 40.00 against a balance of 100.00. The first delivers its delta once. In the second the process stops after the commit, so the delta never arrives and the balance stays at 100.00. In the third the handler runs twice and the balance drops to 20.00.

That design left three failure modes.

A crash after the commit could lose the delta. The row was durable before the event was durably recorded. A process that stopped between the commit and the handler’s save, or a handler that failed after the commit, could leave a committed transaction whose effect never reached the balance. Nothing recorded that a delta was still owed.

A repeated delivery applied the delta twice. Adding an impact is not idempotent. Any path that ran the handler again after its update had committed moved the balance a second time, and the handler could not tell a repeat from a new event.

A discrepancy had no posting trail. The stored number reflected the deltas that had arrived. Recomputing it from transaction history could restore agreement with those rows, but could not identify which delta had gone missing or which had landed twice.

The journal entry commits with the row

Double-entry bookkeeping records every movement as a journal entry of two or more postings, each a signed amount on one account, and requires the postings of an entry to sum to zero. An account’s balance is the sum of its postings. Every amount that leaves one account arrives at another, and every balance can be explained by the postings behind it.

In SpendCentric, each bank account has a cash account in the ledger, and a small set of system accounts stands on the other side: income and expenses per currency, opening balances, adjustments, receivables for money owed by contacts, and a conversion account per currency. Amounts are signed with debits positive. An expense of 40.00 becomes one entry with two postings, −40.00 on the cash account and +40.00 on expenses. The entry is written by the same service call that inserts the transaction row, on the same connection and inside the same transaction, so the row and its money effect commit together or not at all.

One database transaction Transaction row expense, 40.00 Journal entry source = this row, version 0 Cash account −40.00 Expenses +40.00 Sum per currency 0.00 Stored balances cash, in place −40.00 expenses, in place +40.00 not yet visible Commit all four or none Debits are positive. Cash, an asset, decreases; expenses increase by the same amount.
One database transaction holds the transaction row, the journal entry and its two postings, and the balance increments. The postings sum to zero, and nothing becomes visible until the single commit at the end.
on create(transaction):
    begin
        insert transaction row
        entry = compose(row)                  # cash leg + counter leg, sums to zero
        insert entry keyed (source, row id, version 0)
        add each posting to its account's stored balance
    commit
    dispatch events after the money effect is committed

The ledger raises no events of its own. Events still support work such as cache invalidation. Some handlers also create transaction rows, including account initialization; those writes go through the transaction service and receive their own atomic ledger entries. The balance effect of an existing transaction is no longer applied as an event-carried delta.

Four guards on every write

Four mechanisms protect the write path: a shared composer, validation of postings, a unique source key, and atomic balance updates with locks where needed. The diagram shows how a new entry and a replay pass through the writer; source locks are acquired before version derivation.

One composer always enabled Zero sum per currency Source key type, ID, version Balance add in place Commit First write Same key, matching hash Same key, different hash committed hashes match, no change hashes differ, rolled back An entry that does not sum to zero cannot be constructed, so it never reaches the source key
A first write passes all four guards and commits. A replay of the same source version reaches the source key, matches the stored posting hash, and makes no additional balance change. Different hashed content at that key causes the transaction to roll back.

1. One write path. Every live producer writes through a single composer. The transaction service and the demo data seeder are its main callers. New transfers, settlements, shared expenses and recurring transactions reach it through transaction creation. Shared expenses and settlements created before the ledger were migrated to transaction rows as well, so every journal entry belongs to a transaction or a transfer group. During the rollout the composer sat behind a dual-write flag and returned before doing anything while the flag was off, so the ledger shipped inert and was switched on by configuration. The composer is now unconditional, and every new money path has to go through it. The historical backfill is the one exception: it writes through a separate repository with its own checks.

2. Balanced per currency before the entry is saved. A journal entry cannot be constructed unless it has at least two postings, none of them zero, the postings sum to zero in each currency, and each posting’s currency matches its account’s. A transfer between accounts in different currencies routes each side through that currency’s conversion account, so every currency balances on its own. Rows with no money effect produce no entry.

private static void ValidatePostings(IReadOnlyCollection<PostingDraft> postings)
{
    Guard.Against(postings.Count < 2,
        "A journal entry requires at least two postings.");
    Guard.Against(postings.Any(p => p.Amount == 0),
        "Posting amounts must be non-zero.");

    foreach (var currencyGroup in postings.GroupBy(p => p.CurrencyCode))
        Guard.Against(currencyGroup.Sum(p => p.Amount) != 0m,
            $"Postings in {currencyGroup.Key} must sum to zero.");

    Guard.Against(
        postings.Any(p => p.CurrencyCode != p.LedgerAccountCurrencyCode),
        "Posting currency must match the ledger account currency.");
}

JournalEntry.Create calls this before it builds a single posting, so an unbalanced entry never reaches the change tracker.

3. A unique source key. Each entry carries its source type, source ID and version, and a unique index allows one entry per combination. A replayed write hits the index. The writer then loads the existing entry and compares a hash of its postings with a hash of the new ones. Matching hashes are treated as a replay, and the writer returns. Different hashes for the same key cause an exception and roll back the surrounding transaction. If concurrent edits derive the same next version, only one entry can be inserted for that key.

public async Task WriteAsync(
    JournalEntry entry, List<PostingDraft> drafts, CancellationToken ct = default)
{
    try
    {
        await ledgerRepository.AddAsync(entry, ct);
    }
    catch (JournalEntryAlreadyExistsException)
    {
        JournalEntry? existing = await ledgerRepository.GetBySourceAsync(
            entry.SourceType, entry.SourceId, entry.SourceVersion, ct);

        if (existing is not null &&
            PostingContentHasher.Hash(existing) ==
            PostingContentHasher.Hash(entry.Kind, drafts))
            return;

        throw new InvalidOperationException(
            $"Concurrent divergent ledger write detected for " +
            $"{entry.SourceType}/{entry.SourceId} v{entry.SourceVersion}; " +
            "the operation must be retried from fresh state.");
    }

    await ApplyPostingEffectsAsync(drafts, ct);
}

The hash is a SHA-256 over the entry kind and sorted posting representations: account, amount, currency, posting date, category and subcategory. A replay returns before ApplyPostingEffectsAsync, so the same source version does not move a balance a second time. A newly created transaction with a new source ID is a different write.

The replay path runs inside the open transaction. EF Core wraps SaveChanges in a savepoint when a transaction is already open, so the failed insert does not end the outer transaction; it skips that savepoint when Multiple Active Result Sets is enabled, so the setting stays off. The repository also detaches the failed entities, because the change tracker would otherwise try to insert them again on the next SaveChanges.

4. An in-place balance increment over sorted accounts, and source locks. The writer folds an entry’s postings into one delta per ledger account, sorts the accounts by ID and sends one batch: an UPDATE that adds each delta to the stored balance, and a DELETE of the checkpoints the entry invalidates. Concurrent writers therefore never overwrite one another’s increments. The sorted list and the query hints make writers request locks on shared accounts in a consistent order, which is intended to reduce deadlocks caused by inconsistent account-lock ordering, such as between two opposite transfers. The hints do not guarantee the lock order; a deadlock that still occurs rolls back the victim’s transaction, and that write fails.

UPDATE a SET balance = a.balance + v.delta
FROM (VALUES (@account1, @delta1), (@account2, @delta2)) AS v(id, delta)
INNER JOIN accounts AS a ON a.id = v.id
OPTION (FORCE ORDER, LOOP JOIN);

DELETE c
FROM (VALUES (@account1, @from1), (@account2, @from2)) AS v(id, from_period)
INNER JOIN checkpoints AS c
    ON c.account_id = v.id AND c.period_end >= v.from_period
OPTION (FORCE ORDER, LOOP JOIN);

Sources without a version column of their own, such as a group of transfer legs, take an exclusive application lock (sp_getapplock) named after the source and owned by the transaction before they derive their next version. The lock waits up to five seconds, and a negative result fails the write. This guard prevents lost balance updates and two writers choosing the same version for a group.

Corrections preserve the money history

Money effects are corrected by appending entries. Reporting dimensions can be updated in place. When a transaction is edited, the new row is compared with a snapshot taken before the change:

ChangeLedger effectVersion
Amount, account, currency, date, type or split linesOne restatement entry: the negated old postings, on their original dates, plus the new postingsNext version
DeleteOne reversal entry: the negated current postingsNext version
Category or subcategory on an unsplit transaction, budget, or reportability onlyPosting dimension columns updated in place, no entryUnchanged

The negated postings keep their original dates so that a change of date moves the amount out of the old month and into the new one, and historical balances reflect the corrected effective dates. These are balances as of a posting date, rather than a snapshot of what the system knew at the time. The new version is written back to the transaction row in the same transaction, which gives reconciliation a cheap invariant: the newest entry for a row must carry the row’s version.

For an existing single-transaction source with changed money effects, the core of the restatement is short. The excerpt omits transfer handling, first-entry creation and empty-draft checks:

List<PostingDraft> drafts =
    [.. TransactionPostingMapper.Negate(oldDrafts), .. newDrafts];

int version = maxVersion.Value + 1;
assignments.Add(new LedgerVersionAssignment(row.Id, version));
await writer.WriteAsync(
    BuildRowEntry(row, version, JournalEntryKind.Restatement, drafts), drafts, ct);

Negate flips the sign of each amount and keeps everything else, including the posting date. The delete path maps the deleted row, negates its postings and writes them at the next version as a Reversal.

Version Entry Cash Expenses Cash balance from 100.00 0 Original expense created, 40.00 −40.00 +40.00 1 Restatement amount changed to 55.00 +40.00−55.00 −40.00+55.00 2 Reversal transaction deleted +55.00 −55.00 60.00 45.00 100.00 60.00 45.00 Each entry sums to zero. The transaction row carries the latest version, 2; earlier entries are never changed.
One expense through its life. Version 0 posts 40.00. Version 1 restates it to 55.00 by reversing the original postings and posting the new amount. Version 2 reverses 55.00 when the transaction is deleted. The cash balance returns to 100.00, and every step stays in the history.

Categories are dimensions, not accounts

A category is a column on a posting, not an account in the ledger. The set of accounts stays at one cash account per bank account plus a handful of system accounts per currency, creating a category needs no ledger change, and reclassifying an unsplit transaction uses the dimension update above. Changes to split lines produce a restatement so their separate amounts and categories remain represented. Reports aggregate cash-account postings before joining descriptive data and resolving exchange rates by currency and reporting period.

Balances at any date

The current balance is the stored value that guard 4 maintains, so reading it is one row. The overdraft check reads it with an update lock, so two concurrent spends from one account are checked one after the other.

A balance at an earlier date starts from a monthly checkpoint and adds the postings after it:

balance_at(account, date):
    checkpoint = latest checkpoint of account with period_end <= date
    if checkpoint is none:
        return sum(amount of postings of account where posting_date <= date)
    return checkpoint.balance
         + sum(amount of postings of account
               where checkpoint.period_end < posting_date <= date)

An index on account and posting date that includes the amount covers the tail sum. Every write deletes the checkpoints of each account it touches that end on or after its earliest posting date, in the same batch as the balance increment. The rebuild job creates checkpoints for completed months. A current-month write normally has no checkpoint to remove; a backdated write can invalidate several. Missing checkpoints are rebuilt nightly. If no checkpoint is available, the read falls back to summing the full posting history.

Editing the past

No month is closed, so a transaction from any earlier month can be edited or deleted. Changing a March expense from 40.00 to 55.00 in October writes one restatement:

EffectResult
PostingsThe original March postings negated, plus new postings for 55.00, all dated in March
EntryThe next version, with a March entry date and an October creation time
Stored cash balanceDown by 15.00, in the same commit as the edit
CheckpointsThose from March onward deleted in the same batch, and rebuilt by the nightly job
Balances and reportsBalances on and after the expense’s posting date decrease by 15.00; the March report shows the corrected 55.00 expense

A March balance read in October therefore differs from the one shown in March. The creation times of the entries record when each change was made.

The completed balance cutover

The migration was staged around a working legacy balance. Dual-write allowed the two models to be compared before reads depended on the ledger. The rollout followed this sequence:

  1. Dual-write. New money movements were written to the ledger while events continued to update the legacy balance.
  2. Backfill. An application job replayed historical sources under a job lock. Sources already represented in the ledger were skipped, and a final sweep caught rows created during the run.
  3. Drift check. The backfill compared each legacy cash balance with the sum of its postings, and was built to book any difference against opening balances and list it in a drift report. Production accounts showed no drift: every legacy balance matched its postings. Only accounts created by the demo data seeder differed.
  4. Validation. The rollout gate checked legacy-to-ledger parity, stored balances against posting sums, per-entry currency balance and unexplained drift.
  5. Read switches. Balance reads moved first. Report reads followed in a single switch after their SQL had been consolidated and checked for parity. Transaction lists, search and export continued to use transaction rows.
  6. Balance pipeline removal. The legacy balance handlers, dual-write flag and balance-read flag were removed. Ledger writes and balance reads became unconditional.
Read switches had a legacy fallback until balance pipeline removal Dual-write new writesalso post Backfill historyreplayed Drift check none inproduction Gate zero parityfailures Reads switched balances, thenreports together Handlers retired ledger balancesunconditional Nightly reconciliation continues; legacy comparison ends at removal balance reads report flags switched together
The completed rollout: dual-write, historical backfill, a drift check, validation, then balance reads followed by report reads. The legacy balance pipeline was removed. Reconciliation continues against ledger invariants.

The order mattered: switching an account to ledger reads before its history was backfilled would have returned zero if it had no ledger rows.

The unused balance column awaits removal, and the legacy report queries remain available behind report flags.

Failure handling and reconciliation

ConditionMeaningHandling
Row and entry commitThe normal caseBalance changes in the same commit
Database transaction rolls backNo money effect from that transaction is committedRow, entry and balance changes roll back together
Replay of a source version with the same posting hashThat ledger write already existsNo additional balance change
Same source version with a different posting hashConflicting content at one keyException; the enclosing transaction must roll back
Commit succeeds but the response is lostThe caller may not know the outcomeLedger replay protection applies only when the same source identity and version are reused
An invariant checked by reconciliation failsStored ledger state needs investigationCounts and samples are recorded for review

A nightly reconciliation checks invariants of the ledger itself:

  • Each account’s stored balance against the sum of its postings.
  • Each transaction’s cash postings against the row’s expected balance impact.
  • Each row’s version against the version of its newest entry.
  • Sources with a money effect but no entry.
  • The trial balance: all postings per tenant and currency must sum to zero.

During the cutover, legacy balances were also compared with ledger balances. That comparison was removed with the legacy balance pipeline. The remaining checks compare stored ledger state with postings and source rows.

Checklist

  • Commit a money effect in the same transaction as the row that causes it; do not carry it on an event dispatched after the commit.
  • Give each entry a unique source-and-version key, define exactly which fields establish replay equality, and handle request deduplication separately.
  • Enforce zero-sum per currency before an entry is saved, and check the trial balance on a schedule.
  • Append money corrections and split changes as restatements or reversals; define which reporting dimensions may be updated in place.
  • Maintain the current balance with an in-place increment in a consistent account order, and treat checkpoints as a cache that no read depends on.
  • Route every live write through one path, ship it behind a flag switched off, and backfill before any read depends on it.
  • Gate read switches on parity and reconciliation, and distinguish completed cutover work from remaining cleanup.
  • Detach the entities of a failed EF Core insert before continuing in the same context.

Why this design

The money-writing path and the ledger share a SQL Server database and an application transaction. That made atomic posting possible without introducing another data store. Existing API contracts and client flows had to be retained, and reads needed a configurable fallback during rollout. These constraints favoured an incremental migration inside the existing application.

The design has limits. Entry balance is validated in application code, and nightly aggregate checks do not catch every possible mapping error. Reconciliation records counts and samples without raising an alert on its own. Replay protection covers a source version and selected posting fields.

AlternativeWhat it addsWhat it costs
A transactional outbox on the old event pathA durable record of each delta awaiting deliveryThe handler still needs deduplication, and the balance depends on delivery completing
A purpose-built ledger databasePosting and balance invariants enforced by a specialised storage engineA second data store and coordination between source-row and ledger writes
A zero-sum check in the databaseEnforcement for every write path, including hand-written SQLA controlled insertion path, such as a stored procedure or a statement trigger receiving complete entries; a normal row-level check cannot enforce the sum across postings
Event sourcingThe history itself as the source of truthA rewrite of how every transaction is stored and read
Recomputing balances from transactions on every readNo stored state that can driftRead cost that grows with history, and no double-entry check at all

Further reading