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

WhoAmI => notes.sohag.pro/author
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.



