All projectsLive

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

ItemValueTypeEvidence
Valid load-test runs (0 deadlocks, 0 faults)12 / 12MeasuredView evidence
Plain transfer throughput (50 VUs)249 req/sMeasuredView evidence
Plain transfer p95228 msMeasuredView evidence
Hot-account transfer p95 (40 VUs → 1 account, 0 409s, 0 deadlocks)896 msMeasuredView evidence
Mixed workload throughput (30 readers + 10 writers)884 req/sMeasuredView evidence
Balance read p95 (fixed-rate A/B, after)13.4 msMeasuredView evidence
Restore from backup (14/14 tables, imbalance 0)0.68 sMeasuredView evidence
Automated tests392MeasuredView 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

Beforebalance p95 24.9 ms · 4 round tripsView evidence
Afterbalance p95 13.4 ms · 1 statementView evidence
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

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).