Building a Robust Bank Ledger in Golang with PostgreSQL: A Double-Entry Accounting Guide

Building a Robust Bank Ledger in Golang with PostgreSQL: A Double-Entry Accounting Guide

Building a Robust Bank Ledger in Golang with PostgreSQL: A Double-Entry Accounting Guide

Imagine you’re building the backend for a million-dollar fintech app. You store each user’s balance as a single number in the database. It feels simple: just update the number when money moves.

But with one line of code like UPDATE accounts SET balance = balance – 100, you’ve created a system that can silently lose millions. A server crash, a race condition, or a clever attack, and suddenly money vanishes or appears out of thin air.

There’s no audit trail, no way to know what happened, and no way to prove it didn’t happen on purpose.

This isn’t just a theoretical risk. It’s a trap that’s caught even experienced developers. The world’s most trusted financial systems avoid it by using double-entry accounting. Every transaction creates two records: a debit on one account, a credit on another. This lets you reconstruct every cent from history, catch inconsistencies, and audit every transaction.

There are no deletes, and no silent updates. Just an append-only trail that makes fraud and bugs much harder to hide.

In this guide, you’ll build a robust backend in Go and PostgreSQL, using patterns inspired by real fintech companies. You’ll learn how to design a double-entry ledger, generate type-safe SQL with sqlc, and write transactions that are safe even under heavy load.

By the end, you’ll understand why these patterns matter – and how to use them to build software you can trust with real money.

Prerequisites and Project Overview

Before you dive in, make sure you have the following installed:

  • Go 1.23 or newer
  • Docker and Docker Compose
  • golang-migrate CLI: go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest
  • sqlc CLI: go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

You’ll also need a basic understanding of PostgreSQL and REST APIs to follow along. If you’ve built a CRUD app before, you’re ready for this.

The Double-Entry Foundation: How Every Penny is Accounted For

Let’s get to the heart of what makes this system bulletproof: double-entry accounting. Every operation – a deposit, withdrawal, or transfer – creates two entries that always balance. This is the secret sauce that keeps banks, payment apps, and even crypto exchanges from losing track of money.

Picture a simple deposit of $1,000:

AccountDebitCredit
User Account1,000
Settlement Account1,000

Total debits always equal total credits. This is the fundamental rule. Every single operation in this system produces exactly this structure, with no exceptions.

Now picture a $200 transfer from User A to User B. Notice there are four entries, not two – both sides of both accounts are recorded:

AccountDebitCreditDescription
User A200Transfer to User B
User B200Transfer from User A

Both entries share the same transaction_id, so you can always retrieve the complete picture of what happened with a single query. There’s no guessing and no reconstructing, as the ledger tells the full story.

Why the Settlement Account Goes Negative

This trips up newcomers, so it’s worth explaining explicitly. When a user deposits $1,000, the settlement account is debited $1,000. After several user deposits, the settlement balance will be negative. That’s correct and expected: it represents the total amount of real-world money currently held inside the system on behalf of users. The invariant is:

SUM(all user account balances) + settlement balance = 0

If that ever doesn’t hold, something is broken.

Enforcing the Rules in the Database

The database itself enforces these rules, not just the application code. Here’s the core of the entries table migration:

CREATE TABLE IF NOT EXISTS entries (

id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT,

debit NUMERIC(19,4) NOT NULL DEFAULT 0.0000 CHECK (debit >= 0),

credit NUMERIC(19,4) NOT NULL DEFAULT 0.0000 CHECK (credit >= 0),

transaction_id UUID NOT NULL,

operation_type operation_type NOT NULL,

description TEXT,

created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT check_single_side CHECK (

(debit > 0 AND credit = 0) OR (debit = 0 AND credit > 0)

)
);

Let’s break down why each piece matters:

  • Single-sided entries are impossible. The check_single_side constraint means every entry must be either a debit or a credit, never both. If you try to insert an invalid row, the database rejects it – there’s no way around it.
  • Every transaction is linked. Both the debit and credit entries share the same transaction_id (a UUID). This lets you fetch both sides of any operation instantly, making audits and debugging straightforward.

By following this guide, you’ll learn how to build a robust bank ledger in Golang with PostgreSQL, using double-entry accounting to ensure every penny is accounted for.

Project Resources:

  • Here’s the project repository: https://github.com/PaulBabatuyi/double-entry-bank-Go
  • And here’s the front-end repository: https://github.com/PaulBabatuyi/double-entry-bank
  • You can find the live frontend here: https://golangbank.app
  • You can find the live Swagger back-end API here: https://golangbank.app/swagger