필사 모드: Banking Ledger Design — Double-Entry Bookkeeping, Event Sourcing, and Consistency Down to the Last Cent
EnglishIntroduction — When One Cent Is Off, the Bank Stops
The first thing that surprises developers new to banking systems is this fact: if the balance is off by even one cent, end-of-day close does not complete. In a typical web service, you can tolerate slightly inconsistent data, run a correction batch, and move on. A bank ledger is different. The ledger is the legal record of customer assets, the subject of accounting audits and regulatory examinations, and the moment debits and credits stop balancing, that day of business cannot be closed.
In this article, we assume we are designing a banking ledger from scratch and walk through double-entry fundamentals, the data model, balance computation strategies, event sourcing, concurrency control, and consistency problems in distributed environments. The content applies equally to fintech startups building internal ledgers or anyone designing ledger-like systems for points, stored value, or wallets.
A note up front: this is a technical article about system design, not accounting, tax, or legal advice. For actual financial licensing or accounting treatment, always consult professionals and the applicable regulations.
Why the Ledger Is the Source of Truth of a Bank
In a core banking system, the ledger is the final record of every transaction. All other data — the balance shown on a screen, analytical data in the data warehouse, the transaction history in a mobile app — is merely a derived copy of the ledger.
There are three reasons the ledger must be the source of truth.
1. **Legal evidentiary value**: When disputes occur, the ledger record is the legal basis. Records must therefore be immutable, and changes happen only by appending correction records, never by editing.
2. **Accounting integrity**: A bank financial statement is aggregated from the ledger. The total of debits and the total of credits in the General Ledger must always match.
3. **Audit traceability**: It must be possible to reconstruct who recorded which transaction, when, and on what basis.
These three requirements drive every decision in ledger design — the append-only structure, double-entry bookkeeping, idempotency, and audit columns.
Double-Entry Basics — Debits and Credits
Double-entry bookkeeping records every transaction in at least two accounts simultaneously. One side is a debit, the other a credit, and within a single transaction the sum of debit amounts must equal the sum of credit amounts.
Accounts fall into five broad categories, and the category determines whether an increase is recorded as a debit or a credit.
| Account category | Examples | Increase recorded as | Decrease recorded as |
| --- | --- | --- | --- |
| Asset | Cash, loan receivables | Debit | Credit |
| Liability | Customer deposits | Credit | Debit |
| Equity | Share capital | Credit | Debit |
| Revenue | Interest income, fee income | Credit | Debit |
| Expense | Interest expense | Debit | Credit |
Here is a point that frequently confuses developers. Customer deposits are a **liability** from the bank perspective, because money entrusted by customers is debt that must eventually be returned. Conversely, loans are an **asset** (money to be received) from the bank perspective.
As an example, the journal entry for customer A depositing 1,000,000 KRW in cash looks like this.
| Account | Debit | Credit |
| --- | --- | --- |
| Cash (asset increase) | 1,000,000 KRW | |
| Customer deposits (liability increase) | | 1,000,000 KRW |
An internal transfer of 300,000 KRW from customer A to customer B looks like this.
| Account | Debit | Credit |
| --- | --- | --- |
| Customer deposits - A (liability decrease) | 300,000 KRW | |
| Customer deposits - B (liability increase) | | 300,000 KRW |
Collecting 50,000 KRW of loan interest looks like this.
| Account | Debit | Credit |
| --- | --- | --- |
| Customer deposits - A (liability decrease) | 50,000 KRW | |
| Interest income (revenue increase) | | 50,000 KRW |
The invariant that debit totals equal credit totals in every journal entry — called a balanced entry — is the core of ledger integrity. This invariant must be enforced in three layers: application code, database constraints, and batch verification.
The Ledger Data Model — Journal, Postings, Balances
A ledger model proven in practice consists of three core tables.
- **journal_entries (entry header)**: One business transaction. It carries the transaction ID, transaction type, occurrence time, and idempotency key.
- **postings (entry lines)**: The individual debit and credit records that make up an entry. At least two lines belong to each entry.
- **account_balances**: A table holding the current balance per account, in the spirit of a materialized view.
Here is an example schema for PostgreSQL.
-- Account master
CREATE TABLE accounts (
account_id BIGINT PRIMARY KEY,
account_no VARCHAR(20) NOT NULL UNIQUE,
account_type VARCHAR(10) NOT NULL, -- ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE
currency CHAR(3) NOT NULL DEFAULT 'KRW',
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Entry header (journal)
CREATE TABLE journal_entries (
entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
txn_id UUID NOT NULL UNIQUE, -- idempotency key
txn_type VARCHAR(30) NOT NULL, -- DEPOSIT, TRANSFER, INTEREST ...
business_date DATE NOT NULL, -- value date (business-day based)
posted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reversal_of BIGINT REFERENCES journal_entries(entry_id),
description TEXT,
created_by VARCHAR(50) NOT NULL
);
-- Entry lines (postings) — append-only, no UPDATE/DELETE
CREATE TABLE postings (
posting_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES journal_entries(entry_id),
account_id BIGINT NOT NULL REFERENCES accounts(account_id),
direction CHAR(1) NOT NULL CHECK (direction IN ('D', 'C')),
amount NUMERIC(19, 4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
business_date DATE NOT NULL
);
CREATE INDEX idx_postings_account_date
ON postings (account_id, business_date, posting_id);
-- Per-account balance (derived data)
CREATE TABLE account_balances (
account_id BIGINT PRIMARY KEY REFERENCES accounts(account_id),
balance NUMERIC(19, 4) NOT NULL DEFAULT 0,
last_posting_id BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Let us walk through the design points.
1. **Amounts use NUMERIC (or DECIMAL)**: Floating point (FLOAT, DOUBLE) is strictly forbidden. You cannot handle money in a world where 0.1 + 0.2 is not 0.3. Storing integers in the smallest currency unit (won, cents) is also widely used.
2. **Separate direction and amount**: Compared to expressing debit and credit with signed amounts, a direction column plus positive amounts makes aggregation queries and verification much clearer.
3. **Separate business_date (value date) and posted_at (system time)**: A transaction arriving at 23:59 may be valued on the next business day; the accounting date and the physical recording time can differ.
4. **Postings are append-only**: Block UPDATE and DELETE at the database privilege level.
-- Application role gets INSERT/SELECT only
REVOKE UPDATE, DELETE ON postings FROM app_user;
GRANT INSERT, SELECT ON postings TO app_user;
Balance Computation Strategies — Sum vs Real-Time Balance vs Snapshot
There are three broad ways to compute an account balance, each with different trade-offs.
| Strategy | Method | Pros | Cons |
| --- | --- | --- | --- |
| Posting summation | SUM query every time | Always correct, no derived state | Slows down as transactions accumulate |
| Real-time balance table | Update balance per transaction | O(1) reads, immediate balance checks | Update contention, dual consistency management |
| Snapshot plus delta | Store EOD balance, sum only the delta since | Fast reads and verifiable | Requires snapshot batch operations |
In practice you combine all three. At transaction time you update the balance table (to block insufficient funds), at end of day you take a snapshot, and a verification batch reconciles posting sums against the balance table.
A snapshot-based balance query looks like this.
-- Yesterday closing snapshot + today delta = current balance
SELECT s.balance
+ COALESCE(SUM(
CASE WHEN p.direction = 'C' THEN p.amount
ELSE -p.amount END), 0) AS current_balance
FROM balance_snapshots s
LEFT JOIN postings p
ON p.account_id = s.account_id
AND p.business_date > s.snapshot_date
WHERE s.account_id = 1234567
AND s.snapshot_date = (
SELECT MAX(snapshot_date) FROM balance_snapshots
WHERE account_id = 1234567)
GROUP BY s.balance;
For liability accounts (deposits), a credit means increase, so the query adds C and subtracts D as above; for asset accounts the signs are reversed. If the sign rules per account category are not centralized in one place, bugs are guaranteed.
Event Sourcing and the Ledger — Corrections via Reversal Entries
A ledger is essentially the same structure as event sourcing. The immutable append-only journal is the event stream, and the balance is derived state obtained by folding over the events. What accounting has been doing for centuries is exactly event sourcing.
The most important principle is: **never modify a record**. A wrong journal entry is not fixed with an UPDATE; instead, you append a reversal entry that offsets the original, then record the correct entry again.
def reverse_entry(conn, original_entry_id: int, reason: str, operator: str):
"""Create a reversal entry that offsets the original entry.
For each posting of the original, create a posting with the
direction (D/C) flipped. Never modify the original data.
"""
with conn.cursor() as cur:
Check whether this entry was already reversed (prevent double reversal)
cur.execute(
"SELECT 1 FROM journal_entries WHERE reversal_of = %s",
(original_entry_id,))
if cur.fetchone():
raise AlreadyReversedError(original_entry_id)
cur.execute(
"""INSERT INTO journal_entries
(txn_id, txn_type, business_date,
reversal_of, description, created_by)
SELECT gen_random_uuid(), 'REVERSAL', CURRENT_DATE,
entry_id, %s, %s
FROM journal_entries WHERE entry_id = %s
RETURNING entry_id""",
(reason, operator, original_entry_id))
reversal_id = cur.fetchone()[0]
Copy postings with direction flipped
cur.execute(
"""INSERT INTO postings
(entry_id, account_id, direction, amount,
currency, business_date)
SELECT %s, account_id,
CASE direction WHEN 'D' THEN 'C' ELSE 'D' END,
amount, currency, CURRENT_DATE
FROM postings WHERE entry_id = %s""",
(reversal_id, original_entry_id))
return reversal_id
The advantages of the reversal approach are clear.
- Both the original record and the correction record remain, so the audit trail is complete.
- Balance recomputation logic is simple. Summing all postings always yields the current state.
- You can reconstruct the balance as of any point in time (point-in-time queries).
One thing to be careful about is the date on which the reversal occurs. Even if the original was yesterday, the reversal is normally recorded with today as the value date. Changing the numbers of an already-closed business day would retroactively alter reports and accounting books.
Concurrency Control — The Hot Account Problem
Individual customer accounts rarely see concurrent transactions, but accounts like fee income or internal suspense accounts can attract thousands of postings per second. These are called hot accounts.
In a design that updates the balance table per transaction, hot accounts become the center of lock contention.
[Hot account lock contention]
txn1 ──┐
txn2 ──┤ all waiting to UPDATE account_balances
txn3 ──┼─ update the same row ──▶ SET balance = balance + ?
... ──┤ WHERE account_id = 9999 (fee account)
txnN ──┘
──▶ serialized: throughput capped by single-row update speed
Apply mitigation strategies step by step.
1. **Reconsider whether synchronous balance updates are truly needed**: A fee income account needs no limit checks, so you can record only the postings and aggregate the balance asynchronously. Update synchronously only for accounts that need overdraft or limit checks.
2. **Fix the lock ordering**: When a transfer updates two accounts, always acquire locks in ascending account_id order to prevent deadlocks.
3. **Sub-account sharding**: Split the hot account into N sub-accounts, hash-distribute the postings, and sum at read time.
-- Fixed lock ordering example: always smaller account_id first
SELECT * FROM account_balances
WHERE account_id IN (1001, 2002)
ORDER BY account_id
FOR UPDATE;
[Sub-account sharding]
Fee income account (logical)
│
├── fee-income-00 ◀─ hash(txn_id) % 4 == 0
├── fee-income-01 ◀─ hash(txn_id) % 4 == 1
├── fee-income-02 ◀─ hash(txn_id) % 4 == 2
└── fee-income-03 ◀─ hash(txn_id) % 4 == 3
Read: SUM of the 4 sub-account balances
EOD: optionally consolidate sub-accounts into the main account
The price of sharding is that you cannot obtain the exact real-time balance of the logical account with a single-row read. It is hard to apply to accounts that need limit checks and is mostly used for aggregate-style internal accounts.
Idempotency and Duplicate Prevention — Transaction ID Design
Retries are everyday life in financial systems. When a client retries after a network timeout, a transfer must not execute twice. The key is to **enforce the client-generated transaction ID (idempotency key) with a unique constraint on the journal**.
-- The UNIQUE constraint on txn_id blocks duplicate booking at the DB level
INSERT INTO journal_entries (txn_id, txn_type, business_date, created_by)
VALUES ('3fa85f64-5717-4562-b3fc-2c963f66afa6', 'TRANSFER', CURRENT_DATE, 'api')
ON CONFLICT (txn_id) DO NOTHING
RETURNING entry_id;
-- Empty RETURNING means the transaction was already processed
-- → look up and return the original result
Things to check when designing transaction IDs.
- **Who generates it**: The caller (channel system, external institution) must generate it so the ID is identical across retries. If the server generates it, idempotency breaks.
- **Scope and lifetime**: With a combination of institution code, date, and sequence number (common in financial messaging), check whether IDs are reused when the date rolls over and whether the unique constraint must include the date.
- **Response replay**: A duplicate request should receive **the same response as the first processing**, not an error. Store the processing result so it can be looked up by transaction ID.
Consistency Verification — Trust, but Verify
Ledger invariants are not maintained by code alone. Deployment accidents, manual data fixes, and bugs can break consistency at any time, so verification must run continuously.
**1. Per-entry balance check** — Confirm debit totals equal credit totals in every entry.
SELECT entry_id,
SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS debit_sum,
SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS credit_sum
FROM postings
WHERE business_date = CURRENT_DATE
GROUP BY entry_id
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END)
<> SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END);
-- Zero rows means healthy
**2. Trial balance check** — Confirm the grand total of debits equals the grand total of credits across the whole ledger.
**3. Balance table reconciliation** — Compare account_balances values against posting sums per account. Mismatched accounts trigger alerts, and the first step is **root cause investigation**, not automatic correction.
**4. External reconciliation** — For interbank transfers, reconcile against clearing house records; for card acquiring, against card network settlement files. Internal consistency and external consistency are separate problems.
Run the verification batch immediately before and after EOD close, and make it a gate that halts the close when the difference is not zero.
The Distributed Ledger Problem — Double Booking in the Microservices Era
When the ledger service and the payment service are separate microservices, partial failures occur: the payment succeeds but the ledger booking fails. Distributed transactions (2PC) are increasingly avoided even in finance due to availability and operational cost, and the saga pattern with compensating transactions has become the standard approach.
[Transfer saga — happy path and compensation path]
Channel ──▶ Transfer orchestrator
│
├─ 1. Book withdrawal (customer account D / suspense C)
│ success
├─ 2. Request external remittance (clearing house / other bank)
│ │
│ ├─ success ──▶ 3. Clear suspense (suspense D / interbank claim C)
│ │
│ └─ failure ──▶ 3'. Compensate: reverse withdrawal
│ (suspense D / customer account C)
│
└─ Every step is idempotent — retrying the same txn_id
yields the same outcome
Design principles are as follows.
- **Model suspense accounts explicitly.** When the "money in flight" state is expressed as an account, even intermediate saga states keep debits and credits balanced.
- **Compensation means reversal, not deletion.** Failed flows also remain in the record.
- **A timeout is not a failure.** When an external institution times out, the outcome is unknown; keep the transaction in an UNKNOWN state and confirm via reconciliation or status inquiry before deciding to compensate. A classic cause of financial incidents is double processing: treating a timeout as a failure, compensating, and then discovering the original also succeeded.
Integrating with Accounting — From Core Banking to the General Ledger
The customer-level transaction ledger (sub-ledger) and the financial accounting General Ledger (GL) are usually separate. You do not push millions of core banking transactions straight into the GL; instead, an EOD batch produces **aggregated journal entries** grouped by account code, organization, and currency, and posts them to the GL daily.
[Core banking → General Ledger interface]
Core banking ledger (per transaction, millions/day)
│
▼ EOD batch: GROUP BY account code x department x currency
Aggregated journal file (thousands/day)
│
▼ GL interface (validation: balanced entries, mapping exists)
General Ledger (for financial reporting)
The trap here is **missing mappings**. When a new product or transaction type is added without a GL account mapping, the aggregation batch either fails (fortunate) or silently drops the unmapped portion (disastrous). It is safer to make the batch fail when it finds a transaction type without a mapping.
Audit Trail — Who, When, Why
Beyond the transaction itself, ledger data needs the following metadata.
- Recording party: channel (mobile/branch/batch), operator ID, approver ID
- Basis: reference to the original transaction, reversal reason, related message ID
- Time: system time, value date, and channel request time when needed
Electronic financial transaction records carry multi-year retention obligations under regulations such as Korea's Regulation on Supervision of Electronic Financial Transactions (one year, five years, and so on depending on the type). Decide retention design together with the compliance team, and consider partitioning and archiving strategies from the start.
Common Bug Patterns
These are ledger bug patterns encountered frequently in the field.
1. **Using floating point**: Interest computed in double accumulates error until EOD is off by one unit. Use decimal-precision types end to end.
2. **Scattered sign rules**: The rule that assets increase on the debit side is hardcoded in both service A and batch B, and only one gets fixed. Centralize sign rules in a single module.
3. **New transaction ID per retry**: The client SDK generates a fresh UUID on every retry, neutralizing idempotency.
4. **Reversal of a reversal**: The same entry gets reversed twice and the balance tips the other way. Block with a unique constraint on reversal existence.
5. **Backdated postings after close**: Postings land on an already-closed business day, so reports and ledger disagree. You need a close guard on business_date.
6. **Time zone issues**: UTC and local time mix, and value dates around midnight come out wrong. Centralize value-date determination logic.
Design Checklist
- [ ] Are amount types decimal-precise (NUMERIC/DECIMAL or smallest-unit integers)?
- [ ] Are postings append-only with UPDATE/DELETE blocked by DB privileges?
- [ ] Is the balanced-entry invariant verified both in the application and in batch?
- [ ] Are transaction IDs generated by the caller and deduplicated via a unique constraint?
- [ ] Do duplicate requests replay the original response?
- [ ] Are corrections done only via reversal entries, with double reversal blocked?
- [ ] Are value date and system time separated, with backdating into closed days blocked?
- [ ] Have hot accounts been identified and synchronous balance updates minimized?
- [ ] Is a reconciliation batch running between the balance table and posting sums?
- [ ] Are intermediate states of distributed transactions modeled with suspense accounts?
- [ ] Are timeouts treated as unknown outcomes confirmed via reconciliation?
- [ ] Does the batch fail when GL mappings are missing?
- [ ] Are audit metadata (party, basis, time) and retention periods defined?
Closing Thoughts
The essence of ledger design is not flashy technology but **relentless enforcement of invariants**. The centuries-old invariant of double-entry bookkeeping, the simple rule of append-only storage, and idempotency as the fundamental skill of distributed systems — enforce these three in code, schema, and batch verification, and consistency down to the last cent will hold. Conversely, postpone any one of those layers with a casual "later", and the cost will inevitably return as an EOD incident in the small hours.
In the next article, we cover the batch and end-of-day (EOD) architecture that runs on top of this ledger.
References
- ISO 20022 official site: https://www.iso20022.org/
- BIS (Bank for International Settlements): https://www.bis.org/
- Basel Committee on Banking Supervision (BCBS): https://www.bis.org/bcbs/
- SWIFT standards: https://www.swift.com/standards
- Korea Financial Telecommunications and Clearings Institute: https://www.kftc.or.kr/
- Financial Supervisory Service (Korea): https://www.fss.or.kr/
- Martin Fowler — Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html
- Martin Fowler — Accounting Patterns: https://martinfowler.com/eaaDev/AccountingNarrative.html
- PostgreSQL documentation (Numeric Types): https://www.postgresql.org/docs/current/datatype-numeric.html
- PostgreSQL documentation (Explicit Locking): https://www.postgresql.org/docs/current/explicit-locking.html
- IFRS Foundation: https://www.ifrs.org/
현재 단락 (1/270)
The first thing that surprises developers new to banking systems is this fact: if the balance is off...