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

WhoAmI => notes.sohag.pro/author
Last night I had dinner with two friends, and one of them paid the whole bill, 1,800 taka, before the rest of us could reach for our phones. So on the way home I opened my payment app, typed 600 taka, and sent him my share. My balance dropped before I reached the bus stop. Then I stopped and thought about what just happened. Somewhere, a system recorded that money left my account and entered his, atomically, in milliseconds, in a way that both of us, the payment company, and possibly an auditor three years from now can trust completely.
That system is called a ledger, and I'm going to build one. In public, open source, over twelve weeks, with a blog post every week. This is post 1 of 12.
Why a ledger, of all things?
Most side projects die for one of two reasons: they're too ambitious to finish, or too trivial to matter. A todo app teaches you nothing new. A "build your own database" project takes two years and ships never.
A payment ledger sits in the sweet spot. It's small enough to build in twelve weeks, and deep enough that doing it right forces you through almost everything that makes backend engineering hard: data integrity, concurrency, idempotency, observability, and deployment.
Every fintech company has a ledger at its core, and most are built on an idea older than the printing press: double-entry bookkeeping. The rule is simple. Money never appears or disappears. It only moves. Every transaction is recorded as two or more postings that must sum to zero. Your balance isn't a number someone updated; it's the sum of every posting that ever touched your account. The history is the state.
Back to my dinner. Here's what paying my friend back his 600 taka looks like as a double-entry transaction:
| Posting | Account | Amount |
|---|---|---|
| 1 | sohag:wallet | -600 |
| 2 | friend:wallet | +600 |
| Sum | 0 |
Two postings, summing to zero. If a bug ever produces a transaction where the sum isn't zero, money was created or destroyed out of thin air, and the system must reject it before it persists:
Transaction: pay back dinner share (600 taka)
|
+---------------+---------------+
| |
v v
Posting 1: sohag:wallet Posting 2: friend:wallet
-600 +600
| |
+---------------+---------------+
|
v
Sum of postings = 0 ?
| |
yes | | no
v v
Commit: append to Reject: money cannot
ledger (immutable) appear or vanish
That one constraint is why this project is interesting to build:
Correctness is non-negotiable. A bug in a CRUD app shows a stale page. A bug in a ledger creates or destroys money. The invariant must hold under any failure, any concurrency, any retry.
The hard problems are real ones. What happens when two requests post to the same account simultaneously? When a client retries a payment because the network dropped the response? When the database dies mid-transaction? These aren't interview puzzles; they're Tuesday.
It's bounded. The domain fits in your head. Accounts, transactions, postings, balances. No ML, no infinite feature surface. The depth is vertical, not horizontal.
Why Go?
I want a language where the production story is boring. Go gives me a single static binary, a stellar standard library (net/http, log/slog, database/sql and friends), first-class concurrency for the stress tests I have planned, and an ecosystem where the "right" choice for most infrastructure questions is well-trodden.
The stack is locked upfront, because relitigating decisions mid-project is how twelve weeks becomes thirty:
Router: chi (idiomatic, minimal)
Database: Postgres with pgx + sqlc for type-safe queries, goose for migrations
API: REST first, gRPC later (with buf for proto management)
Testing: standard library + testcontainers-go, k6 for load, property-based tests for the balance invariant
Observability: OpenTelemetry traces, slog structured logs, Prometheus metrics
Deployment: Docker image, deployed to a VPS via GitHub Actions CI/CD
No Kubernetes, no Kafka, no event sourcing, no multi-currency. Not because they're bad, but because scope discipline is the difference between shipping and abandoning. Single currency, one region, no admin UI. Everything cut from v1 becomes a follow-up post if the project earns it.
The 12-week plan
Scaffolding: repo, module layout, tooling, hello ledger (this post)
Domain model: double-entry accounting in Go types; invariants the compiler helps enforce
Postgres schema + repository layer: append-only postings, integration tests against real Postgres
Transaction posting service: atomicity, SERIALIZABLE isolation, correctness under 10,000 concurrent posts
REST API: chi, validation, RFC 7807 errors, OpenAPI
Idempotency keys + immutable audit log: the two patterns that make fintech APIs boring (in a good way)
gRPC layer: shared domain services behind both protocols
OpenTelemetry: one trace from HTTP handler to SQL query
Testing: unit, integration, property-based, load, and a little chaos
Docker + infrastructure: multi-stage builds, distroless, tiny images
Live deployment + CI/CD: push to main, live in minutes
Launch + retrospective
Week 1: the unglamorous part
This week was scaffolding, deliberately light. The goal: anyone can clone the repo, run make run, and hit a health check.
The layout follows the standard Go convention:
go-ledger/
├── cmd/server/ # main.go, wiring only, no logic
├── internal/ # domain code lives here, unimportable from outside
├── pkg/ # public packages, if any earn the spot
├── docs/adr/ # architecture decision records
├── Makefile # run, build, test, lint, dev
├── Dockerfile # multi-stage skeleton, distroless base
├── .golangci.yml # lint config
└── .air.toml # hot reload
The server itself is about seventy lines of standard library: an http.ServeMux (the 1.22+ pattern-matching routes mean I don't even need chi yet), slog JSON logging from day one, and graceful shutdown on SIGTERM, because writing it now is free and bolting it on later never happens.
$ curl localhost:8080/healthz
{"status":"ok"}
Not impressive. But it builds clean, lints clean (golangci-lint with gosec and friends enabled from commit one), and ships with the project's first architecture decision record: ADR-001, why double-entry. Writing ADRs for a solo project feels ceremonial until you're in week 9 trying to remember why you rejected event sourcing. Future me will thank present me.
My AI companion
Full transparency: I'm not building this alone. I have an AI pair, Claude, running in my terminal via Claude Code.
This week it handled the unglamorous parts: installed the Go toolchain, scaffolded the project layout, wrote the Makefile and lint config, drafted the first ADR, and caught its own lint failures before I saw them. That's the deal I've settled on: Claude accelerates the mechanical work, and I own every decision that matters. The architecture, the invariants, the trade-offs in the ADRs, and the understanding stay mine. A ledger where I can't explain why every line exists would defeat the entire point of this project.
I'll be honest about the split as the series goes. When Claude writes something substantial, I'll say so. When I overrule it, that's probably worth a paragraph too; those disagreements tend to be where the interesting engineering lives. If you're curious how an AI-assisted workflow holds up across a twelve-week production build, that's now a quiet subplot of this series.
Why you should build one too
Because "I read about idempotency keys" and "I watched 100 goroutines try to corrupt my ledger and fail" are different kinds of knowledge. The second kind is what production engineering actually is, and you can't get it from blog posts, including this one. You get it by building the thing, breaking it, and fixing it.
The repo is open source at github.com/sohag-pro/go-ledger. Follow along, steal the plan, or build your own. Next week: modeling double-entry accounting in Go's type system, and making the balance invariant impossible to violate.



