Skip to main content

Command Palette

Search for a command to run...

Modeling Double-Entry Accounting in Go: Types That Make Invariants Impossible to Violate

If a transaction that doesn't balance can't even be built without the compiler and a tiny Validate method noticing, a whole category of money bugs just stops existing

Updated
9 min read
Modeling Double-Entry Accounting in Go: Types That Make Invariants Impossible to Violate

A few years ago I shipped a bug that moved money and forgot to take it from anywhere. The code added a credit to one account and, because of a botched early return, never wrote the matching debit. Nothing crashed. No test failed. The balance just quietly grew, like a bathtub with the drain unplugged, until someone in finance noticed the numbers didn't tie out and the hunt began.

That bug is the reason this week exists. Last week I explained why I'm building a payment ledger and got a "hello ledger" health check responding. This week is the part that would have caught that bug at compile time, or close to it: the domain model. No database yet, no HTTP. Just Go types and the one rule they exist to protect. This is post 2 of 12.

The one rule

Double-entry accounting has a single invariant that everything else hangs off:

Every transaction is two or more postings, and those postings must sum to zero.

Money never appears or vanishes. It moves. If I pay a friend back 600 taka, one account goes down by 600 and another goes up by 600, and the two cancel out. The sum is zero. If the sum is ever anything but zero, money was created or destroyed, and that transaction is a bug wearing a transaction's clothes.

My whole goal for the week: make a transaction that doesn't balance hard to even build, and impossible to validate. If the type system and one small method stand guard, the bathtub bug from my past can't happen here.

First decision: how do you even store money?

Before modeling a transaction I had to answer a deceptively boring question: what is an amount? This got its own architecture decision record (ADR-002), because getting it wrong is expensive and quiet.

The wrong answer is float64. Floating point can't represent most decimal fractions exactly. In Go, 0.1 + 0.2 is 0.30000000000000004. That rounding error is invisible on one transaction and catastrophic across a million of them. Money and floats do not mix.

The answer I picked: store every amount as a signed int64 count of the currency's smallest unit. For US dollars that's cents. So $10.50 is the integer 1050. Exact, fast, no allocation, and it maps straight onto a Postgres BIGINT when the database arrives next week. The tradeoff is that I have to guard against overflow myself, which turns out to be a few lines.

Here's the type. The fields are unexported so a Money can only be built through a constructor that validates the currency:

type Money struct {
    amount   int64    // signed minor units: cents for USD
    currency Currency
}

func NewMoney(amount int64, currency Currency) (Money, error) {
    if err := currency.Validate(); err != nil {
        return Money{}, err
    }
    return Money{amount: amount, currency: currency}, nil
}

The interesting part is arithmetic. Adding two amounts can overflow int64, and an overflow that silently wraps a balance from a huge positive to a huge negative is exactly the kind of horror this project is meant to avoid. So Add checks and returns an error instead of wrapping:

func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, ErrCurrencyMismatch
    }
    sum := m.amount + other.amount
    // Overflow happened if both operands share a sign but the result flipped.
    if (m.amount > 0 && other.amount > 0 && sum < 0) ||
        (m.amount < 0 && other.amount < 0 && sum > 0) {
        return Money{}, ErrOverflow
    }
    return Money{amount: sum, currency: m.currency}, nil
}

Two things fall out of this for free. Different currencies can't be added, so a multi-currency mistake (a thing explicitly out of scope for v1) can't silently corrupt a sum. And overflow is a typed error you can test, not undefined behavior you discover in production.

Modeling the transaction

A posting is a single signed entry against one account. The sign carries the direction: a positive amount is a debit, a negative amount is a credit. One field, not two, because the sign already tells you everything.

type Posting struct {
    AccountID string
    Amount    Money
}

type Transaction struct {
    ID       string
    Postings []Posting
}

Back to paying my friend 600 taka. As postings:

Posting Account Amount
1 sohag:wallet -600
2 friend:wallet +600
Sum 0

Now the chokepoint. Transaction.Validate() is the single place in the domain where the invariant lives. It needs at least two postings, every posting has to name an account, every leg has to be the same currency, and the signed amounts have to sum to exactly zero:

func (t Transaction) Validate() error {
    if len(t.Postings) < 2 {
        return ErrTooFewPostings
    }
    for _, p := range t.Postings {
        if err := p.Validate(); err != nil {
            return err
        }
    }
    sum := t.Postings[0].Amount
    for _, p := range t.Postings[1:] {
        next, err := sum.Add(p.Amount) // surfaces currency mismatch and overflow
        if err != nil {
            return err
        }
        sum = next
    }
    if !sum.IsZero() {
        return ErrUnbalanced
    }
    return nil
}

Notice how the currency check and the overflow check come for free. I don't write them in Validate at all. They happen because the running sum is built with Money.Add, and Add already refuses to mix currencies or wrap. The small type did the work so the big method didn't have to.

