LedgerCore
A PostgreSQL double-entry ledger where money is never created or lost and no transfer is ever applied twice
What this project proves
- Correctness under concurrency: ordered row locks gave 0 deadlocks and 0 faults across 12/12 load runs, and a reversal race was found and fixed with a test
- Invariants in the database: debits = credits is checked by a trigger at COMMIT, with imbalance 0 both under load and in a database restored from backup
- Measurement-driven optimization: a single-statement balance read cut p95 from 24.9 to 13.4 ms in a fixed-rate A/B
- Finding causes with evidence: a 10-second stall under load was traced by lining up Postgres and API log timestamps, and the cause turned out to be synchronous logging
- Operations: SHA-tagged deploys, rollback in about 1 s, daily backups with verified restores
- Role
- Solo: design, backend, database, tests, load testing, deployment (53 commits)
- Period
- 2026.08 – 2026.10
- Stack
- NestJS
- TypeScript
- PostgreSQL
- node-pg-migrate
- zod
- React
- Vite
- Vitest
- k6
- Docker
- Caddy
- GitHub Actions
Key numbers
| Item | Value | Type | Evidence |
|---|---|---|---|
| Valid load-test runs (0 deadlocks, 0 faults) | 12 / 12 | Measured | View evidence |
| Plain transfer throughput (50 VUs) | 249 req/s | Measured | View evidence |
| Plain transfer p95 | 228 ms | Measured | View evidence |
| Hot-account transfer p95 (40 VUs → 1 account, 0 409s, 0 deadlocks) | 896 ms | Measured | View evidence |
| Mixed workload throughput (30 readers + 10 writers) | 884 req/s | Measured | View evidence |
| Balance read p95 (fixed-rate A/B, after) | 13.4 ms | Measured | View evidence |
| Restore from backup (14/14 tables, imbalance 0) | 0.68 s | Measured | View evidence |
| Automated tests | 392 | Measured | View evidence |
The problem
A system that records money moving between accounts has one requirement: never be wrong. Whether requests arrive at the same instant, the client retries, or the process dies halfway, money must not appear or vanish and no transfer may be applied twice.
Architecture
An API (a NestJS 10 modular monolith) handles users and accounts, deposits and withdrawals, transfers, reversals, balances, history and reconciliation. A separate worker process from the same codebase leases outbox events with FOR UPDATE SKIP LOCKED and sends them as HMAC-signed webhooks. Invariants are checked by deferred constraint triggers in PostgreSQL 16, so a bug in application code cannot bypass them. An operations console (React + Vite) shows accounts, transfers and outbox delivery.
Data model
Key constraints and indexes
- ledger_entries: UPDATE / DELETE 금지 (트리거), append-only
- covering index (account_id, id) INCLUDE (direction, amount): 잔액 합계를 index-only scan으로
- accounts: 통화마다 SYSTEM 계좌 1개 (unique index)
- transactions.reverses_id: 원 거래 하나에 reversal 하나 (unique index)
- idempotency_keys: 원장 엔트리와 같은 트랜잭션에 저장
- 모든 FK ON DELETE RESTRICT: 돈과 관련된 행은 cascade로 지울 수 없음
- outbox_events: created_at 기준 월별 파티션 + DEFAULT 파티션
Key design decisions
ADR-0001 Money as BIGINT minor units, strings in the API
- Context
- JSON numbers are IEEE 754, so large amounts can be silently corrupted.
- Decision
- Store BIGINT minor units, wrap them in a bigint-based Money type and serialize as strings at every boundary.
- Trade-off
- Clients must read amounts as strings, in exchange for no floating-point errors.
ADR-0002 No balance column; balances derived from entries
- Context
- A stored balance reads fast, but when it disagrees with the entries there is no way to tell which is right.
- Decision
- Balances are always the sum of ledger_entries, with a covering index to bound the cost.
- Trade-off
- Read cost grows with history; snapshots get added once a measurement calls for them.
ADR-0003 Raw SQL instead of an ORM
- Context
- In a ledger every query and lock has to be visible; what an ORM hides is dangerous.
- Decision
- The pg driver with raw SQL, and node-pg-migrate for migrations.
- Trade-off
- Type safety is guaranteed by integration tests rather than the compiler.
ADR-0004 Modular monolith
- Context
- Splitting the ledger and the outbox would break the atomicity everything depends on.
- Decision
- accounts, ledger, idempotency and outbox modules in one process and one database.
- Trade-off
- A module never touches another module's tables directly.
ADR-0005 Invariants in deferred constraint triggers
- Context
- The rules for a valid transaction are properties of a set of entries, which a CHECK cannot express, and application checks can be bypassed.
- Decision
- Triggers at COMMIT check at least two entries, debits = credits, total = transaction amount and matching currencies.
- Trade-off
- Errors surface at COMMIT rather than at the statement.
ADR-0006 READ COMMITTED + FOR UPDATE in id order
- Context
- Locking accounts in request order deadlocks A→B against B→A immediately.
- Decision
- Account row locks are always taken in ascending id order.
- Trade-off
- Every new write path must follow the same order.
ADR-0007 Monthly partitions for the outbox only, none for the ledger
- Context
- Partitioning transactions would quietly break the unique constraint that prevents double refunds.
- Decision
- Monthly partitions by created_at plus a DEFAULT partition, applied to outbox_events only.
- Trade-off
- Creating partitions ahead of time becomes an operational task.
Before and after
- What changed
- The balance endpoint did BEGIN READ ONLY → check account → SUM → COMMIT, making four round trips while holding a connection (HTTP p95 141 ms, the SQL itself about 0.3 ms). Under READ COMMITTED each statement takes its own snapshot, so the transaction gave no consistency guarantee anyway. Raising the pool from 20 to 40 helped only 7%, so the pool was not the cause, and EXPLAIN ANALYZE on a 200,000-row database showed the same 55 buffers and the same index for both versions. I replaced it with a single accounts + CROSS JOIN LATERAL (SUM …) statement (keeping the 404) and added a regression test that asserts one database call. Measured at a fixed 100 iter/s, 3 + 3 runs; differences in history and transfer p95 are within noise.
Incident write-up
- Symptom
- A full benchmark produced three 500s and three 409s; the ledger stayed balanced.
- Root cause
- Postgres raised lock_timeout at 04:00:52.7, but the API logged its response 10 seconds later, with a burst of 500s within 40 ms: the Node process itself had stalled. The cause was logging every request at info level; on Windows, writes to a stdout pipe are synchronous and the PowerShell redirection could not keep up.
- Fix
- With LOG_LEVEL=warn the same scenario gave 0 faults, 0 409s and 37% more throughput. lock_timeout 3 s, statement_timeout 5 s and idle_in_transaction_session_timeout 10 s released the locks, and timeouts that had returned 500 INTERNAL_ERROR now return 503 DATABASE_TIMEOUT (retryable: true) (8c30dba).
- Prevention
- Wrote three timeout → 503 tests against a real Postgres and pg-pool, and recorded in the limitations that production needs an async logger or sampling.
Tests and quality
- Tests
- 392
- Coverage
- —
- CI
- GitHub Actions (커밋 메시지, 포맷, lint, typecheck, Postgres 서비스로 테스트, 빌드; billing 문제로 현재 중단)
- Test tools
- Vitest, supertest, 실제 PostgreSQL 16 (mock 없음), k6 + bench.ps1
Deployment and operations
- Deployment
- iwinv VPS on Docker Compose. Only web (Caddy) sits on the external proxy network, forwarding /v1/* to the API; Postgres and Redis ports are not exposed. deploy.sh: conflict-marker check → git merge --ff-only → build migrate, api and web images with SHA tags (the worker uses the api image) → start the API and worker after migrate succeeds → wait up to 60 s for /v1/health/ready → record only a ready version in APP_TAG and .deploy-history, otherwise print the exact rollback command.
- Backups
- Cron backs up every Postgres container on the server with pg_dump -Fc daily at 03:00 and keeps 7 days. restore-test.sh restores the latest dump into a scratch database and compares table counts, and for LedgerCore VERIFY_SQL also checks the double-entry invariant: a 68K dump, 14/14 tables, restore in 0.68 s, 20 entries with imbalance=0.
- Rollback
- rollback.sh returns api, worker and web to the previous (or a given) SHA without building and checks health; it never touches Postgres or Redis, and migrations are not rolled back. Tested in production 2066318 → d10826f → 2066318, ready every time (about 1 s).
- Monitoring
- postgresql.prod.conf: lock_timeout 3 s, statement_timeout 5 s, idle_in_transaction_session_timeout 10 s, queries slower than 500 ms logged, pg_stat_statements enabled; the database is connected to PG Insight over the monitoring network. /v1/health/ready
Screenshots
Limits and retrospective
Current limits
- All numbers come from one laptop (i7-8565U, 4C/8T, 15 W) running k6, the API and Postgres together; they do not represent production capacity, only the comparisons are reliable
- Plain transfer p95 is 228 ms, missing the script's 150 ms target
- Balance cost grows with history (0.42 ms at about 240 entries, 1.68 ms at about 850); the next step is a snapshot table, a cache rebuildable from the entries
- assert_transaction_balanced runs three times per transfer (once per inserted row) and is the second-largest share of database time in the baseline: 104 s over 42,421 transfers
- Synchronous per-request logging can stall Node under load, so production needs an async logger or sampling
- A single Postgres primary: no horizontal write scaling, replicas or failover
- Redis is in the compose file but unused; the outbox queue lives in Postgres (SKIP LOCKED)
- GitHub Actions is stopped by a billing issue, so tests were run locally by hand
Key code
db/migrations/1737000200000_enforce-double-entry-invariant.sqlDeferred constraint triggers that check debits = credits at COMMIT
db/migrations/1737000300000_make-ledger-immutable.sqlForbids editing and deleting entries in the database itself
apps/api/src/modules/ledger/infrastructure/transaction.repository.tsFOR UPDATE on accounts in id order
apps/api/src/modules/ledger/application/transfer.service.tsLock, funds check, entries and outbox in one transaction
apps/api/src/modules/ledger/application/reversal.service.tsReversal that re-reads status after locking (the race fix)
apps/api/src/modules/ledger/infrastructure/balance.repository.tsSingle-statement balance read
apps/api/src/shared/database/transaction.manager.tsAsyncLocalStorage transactions with retries on 40001/40P01
apps/api/src/shared/errors/postgres-error.translator.tsSQLSTATE → domain errors, timeout → 503
apps/api/test/concurrency/transfer-concurrency.test.tsTen concurrency tests
load-tests/bench.ps1Warm-up, three runs, median and validity rules
Current status and next steps
All 12 load-test runs on the final code were valid, with 0 faults, 0 deadlocks, 0 contention and a balanced ledger after every run. Cutting the balance read to a single statement lowered p95 from 24.9 ms to 13.4 ms in a fixed-rate A/B. Before trusting the numbers I also found and fixed a race where the second of two concurrent reversals returned 422 instead of 409 (ccee39b), tests that never ran on a clean clone because .env.test was gitignored (e0e6884), and __ITER and teardown bugs in the k6 scripts (0f76291).