Ledgerline: a safe payments ledger
A Go and PostgreSQL ledger that demonstrates idempotency, transaction safety, and reconciliation.
Build a small ledger API in Go and PostgreSQL. Show how it handles retries, concurrent transfers, and incomplete webhook delivery. The project is a learning system, not a real payment service.
Focus: transaction correctness, database design, and failure handling. Plan about 60 to 70 hours across global weeks 2 to 7. The milestones below use project weeks 1 to 6, which map to global weeks 2 to 7.
Use the project reading in the calendar
Read the linked sources in the matching calendar reading slots. Do not add a separate three-hour reading block before the build.
- Stripe: Designing robust and predictable APIs with idempotency: the canonical idempotency-key design. Your
/transfersendpoint is a mini version of it. - Brandur Leach: Implementing Stripe-like idempotency keys in Postgres: the reference approach for your unique-index plus initial-request-row design.
- Modern Treasury: Accounting for Developers, Part I: double-entry basics without finance jargon.
- Modern Treasury: Enforcing Immutability in your Double-Entry Ledger: why ledger rows must never be
UPDATEd. - Modern Treasury: How to Scale a Ledger, Part I: where your design hurts first as data grows.
- microservices.io: Transactional outbox: the pattern your week-3 worker implements.
The flow
Build spec
Scope
- Core: accounts, transfers, a balanced journal, idempotency, and a reconciliation command.
- If time allows: an outbox worker, signed webhooks, and a small failure-injection mode.
- Cut first: elaborate provider simulation, large randomized workloads, and multiple worker processes.
Show one transfer from API request through database rows and reconciliation. Do not connect the project to real payment credentials or real funds.
The API
- REST API: accounts, transfers, balances.
- Double-entry journal:
sum(debits) == sum(credits)for every transaction, always. A test checks it, and a nightly reconciliation job checks it again.
Idempotency
- Idempotency keys on
POST /transfers: same key plus same payload gives the same result, with no second side-effect. - Store the key in a unique index, insert it first, then apply the transfer in the same transaction.
Locking and money
- Postgres:
SELECT … FOR UPDATEwith ordered lock acquisition by account ID. This kills deadlocks. - Money is int64 minor units everywhere, never floats.
Outbox and webhooks
- Transactional outbox: webhook rows are written in the same transaction as the transfer.
- A worker reads them with
FOR UPDATE SKIP LOCKEDand delivers with exponential backoff (1 s, 4 s, 16 s, 64 s, with jitter). - Each payload is HMAC-signed.
- Failure simulator: choose whether a callback is dropped, duplicated, delayed, or delivered normally. Use a fixed seed when you want to reproduce a run, and report the cases you tested.
Tests and chaos
- Failure test: stop the service during a transfer, restart it, and inspect the journal and idempotency result.
- Property-based test: generate transfers with valid and invalid balances. Check that each committed transaction balances and that retries do not create a second transfer.
- CLI tool:
ledgerline reconcile --from 2026-11-01prints a drift report.
Benchmarks
- Measure transfer throughput and p50/p95/p99 latency with a repeatable local workload.
- Record the hardware, database settings, workload size, and limits beside every result.
Milestones
Artifacts to publish
- README with an architecture diagram, the idempotency design, the lock-ordering note, results from your own run, and a short demo.
docs/ADR-001-idempotency.md,ADR-002-outbox.md,ADR-003-lock-ordering.md.- Optional post: "What I learned from testing retries in a small payments ledger".
- Benchmark script and raw CSV in the repo, so the numbers are re-runnable.
Interview yield
Idempotency design · exactly-once vs at-least-once · transactional outbox · deadlock avoidance · money rounding · ACID in anger · "what happens if your worker dies mid-webhook?"