Here's the same flow as a picture:

        Transaction: pay back dinner share
                       |
        +--------------+--------------+
        |              |              |
        v              v              v
   Posting 1       Posting 2      Posting N
  sohag -600      friend +600       ...
        |              |              |
        +--------------+--------------+
                       |
            fold with Money.Add  ->  same currency? overflow?
                       |
                       v
                  Sum == 0 ?
                  /        \
              yes /          \ no
                 v            v
        Validate passes   ErrUnbalanced
        (safe to persist)  (rejected)

Every arrow that could go wrong has a named error waiting for it: ErrTooFewPostings, ErrInvalidPosting, ErrCurrencyMismatch, ErrOverflow, ErrUnbalanced. When something fails, the caller knows exactly which rule it broke.

Proving it holds, hundreds of times

Table-driven unit tests cover the cases I can think of: a balanced two-leg transaction, a balanced three-leg one, an unbalanced one, a single posting, a currency mismatch, an empty account ID. Good, but those are the cases I imagined. The bug from my past was one I hadn't imagined. So the balance invariant gets a second kind of test.

Property-based testing flips the script. Instead of asserting "this specific input gives this specific output," you assert a property that must hold for all inputs, then let the library throw hundreds of randomized cases at it and try to find one that breaks the rule. I'm using pgregory.net/rapid.

The generator builds a transaction that is balanced by construction: pick a random number of legs, give each a random amount, then make the last leg the exact negation of everything before it. The sum is zero on purpose. The property: any transaction built this way must pass Validate.

func TestProp_BalancedAlwaysValid(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        tx := Transaction{ID: "tx", Postings: genBalancedPostings(t)}
        if err := tx.Validate(); err != nil {
            t.Fatalf("balanced transaction rejected: %v", err)
        }
    })
}

Then the mirror image: take a balanced transaction, bump exactly one leg by a non-zero amount, and assert it always fails. A balanced ledger you can nudge into imbalance and it still passes would be a disaster.

func TestProp_PerturbedAlwaysInvalid(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        postings := genBalancedPostings(t)
        idx := rapid.IntRange(0, len(postings)-1).Draw(t, "idx")
        delta := rapid.Int64Range(1, 1_000_000).Draw(t, "delta")
        bumped, _ := NewMoney(postings[idx].Amount.Amount()+delta, "USD")
        postings[idx].Amount = bumped
        tx := Transaction{ID: "tx", Postings: postings}
        if err := tx.Validate(); err == nil {
            t.Fatal("perturbed transaction passed Validate, expected failure")
        }
    })
}

If rapid ever finds a counterexample, it shrinks it down to the smallest input that still breaks and hands it to me. That's the part I love: the test doesn't just say "something's wrong," it says "here is the simplest case that's wrong." For an invariant this important, I want a tireless adversary trying to break it on every run, not just the handful of examples I was clever enough to write down.

Everything passes, the domain package sits at 98% coverage, and the linter (gosec included) is clean.

What I deliberately left out

No Account balance field. Anywhere. A balance is never stored as mutable state; it's the sum of every posting that ever touched the account, derived when you ask. That's the whole point of double-entry, and storing a balance you also derive is how the two drift apart and lie to you. The Account type this week is identity and classification only: an ID, a name, a type (asset, liability, equity, income, expense), and a currency.

No persistence, no concurrency control, no money math beyond add, subtract, and negate. Those have their own weeks. This week was about getting the shapes right, because every layer above this one (the repository, the service, the API) is going to lean on these types being correct.

The honest split

Same as last week: I own the decisions, Claude does the mechanical lifting. The choice of int64 minor units over a decimal library, the signed-posting convention, the idea of making Validate the single chokepoint, those are mine and they're written down in ADR-002 so future me can't forget the reasoning.

What Claude did, working from a detailed plan I reviewed first, was the test-driven grind: write the failing test, watch it fail, write the minimal code, watch it pass, commit, repeat. It also caught a real edge case I'd waved off. My Money.String formatter negated the amount to print a minus sign, which breaks for the single most-negative int64 value, whose negation doesn't fit in an int64. An absurd amount for a real ledger, but "absurd inputs shouldn't produce nonsense" is exactly the standard this project holds itself to, so we fixed it with an unsigned-magnitude trick and added a test pinning the behavior. Small thing. But small things quietly wrong is the whole genre of bug I'm trying to engineer out of existence.

Next week

The types are solid and proven. Next week they meet reality: a Postgres schema with append-only postings, a repository layer with sqlc, and the first integration test that creates accounts, posts a real transaction, and reads a balance back, all against an actual database spun up in a container.

The repo is open at github.com/sohag-pro/go-ledger. The domain model from this post lives in internal/domain, and ADR-002 explains the money decision in full.


Building go-ledger

Part 2 of 2

Kicking off a 12-week build of an open-source, production-grade payment ledger in Go: double-entry accounting, Postgres, gRPC, OpenTelemetry, and a real deployment.

Start from the beginning

Why I'm Building a Production Go Payment Ledger (And You Should Too)

What a 600 taka dinner debt taught me about the system every fintech is built on, and why I'm spending twelve weeks building one in public