# 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:

```go
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:

```go
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.

```go
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:

```go
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:

```plaintext
        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`.

```go
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.

```go
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](https://github.com/sohag-pro/go-ledger). The domain model from this post lives in `internal/domain`, and [ADR-002](https://github.com/sohag-pro/go-ledger/blob/main/docs/adr/002-money-representation.md) explains the money decision in full.

* * *
