How to Build a Double-Entry Ledger System for a Fintech Product
A practical guide for engineers on designing and implementing a double-entry ledger system that stays accurate, auditable, and scalable in production.
Building money tracking into your product feels simple until it is not. A balance column on a user table works for a prototype. It falls apart the moment you need to run a reconciliation report, handle a dispute, or show an auditor the history of a single cent. By then, migrating is painful and expensive.
A double-entry ledger is the right foundation from the start. This guide walks through what it is, how to design one, and the decisions that actually matter in production.
What a Double-Entry Ledger System Is
In double-entry accounting, every financial event produces at least two entries: a debit to one account and a credit to another. The sum of all debits always equals the sum of all credits. That invariant is the entire point — it makes silent data corruption detectable.
A single-entry approach (storing a balance as a mutable number) has no such invariant. You cannot tell whether a balance of $1,000 is correct without trusting every write that ever touched it. Double-entry lets you compute any balance at any point in time from an append-only log, then verify it against itself.
Core Data Model
Start with three tables: accounts, transactions, and entries.
Accounts represent the entities that hold value — user wallets, platform revenue accounts, fee pools, escrow accounts. Give each account a type (asset, liability, equity, revenue, expense) because the rules for normal balances differ.
Transactions are the top-level financial events. A single transaction might represent a payment, a fee charge, or a refund. Transactions carry metadata: an idempotency key, a timestamp, a status, and a reference to the business event that caused them.
Entries are the individual debit/credit lines within a transaction. Every transaction must have at least two entries, and the sum of all entry amounts within a transaction must be zero (using signed amounts where debits are positive and credits are negative, or using an explicit direction column — pick one convention and enforce it everywhere).
A database constraint that enforces this sum-to-zero rule is not optional. Put it at the database level, not just in application code.
How to Build a Double-Entry Ledger System: Step by Step
1. Design your chart of accounts first
Before writing a line of code, map out every account your system needs. Group them by type. A payment platform typically needs: user asset accounts, a settlement liability account, a fee revenue account, and a suspense account for transactions in flight. Skipping this design step produces a ledger that cannot generate a valid balance sheet.
2. Make entries immutable
Never update or delete an entry. If a transaction needs to be reversed, create a new transaction that mirrors the original with opposite signs. This preserves the full history and makes audits straightforward — every entry that ever existed is still in the database.
3. Use idempotency keys on every transaction
Distributed systems have retries. A payment processor will send the same webhook twice. Your ledger must be able to receive the same event twice and produce exactly one set of entries. Store idempotency keys with a unique constraint and check before inserting.
4. Compute balances, do not store them
Resist the temptation to cache a running balance on the account row. Computing balance from entries is the correct approach. If query performance becomes a problem, use a materialized view or a balance snapshot table that is rebuilt from entries — never a mutable column that is updated in the same transaction as the entry insert without a lock.
5. Lock accounts at the right granularity
Hot accounts (platform fee accounts, shared escrow) get many concurrent writes. Use row-level locking or an optimistic concurrency strategy (compare-and-swap on a version column) to prevent race conditions. This is one of the most common production failures in homegrown ledgers.
6. Add bi-temporality for late-arriving events
Financial systems receive corrections and late transactions. Bi-temporal design tracks two timestamps: when an event actually occurred and when it was recorded in the system. This lets you reconstruct the ledger state as it existed at any past point, which auditors and regulators require.
What to Validate in CI
Run a suite of invariant checks in your test suite and as a periodic job in production:
- Every transaction has at least two entries
- Every transaction sums to zero
- Every account balance computed from entries matches the snapshot (if you maintain one)
- No entry has been updated or deleted since creation
Running these checks on a schedule catches drift caused by bugs that slipped through.
When to Use a Ledger-as-a-Service
Building a ledger correctly takes time. Products like Formance, Modern Treasury, or Tigerbeetle provide the data model and concurrency guarantees out of the box. The trade-off is vendor dependency and cost. If money movement is core to your product and you have the engineering capacity, building in-house with a well-tested schema and strict invariants is defensible. If you are moving fast and reconciliation is a secondary concern, a managed ledger buys you time.
The decision is a product question as much as an engineering question: how much of your competitive advantage lives in the financial infrastructure layer?
If you are building a fintech product and want a second opinion on your ledger architecture before you commit to it, talk to Clixo. We have built custom ledger systems for payment platforms and can review your data model in a single session.