Shamil

Software Engineer | Berlin, Germany


I’ve been a backend developer for about six years now. Over that time, I’ve tackled very small CRUD apps to major systems like CDNs. However, it wasn’t until I joined a online stock-broker that I realized how little I really understood about accounting principles, particularly double-entry bookkeeping.

Double-entry bookkeeping looks really intimidating from the outside, especially if you’ve spent most of your career just updating a single balance field in a table whenever money changes hands. But the moment you step into fintech, you quickly realize that naive approaches to tracking money can get out of hand fast. I recently found myself in exactly that situation, and this blog post is my attempt to break things down so developers (i.e future me) can more easily wrap their heads around double-entry systems.

The goal of this article is to share an engineer’s perspective on how double-entry bookkeeping can help us track money with more confidence and fewer headaches. I’ll keep it casual and example-focused, with enough accounting context (as I understand them) to give us a solid foundation.

The Naive Approach to Tracking Money

If you’re like me, you may have started tracking money with a single table that looks a bit like this:

user_id balance
1 100.00
5 50.25

At first glance, it seems straightforward:

  • A user’s balance column is updated whenever they deposit or withdraw funds.
  • If Mark sends $10 to Steve, the app logic just decreases Mark’s balance by 10 and increases Steve’s by 10.

Done. Everything works fine… until it doesn’t.

Say our system encounters a bug or a race condition, or even something more mundane like partial transaction updates that fail halfway. Suddenly, Mark’s balance changes from $100 to $90, but Steve still sees $50.25. Where did the $10 go? Now we have mismatched balances and no easy way to trace or fix them.

Sure, we might add an “audit trail” or log file. But if that audit trail isn’t tightly integrated, we’d still be clueless about the root cause. Or we might be forced to do some manual reconciliation. As your application grows, this simple approach can get messy, fast.

Enter Double-Entry Bookkeeping

This is exactly the scenario that double-entry bookkeeping helps address. It forces every money flow in our system to be accounted for in a structured way, so that totals on both sides match exactly. The solution isn’t new or anything, in fact, it’s been around for centuries.

At its core, double-entry bookkeeping relies on a simple but powerful idea:

Every financial transaction involves at least two accounts, and every transaction affects these accounts with equal and opposite entries (a debit and a credit).

Let me break this down further.

Debits, Credits, and Account Types

If you’re new to accounting terminology, just like I was, you might initially find the terms “debit” and “credit” a bit confusing. In everyday life, we usually think of “debit” as money leaving our account and “credit” as money coming in. Well, imagine my surprise when I learned accounting doesn’t always work that way!

Instead, accounting has this concept of Debit Normal and Credit Normal accounts. Simply put, every account has a “normal” balance side, meaning the side (debit or credit) that increases its balance.

  • Debit Normal accounts represents funds we own or uses of money. They increase when we debit them and decrease when we credit them. Some examples are:

    • Asset accounts: Think of assets as things you own, like cash, user wallets, or physical property. When you add money to a cash account, you debit it. When money leaves, you credit it.
    • Expense accounts: Expenses (like transaction costs or refunds you pay out) also increase on the debit side.
  • Credit Normal accounts represents funds we owe or sources of money. They behave in reverse - they increase when credited and decrease when debited. Sources of money includes bank loans, investors’ capital, revenues or profits. Some examples of credit normal accounts are:

    • Liability accounts: Money you owe to someone else (like customer deposits or debts) increases on the credit side. For example, the user balance accounts are credit normal accounts, because they represent funds we are holding on behalf of our users. Because users should be able to withdraw them at any time, they are funds we “owe”- therefore liabilities.
    • Equity accounts: Owner’s equity or investment balances also increase with credits.
    • Revenue accounts: Money you earn from your services, like transaction fees, increases when credited. Once we collect the revenuw, those funds are technically now available for our “use” - and as such, they are sources of funds.

Meet “Swiftly” - Our Payments App

Let’s put everything from above into practice by designing a database schema for our imaginary payment app - let’s call it Swiftly (yes, I am boring).

Swiftly is similar to Venmo. Users can:

  • Deposit money from their bank account or credit card into a Swiftly wallet.
  • Transfer money to other users instantly.
  • Withdraw money from their Swiftly wallet back to their bank account.
  • For deposits and withdrawals, a 0.5% of the amount is charged as the service fee.

Some of the other requirements are:

  • Every user has their own wallet (an account).
  • The app itself manages some system-level accounts, like:
    • Swiftly Cash Account: To hold money users deposit before it’s withdrawn or transferred elsewhere.
    • Revenue Account: To store the fees collected from deposits and withdrawals.
  • Every transaction must follow double-entry rules (debits must always equal credits).

Before we dive into SQL, let’s clarify some key entities in our system:

  • Ledger: A category or grouping for related accounts (like “Assets,” “Liabilities,” “Revenue,” “Expenses”).
  • Account: Individual sub-ledgers that track specific sources or destinations of money. For instance:
    • User wallets.
    • Swiftly’s bank account
    • Revenue from transaction fees
  • Transaction: Represents an event that moves money around (like deposits, withdrawals, transfers).
  • Posting: Individual debit or credit entries tied to a transaction, showing exactly where money comes from and where it goes.

