Ledger app architecture
Ledger app architecture: the decisions that matter
The architecture of a ledger app comes down to 3 choices: double-entry immutable journal as the source of truth, integer minor units with explicit currency and strong consistency via row locks / SERIALIZABLE, idempotency keys on every money API. Each is explained below with the alternatives that were rejected and what you accept by choosing it.
Last checked
Double-entry immutable journal as the source of truth
Situation: A balance column that is updated in place cannot explain itself, cannot be audited and is easy to corrupt with a race or a bug.
Choice: Every movement is a journal entry with balanced debit and credit lines. Balances are a cache of the journal. Entries are append-only; mistakes are corrected by reversing entries. The database enforces balance with a deferred constraint trigger.
- Rejected: Single balance column updated by application code: simple, impossible to audit, and loses money on bugs.
- Rejected: Event-sourced ledger with a separate store: powerful but another platform to run before it is needed.
- Rejected: Single-entry transaction list: cannot prove the books balance.
- Consequence: Reconciliation and audits are tractable because every cent has a counter-entry.
- Consequence: Writes cost more than a counter update (entry + 2 or more lines) but stay one transaction.
- Consequence: Hot system accounts need special handling (derived balances).
Integer minor units with explicit currency
Situation: Floating point and implicit currency cause rounding drift and cross-currency bugs that only surface in production.
Choice: Store amounts as bigint minor units next to an ISO-4217 code; arithmetic goes through a Money type that refuses mixed currencies; allocation/rounding happens in one named function with a documented rule.
- Rejected: numeric/decimal columns: exact but invites unit confusion and slower application math.
- Rejected: float/double: unacceptable for money.
- Consequence: JSON payloads carry integer strings to avoid 53-bit precision loss in JavaScript.
- Consequence: Currencies with different exponents need a currency table in the Money helper.
Strong consistency via row locks / SERIALIZABLE, idempotency keys on every money API
Situation: Concurrent transfers from the same wallet and client retries are the two ways balances go wrong.
Choice: Posting locks accounts in ascending id order inside one transaction (or runs SERIALIZABLE with bounded retries), and every mutating money endpoint requires an Idempotency-Key backed by a unique constraint.
- Rejected: Optimistic concurrency with version column and client retry: workable but pushes complexity to every caller.
- Rejected: Eventual consistency / async posting: allows overdrafts and double-spends.
- Consequence: Throughput per hot account is bounded by lock duration, so transactions stay short and external calls never happen inside them.
- Consequence: Clients must generate and persist idempotency keys.
How the data is indexed
Indexes are chosen from the questions the app asks most often. Each line below names a table and the question its main index answers.
- ledger_accounts: unique (owner_id, purpose, currency) — one wallet per owner, purpose and currency.
- journal_entries: unique (initiator_id, idempotency_key) — a retried request can never post twice.
- journal_lines: (account_id, created_at DESC, id DESC) — account activity feed with keyset pagination.
- holds: (account_id) where status = 'active' — available balance = balance minus active holds.
- idempotency_keys: unique (scope, idem_key) — replay lookup, scope is user id + route.
- transfers: unique (initiator_id, idempotency_key) — one transfer per client request.
- processor_events: unique (provider, event_id) — webhook replay and out-of-order duplicates are no-ops.
- velocity_counters: unique (user_id, window_kind, window_start) — one counter row per window, atomic upsert in the posting transaction.
What Forge64 provides underneath
Forge64 matches your description to one of 12 reference architectures, writes the plan and data model into the project, generates the app, and runs a quality gate over the code before you see it. Each app gets its own Postgres database and sign-in with no per-user fee. Hosting on Forge Cloud and Forge Payments are rolling out.
This is a reference design for the category, not a promise that every item is generated today. Built-ins such as email, background jobs, file uploads and search are coming soon, so anything here that needs them is something you add yourself for now.
FAQ
- What is the most important architecture decision for a ledger app?
- Double-entry immutable journal as the source of truth. Every movement is a journal entry with balanced debit and credit lines. Balances are a cache of the journal. Entries are append-only; mistakes are corrected by reversing entries. The database enforces balance with a deferred constraint trigger.
- Do I have to make these decisions myself?
- No. Forge AI plans them from your description and writes the decisions into the project as architecture records you can read and change.
- What do the alternatives cost me?
- For example, for "Integer minor units with explicit currency": numeric/decimal columns: exact but invites unit confusion and slower application math. float/double: unacceptable for money.
Build your app with Forge64
One prompt to a running app with auth, database, hosting and payments. Start free, no credit card required.
Start building for free