← writing

Mar 2026 · 2 min · ↵ Writing

Designing an event-driven ledger in Go

gogrpc
Abstract acrylic painting in blue, yellow and red

Most financial systems are built around the database. A record is an update to a row — readable, writable, forgettable. That works until someone asks the one question every ledger eventually faces: how did this balance get here?

For AutoPesa the answer had to be airtight. So instead of storing balances, we stored the events that produce them — and rebuilt the rest.

The problem

A row that can be updated is a row that can be wrong. When a balance is just a number in a column, an off-by-one in any code path silently corrupts the truth, and there is no way to prove what the balance should have been.

We needed a record that was append-only, ordered, and replayable — a financial diary nobody could quietly edit.

The architecture

The system is organised hexagonally: the domain core knows nothing about Postgres, gRPC, or HTTP. Ports describe what the core needs; adapters supply it.

type Event struct {
	ID        string
	Kind      string
	Amount    int64 // minor units, never floats
	CreatedAt time.Time
}
 
type Ledger interface {
	Commit(ctx context.Context, e Event) error
	Replay(ctx context.Context, account string) (Balance, error)
}

The core depends only on that interface. Swapping Postgres for an in-memory store in tests is a one-line change, and the domain logic never notices.

The event store

Every mutation is an INSERT. There are no UPDATEs on the ledger table — the balance is a projection, folded from the event stream on read (and cached).

func (l *pgLedger) Replay(ctx context.Context, acct string) (Balance, error) {
	rows, err := l.q.EventsByAccount(ctx, acct)
	if err != nil {
		return 0, err
	}
	var bal Balance
	for _, e := range rows {
		bal = bal.Apply(e) // pure function, fully testable
	}
	return bal, nil
}

Because Apply is pure, the entire balance history is a property test away from being verified.

gRPC internals

Services talk over gRPC with generated, typed stubs. The win isn't speed — it's that an invalid message can't be constructed. The wire contract and the Go types are the same source of truth, so a renamed field is a compile error, not a 3am incident.

Typed internal comms turned a class of runtime bugs into build failures.

Outcomes

  • ~40% faster dashboard loads from cached projections.
  • A tamper-evident ledger: the history is the source of truth.
  • 100% typed service-to-service communication.

The lesson that stuck: model the events, not the state. State is just the latest replay.