Now, let’s bring all these concepts into actual database tables.

Designing the Database Schema

I’ll be using PostgreSQL as our example database, but these principles apply broadly to any relational database.

  1. The ledgers Table
CREATE TABLE ledgers (
  id UUID PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  ledger_type VARCHAR(50) NOT NULL, -- ASSET, LIABILITY, REVENUE, EXPENSE, EQUITY
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Common examples of ledgers:

  • Assets: User wallets, cash holdings
  • Revenue: Fees collected
  • Liabilities: Money owed to users
  1. The accounts Table

Accounts represent specific storage places for money. Each user gets a wallet (an account), and we also have system accounts.

CREATE TABLE accounts (
  id UUID PRIMARY KEY,
  ledger_id UUID REFERENCES ledgers(id),
  user_id UUID NULL, -- user-owned accounts have a user_id
  name VARCHAR(100) NOT NULL,
  currency VARCHAR(3) DEFAULT 'EUR',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Examples:

  • User wallet: “Mark’s Wallet” (linked to Mark’s user_id)
  • Swiftly Cash: System account under the “Assets” ledger
  • Swiftly Revenue: System account under the “Revenue” ledger
  1. The transactions Table

This is the high-level description of each money movement. Each transaction is a single logical event, like a deposit or withdrawal, and has a unique external ref_id for tracking.

CREATE TABLE transactions (
  id UUID PRIMARY KEY,
  ref_id VARCHAR(100) UNIQUE NOT NULL,
  transaction_type VARCHAR(50), -- DEPOSIT, WITHDRAWAL, TRANSFER
  user_id UUID NULL, -- the user who initiated it
  description TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Examples:

  • “Deposit of $100 from Mark’s bank account”
  • “Transfer of $20 from Steve to Bill”
  1. The postings Table (The Heart of Double-Entry)

Each transaction has multiple postings, to clearly show the money’s source and destination, always in balance.

CREATE TABLE postings (
  id UUID PRIMARY KEY,
  transaction_id UUID REFERENCES transactions(id),
  account_id UUID REFERENCES accounts(id),
  side VARCHAR(6) CHECK (side IN ('DEBIT', 'CREDIT')),
  amount NUMERIC(18,2) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Why use explicit “Debit” and “Credit” sides instead of positive/negative numbers?

  • Clearly shows intent (no ambiguity).
  • Aligns closely with standard accounting practices.
  • Makes debugging and auditing straightforward.

How Does Everything Fit Together?

Now that we have the tables defined, let’s see how all these tables fit together by introducing multiple users and tracking real transactions among them. Typically, a system like ours handles three main transaction types that affect our ledger:

  • Deposits: A user adds cash into their account balance. We charge a service fee for the deposit and add the remaining amount to user’s balance. We also record this fee as part of our revenue.
  • Transfers: A user sends money from their balance to another user’s balance.
  • Withdrawals: A user withdraws money from their account balance. We charge a service fee for withdrawals (deducted from the user’s balance), and record this fee as part of our revenue.

Initial Setup: Ledgers and Accounts

First, we clearly define the ledgers and accounts participating in this transaction. We can assume that we have the following general ledgers available in our ledgers table:

id name ledger_type created_at
ledger-id-1 Assets ASSET 2022-01-06 11:17:37.893461+00
ledger-id-2 Liabilites LIABILITY 2022-01-06 11:17:37.893461+00
ledger-id-3 Revenue REVENUE 2022-01-06 16:42:29.395302+00

Here

  • Assets Ledger: Contains all accounts holding things of value, like cash available to Swiftly.
  • Liabilities Ledger: Contains all user wallet accounts, i.e funds we are holding on behalf of the user.
  • Revenue Ledger: Contains accounts used to track income earned by Swiftly.

In a real-world application, there might be many more ledgers based on fine-grained types than the ones we used above. However, I want to keep things simple for the sake of this example.

Now, every account (i.e. a sub-ledger) in our system will belong to one of these above ledgers.

id ledger_id user_id name currency created_at
uuid-1 ledger-id-1 NULL Swiftly Cash EUR 2022-01-12 07:36:13.672810+00
uuid-3 ledger-id-3 NULL Swiftly Revenue EUR 2022-01-12 07:49:46.248101+00
uuid-4 ledger-id-2 mark-uuid Mark’s Wallet EUR 2023-10-02 10:51:28.421723+000
uuid-5 ledger-id-2 steve-uuid Steve’s Wallet EUR 2023-10-02 10:51:28.421723+000
uuid-6 ledger-id-2 bill-uuid Bill’s Wallet EUR 2023-10-02 10:51:28.421723+000

Here

  • Swiftly Cash: Represents money physically held by Swiftly. We can think of it like a bank account owned by Swiftly and the cash available in that account.
  • Swiftly Revenue: Stores income from transaction fees collected by Swiftly.
  • User Wallets: Represents money user owns (for example, money available to Mark for his spendings). We create an account for each user when they sign-up to our service.

Transaction 1: Bill deposits €100 into his wallet (with 0.5% fee)

Let’s assume Bill decides to top up his Swiftly wallet from his bank.

id ref_id transaction_type user_id description created_at
txn-001 TXN1001 DEPOSIT bill-uuid deposits €100 into wallet 2024-03-13 00:34:28.421723+00

When Bill deposits €100 into Swiftly from his external bank, the money first lands in Swiftly’s Cash account, causing its balance to increase. Swiftly is essentially holding this money now, so it gets debited (remember, cash accounts are debit normal).

However, even though Swiftly physically holds the cash, it actually belongs to Bill; so Bill’s wallet balance should also increase. Since Bill now has €99.50 available to use within the app (after Swiftly takes its 0.5% fee of €0.50), we credit Bill’s wallet by €99.50.

At the same time, Swiftly earned €0.50 from this deposit, so the Swiftly Revenue account is credited by €0.50, indicating an increase in earned income.

The effective postings records are thus:

id transaction_id account side amount created_at
p_001 txn-001 Swiftly Cash DEBIT 100.00 2024-03-13 00:34:28.629891+00
p_002 txn-001 Mark’s Wallet CREDIT 99.50 2024-03-13 00:34:28.720233+00
p_003 txn-001 Swiftly Revenue CREDIT 0.50 2024-03-13 00:34:28.812177+00

Here’s what the postings explicitly represent:

  • DEBIT Swiftly Cash (€100): Swiftly’s available cash increases (now holding Bill’s deposit).
  • CREDIT Bill’s Wallet (€99.50): Bill’s available balance increases (money he can now spend).
  • CREDIT Swiftly Revenue (€0.50): Swiftly earns €0.50 as revenue.

Transaction 2: Mark sends €10 to Steve

Assume Mark wants to send €10 to Steve directly within Swiftly. Here’s how this transaction is captured.

id ref_id transaction_type user_id description created_at
txn-002 TXN1002 TRANSFER mark-uuid Mark sends €10 to Steve 2024-03-13 00:34:28.421723+00

The postings table would look like this:

id transaction_id account-id side amount created_at
p_004 txn-002 Mark’s Wallet DEBIT 10.00 2024-03-13 00:34:28.629891+00
p_005 txn-002 Steve’s Wallet CREDIT 10.00 2024-03-13 00:34:28.720233+00

Remember that the accounts involved here are both credit normal accounts.

Transaction 3: Mark withdraws €30 to his bank account (0.5% fee applies)

When Mark decides to withdraw €30 from his Swiftly wallet to his external bank

id ref_id transaction_type user_id description created_at
txn-003 TXN1003 WITHDRAWAL mark-uuid withdraw €30 into wallet 2024-03-13 00:34:28.421723+00

a few important things happen simultaneously:

  • Mark’s wallet balance decreases: He is moving €30 out of his wallet, so we need to reduce the balance in his account. Since this is a credit normal account, so we create a debit entry.
  • Swiftly transfers money out: Swiftly now needs to pay Mark externally, reducing its cash holdings by €29.85 (the withdrawal amount minus a 0.5% service fee). Since the cash account is debit normal, this decrease is shown as a CREDIT of €29.85.
  • Swiftly earns revenue from the withdrawal: The 0.5% fee charged (€0.15) is Swiftly’s earned revenue. Revenue is credit normal, therefore it increases via a CREDIT to the Swiftly Revenue account.
id transaction_id account side amount created_at
p_006 txn-003 Mark’s Wallet DEBIT 30.00 2024-03-13 00:34:28.720233+00
p_007 txn-003 Swiftly Cash CREDIT 29.85 2024-03-13 00:34:28.629891+00
p_008 txn-003 Swiftly Revenue CREDIT 0.15 2024-03-13 00:34:28.812177+00

Each of the example transactions above involves multiple movements of money - user balances changing, Swiftly holding cash, and revenue earned through fees. Without double-entry, we’d have to track these separately and somehow connect them later, risking mistakes and inconsistencies.

But with double-entry bookkeeping, we record each transaction as a single, self-contained event with multiple postings. Every debit perfectly matches every credit, ensuring money flowing in equals money flowing out. This structure makes our system transparent, reliable, and easy to audit.

Final Thoughts

Learning double-entry bookkeeping as an engineer has been eye-opening for me, and hopefully, this post gave you a good sense of how you might implement these ideas practically.

Although double-entry bookkeeping seem complicated compared to simply updating a single balance field, but as we’ve seen, the added complexity pays off with unmatched traceability and reliability. Use of explicit debit and credit postings, clear ledgers, and structured transactions makes our app audit-friendly from the ground up. While the initial effort is slightly higher, the long-term benefits make it unquestionably worth it.

Happy building!