<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Paul Babatuyi - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Paul Babatuyi - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 10 May 2026 14:14:34 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/paulbabatuyi/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Bank Ledger in Golang with PostgreSQL using the Double-Entry Accounting Principle. ]]>
                </title>
                <description>
                    <![CDATA[ The Hidden Bugs in How Most Developers Store Money 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 simp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-bank-ledger-in-go-with-postgresql-using-the-double-entry-accounting-principle/</link>
                <guid isPermaLink="false">69c4173d10e664c5dac8cea1</guid>
                
                    <category>
                        <![CDATA[ Go Language ]]>
                    </category>
                
                    <category>
                        <![CDATA[ golang ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ banking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ accounting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ double entry ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Paul Babatuyi ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 17:11:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/faea1d4c-5319-4746-96b0-315f37017e26.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <h2 id="heading-the-hidden-bugs-in-how-most-developers-store-money">The Hidden Bugs in How Most Developers Store Money</h2>
<p>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.</p>
<p>But with one line of code like <code>UPDATE accounts SET balance = balance - 100</code>, 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.</p>
<p>There's no audit trail, no way to know what happened, and no way to prove it didn't happen on purpose.</p>
<p>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.</p>
<p>There are no deletes, and no silent updates. Just an append-only trail that makes fraud and bugs much harder to hide.</p>
<p>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.</p>
<p>By the end, you'll understand why these patterns matter –&nbsp;and how to use them to build software you can trust with real money.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites-and-project-overview">Prerequisites and Project Overview</a></p>
</li>
<li><p><a href="#heading-the-double-entry-foundation-how-every-penny-is-accounted-for">The Double-Entry Foundation</a></p>
</li>
<li><p><a href="#heading-type-safe-sql-with-sqlc-no-more-surprises">Type-Safe SQL with sqlc</a></p>
</li>
<li><p><a href="#heading-the-store-layer-transactions-and-automatic-retries">The Store Layer: Transactions and Retries</a></p>
</li>
<li><p><a href="#heading-the-service-layer-where-business-logic-meets-double-entry">The Service Layer: Business Logic</a></p>
</li>
<li><p><a href="#heading-the-api-layer-secure-predictable-and-boring-by-design">The API Layer</a></p>
</li>
<li><p><a href="#heading-running-it-locally-your-first-end-to-end-test">Running It Locally</a></p>
</li>
<li><p><a href="#heading-testing-prove-the-system-works">Testing: Prove the System Works</a></p>
</li>
<li><p><a href="#heading-deployment-engineering-decisions-that-matter-in-production">Deployment</a></p>
</li>
<li><p><a href="#heading-conclusion-building-for-the-real-world">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-project-resources">Project Resources:</h3>
<p>Here's the project repository: <a href="https://github.com/PaulBabatuyi/double-entry-bank-Go">https://github.com/PaulBabatuyi/double-entry-bank-Go</a></p>
<p>And here's the front-end repository: <a href="https://github.com/PaulBabatuyi/double-entry-bankhttps://github.com/PaulBabatuyi/double-entry-bank">https://github.com/PaulBabatuyi/double-entry-bank</a></p>
<p>You can find the live frontend here: <a href="https://golangbank.app">https://golangbank.app</a></p>
<img src="https://cdn.hashnode.com/uploads/covers/6968db1b0578d1643036e600/2240e617-5a6d-4742-995f-6ecb8fecb56e.png" alt="Double-entry frontend transaction" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>You can find the live Swagger back-end API here: <a href="https://golangbank.app/swagger">https://golangbank.app/swagger</a></p>
<img src="https://cdn.hashnode.com/uploads/covers/6968db1b0578d1643036e600/3a6c1e02-5ceb-43e4-86a3-0530735b79cb.png" alt="Backend API endpoints (Swagger)" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-prerequisites-and-project-overview">Prerequisites and Project Overview</h2>
<p>Before you dive in, make sure you have the following installed:</p>
<ul>
<li><p>Go 1.23 or newer</p>
</li>
<li><p>Docker and Docker Compose</p>
</li>
<li><p><code>golang-migrate</code> CLI: <code>go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest</code></p>
</li>
<li><p><code>sqlc</code> CLI: <code>go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest</code></p>
</li>
</ul>
<p>You'll also need a basic understanding of PostgreSQL and REST APIs to follow along.</p>
<p>If you've built a CRUD app before, you're ready for this. The project uses sqlc for type-safe queries, JWT for authentication, and a layered architecture that keeps business logic, persistence, and HTTP handling cleanly separated.</p>
<p>Here's how the project is organized:</p>
<pre><code class="language-plaintext">.
├── cmd/                # Server entrypoint
│   └── main.go
├── internal/
│   ├── api/            # HTTP handlers &amp; middleware
│   ├── db/             # Store layer (transactions, sqlc)
│   └── service/        # Business logic (ledger operations)
├── postgres/
│   ├── migrations/     # SQL migration files
│   └── queries/        # sqlc query files
├── docs/               # Swagger docs
├── Dockerfile, docker-compose.yml, Makefile
└── README.md
</code></pre>
<p>The architecture follows a clear three-layer pattern:</p>
<ul>
<li><p><strong>API Layer</strong>: Handles HTTP requests, authentication, and routing.</p>
</li>
<li><p><strong>Service Layer</strong>: Contains the business logic. This is where double-entry rules are enforced.</p>
</li>
<li><p><strong>Store Layer</strong>: Manages database transactions and persistence.</p>
</li>
</ul>
<p>Every request flows from the handler, through the service, to the store, and finally to PostgreSQL. This separation makes the code easier to test, debug, and extend.</p>
<h3 id="heading-backend-request-flow">Backend Request Flow</h3>
<pre><code class="language-mermaid">graph TD
    A[HTTP Request] --&gt; B[Handler - API Layer]
    B --&gt; C[LedgerService - Business Logic]
    C --&gt; D[Store - Persistence Layer]
    D --&gt; E[(PostgreSQL)]
    E --&gt; D
    D --&gt; C
    C --&gt; B
    B --&gt; F[HTTP Response]
</code></pre>
<h2 id="heading-the-double-entry-foundation-how-every-penny-is-accounted-for">The Double-Entry Foundation: How Every Penny is Accounted For</h2>
<p>Let's get to the heart of what makes this system bulletproof: double-entry accounting. Every operation – a deposit, withdrawal, or transfer&nbsp;– 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.</p>
<p>Picture a simple deposit of $1,000:</p>
<pre><code class="language-plaintext">| Account              | Debit   | Credit  |
|----------------------|---------|---------|
| User Account         |         | 1,000   |
| Settlement Account   | 1,000   |         |
</code></pre>
<p>Total debits always equal total credits. This is the fundamental rule. Every single operation in this system produces exactly this structure, with no exceptions.</p>
<p>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:</p>
<pre><code class="language-plaintext">| Account       | Debit   | Credit  | Description           |
|---------------|---------|---------|-----------------------|
| User A        | 200     |         | Transfer to User B    |
| User B        |         | 200     | Transfer from User A  |
</code></pre>
<p>Both entries share the same <code>transaction_id</code>, 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.</p>
<h3 id="heading-why-the-settlement-account-goes-negative">Why the Settlement Account Goes Negative</h3>
<p>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:</p>
<pre><code class="language-plaintext">SUM(all user account balances) + settlement balance = 0
</code></pre>
<p>If that ever doesn't hold, something is broken.</p>
<h3 id="heading-enforcing-the-rules-in-the-database">Enforcing the Rules in the Database</h3>
<p>The database itself enforces these rules, not just the application code. Here's the core of the <code>entries</code> table migration:</p>
<pre><code class="language-sql">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 &gt;= 0),
    credit NUMERIC(19,4) NOT NULL DEFAULT 0.0000 CHECK (credit &gt;= 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 &gt; 0 AND credit = 0) OR (debit = 0 AND credit &gt; 0)
    )
);
</code></pre>
<p>Let's break down why each piece matters:</p>
<ul>
<li><p><strong>Single-sided entries are impossible.</strong> The <code>check_single_side</code> 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.</p>
</li>
<li><p><strong>Every transaction is linked.</strong> Both the debit and credit entries share the same <code>transaction_id</code> (a UUID). This lets you fetch both sides of any operation instantly, making audits and debugging straightforward.</p>
</li>
<li><p><strong>Operation types are explicit.</strong> The <code>operation_type</code> column is an enum at the database level, so only valid types like <code>deposit</code>, <code>withdrawal</code>, or <code>transfer</code> are allowed. There are no typos and no surprises.</p>
</li>
</ul>
<h3 id="heading-the-settlement-account-the-systems-anchor">The Settlement Account: The System's Anchor</h3>
<p>Every real-world ledger needs a way to represent money entering or leaving the system. That's what the settlement account does. Here's how it's seeded in the database:</p>
<pre><code class="language-sql">INSERT INTO accounts (id, name, balance, currency, is_system)
SELECT gen_random_uuid(), 'Settlement Account', 0.0000, 'USD', TRUE
WHERE NOT EXISTS (
    SELECT 1 FROM accounts WHERE is_system = TRUE AND name = 'Settlement Account'
);
</code></pre>
<p>The settlement account represents the "outside world." When a user deposits money, it comes from the settlement account. When they withdraw, it goes back. Using <code>WHERE NOT EXISTS</code> makes this migration idempotent –&nbsp;that is, safe to run multiple times without creating duplicates.</p>
<h2 id="heading-type-safe-sql-with-sqlc-no-more-surprises">Type-Safe SQL with sqlc: No More Surprises</h2>
<p>In financial systems, you can't afford surprises from your database layer. That's why this project uses sqlc, a tool that turns your SQL queries into type-safe Go code at compile time.</p>
<p>With sqlc, you see exactly what SQL runs, catch mistakes before they hit production, and avoid the "magic" (and hidden bugs) of most ORMs. Every query is explicit, every type is checked, and you get the best of both worlds: raw SQL power with Go's safety.</p>
<h3 id="heading-why-numeric-becomes-string-and-not-float64">Why NUMERIC Becomes String (and Not float64)</h3>
<p>Here's a subtle but critical detail from <code>sqlc.yaml</code>:</p>
<pre><code class="language-yaml">overrides:
    - db_type: "pg_catalog.numeric"
      go_type: "string"
    - column: "entries.debit"
      go_type: "string"
    - column: "entries.credit"
      go_type: "string"
    - column: "accounts.balance"
      go_type: "string"
    - db_type: "operation_type"
      go_type: "string"
</code></pre>
<p><strong>Why string, not float64?</strong> Floating point arithmetic is imprecise. <code>0.1 + 0.2</code> in most programming languages does not equal exactly <code>0.3</code>.</p>
<p>For money, you need exact decimal arithmetic. This project uses <code>shopspring/decimal</code> for all calculations and stores amounts as strings, converting at the service layer boundary. The database column itself is <code>NUMERIC(19,4)</code>, which stores exact decimals – no float rounding ever touches your money.</p>
<h3 id="heading-preventing-race-conditions-locking-with-for-update">Preventing Race Conditions: Locking with FOR UPDATE</h3>
<p>One of the most important queries in the system is <code>GetAccountForUpdate</code>:</p>
<pre><code class="language-sql">SELECT * FROM accounts
WHERE id = $1
LIMIT 1
FOR UPDATE; -- locks row for update, prevents TOCTOU races
</code></pre>
<p>This query uses <code>FOR UPDATE</code> to lock the account row during a transaction. Why? Imagine two requests both see a \(500 balance and both try to withdraw \)400. Without locking, both would succeed, and you'd end up with a negative balance. With <code>FOR UPDATE</code>, the second transaction waits until the first finishes, eliminating this classic race condition.</p>
<h3 id="heading-calculating-the-true-balance-always-trust-the-entries">Calculating the True Balance: Always Trust the Entries</h3>
<p>The real source of truth for any account is the sum of its entries, not the denormalized <code>balance</code> column. Here's the reconciliation query:</p>
<pre><code class="language-sql">SELECT CAST(
    (COALESCE(SUM(credit), 0::NUMERIC) - COALESCE(SUM(debit), 0::NUMERIC))
    AS NUMERIC(19,4)
) AS calculated_balance
FROM entries
WHERE account_id = $1;
</code></pre>
<p>This computes the true balance from the ledger itself. It's how you catch bugs, audit the system, and prove that every penny is accounted for. The <code>balance</code> column on accounts is a denormalized cache for fast reads –&nbsp;and this query is the ground truth that validates it.</p>
<h2 id="heading-the-store-layer-transactions-and-automatic-retries">The Store Layer: Transactions and Automatic Retries</h2>
<p>Every financial operation in this system runs inside a transaction –&nbsp;no exceptions. This is enforced by the <code>ExecTx</code> pattern in the store layer:</p>
<pre><code class="language-go">func (store *Store) ExecTx(ctx context.Context, fn func(q *sqlc.Queries) error) error {
    const maxAttempts = 10
    var lastErr error
    for attempt := 0; attempt &lt; maxAttempts; attempt++ {
        lastErr = store.execTxOnce(ctx, fn)
        if lastErr == nil {
            return nil
        }
        if !isSerializationError(lastErr) {
            return lastErr
        }
        if attempt &lt; maxAttempts-1 {
            if waitErr := sleepWithContext(ctx, retryWait(attempt)); waitErr != nil {
                return waitErr
            }
        }
    }
    return fmt.Errorf("transaction failed after %d attempts due to serialization conflicts: %w", maxAttempts, lastErr)
}
</code></pre>
<h3 id="heading-why-serializable-isolation">Why Serializable Isolation?</h3>
<p>The transaction uses PostgreSQL's strictest isolation level: <code>sql.LevelSerializable</code>. This is like running transactions one at a time, eliminating entire classes of concurrency bugs. If two operations would conflict, PostgreSQL aborts one and returns a serialization error (SQLSTATE 40001).</p>
<h3 id="heading-automatic-retries-handling-real-world-concurrency">Automatic Retries: Handling Real-World Concurrency</h3>
<p>When a serialization error occurs, the code automatically retries with exponential backoff:</p>
<pre><code class="language-go">func retryWait(attempt int) time.Duration {
    base := 50 * time.Millisecond
    for i := 0; i &lt; attempt; i++ {
        base *= 2
        if base &gt;= time.Second {
            return time.Second
        }
    }
    return base
}

func sleepWithContext(ctx context.Context, d time.Duration) error {
    select {
    case &lt;-ctx.Done():
        return ctx.Err()
    case &lt;-time.After(d):
        return nil
    }
}
</code></pre>
<p>The backoff starts at 50ms and doubles each attempt, capping at 1 second. Up to 10 attempts are made. If the client disconnects mid-retry, <code>sleepWithContext</code> detects the cancelled context and returns immediately. This means no wasted resources.</p>
<h2 id="heading-the-service-layer-where-business-logic-meets-double-entry">The Service Layer: Where Business Logic Meets Double-Entry</h2>
<p>The service layer is the heart of the system. Its job is to translate business operations – deposits, withdrawals, transfers – into double-entry journal entries that always balance.</p>
<h3 id="heading-deposit-crediting-the-user-debiting-the-settlement">Deposit: Crediting the User, Debiting the Settlement</h3>
<p>Every deposit creates two entries: a credit to the user's account and a matching debit to the settlement account. Both entries share the same transaction ID.</p>
<pre><code class="language-go">func (s *LedgerService) Deposit(ctx context.Context, accountID uuid.UUID, amountStr string) error {
    amount, err := validatePositiveAmount(amountStr)
    if err != nil {
        return err
    }
    return s.store.ExecTx(ctx, func(q *sqlc.Queries) error {
        settlement, err := q.GetSettlementAccountForUpdate(ctx)
        if err != nil {
            return fmt.Errorf("settlement account not found: %w", err)
        }
        account, err := q.GetAccountForUpdate(ctx, accountID)
        if err != nil {
            return fmt.Errorf("account not found: %w", err)
        }
        if account.Currency != settlement.Currency {
            return ErrCurrencyMismatch
        }
        txID := uuid.New()
        // 1. Credit user account
        _, err = q.CreateEntry(ctx, sqlc.CreateEntryParams{
            AccountID:     accountID,
            Debit:         decimal.Zero.StringFixed(4),
            Credit:        amount.StringFixed(4),
            TransactionID: txID,
            OperationType: "deposit",
            Description:   sql.NullString{String: "External deposit", Valid: true},
        })
        if err != nil { return err }
        // 2. Debit settlement (opposing entry)
        _, err = q.CreateEntry(ctx, sqlc.CreateEntryParams{
            AccountID:     settlement.ID,
            Debit:         amount.StringFixed(4),
            Credit:        decimal.Zero.StringFixed(4),
            TransactionID: txID,
            OperationType: "deposit",
            Description:   sql.NullString{String: fmt.Sprintf("Deposit to account %s", accountID), Valid: true},
        })
        if err != nil { return err }
        // 3. Update both balances atomically
        if err = q.UpdateAccountBalance(ctx, sqlc.UpdateAccountBalanceParams{
            Balance: amount.StringFixed(4), ID: accountID,
        }); err != nil { return err }
        return q.UpdateAccountBalance(ctx, sqlc.UpdateAccountBalanceParams{
            Balance: amount.Neg().StringFixed(4), ID: settlement.ID,
        })
    })
}
</code></pre>
<p>Two things are worth highlighting. First, both accounts are locked with <code>GetAccountForUpdate</code> and <code>GetSettlementAccountForUpdate</code> before any entries are written. This prevents any other concurrent transaction from reading a stale balance and acting on it.</p>
<p>Second, <code>amount.Neg()</code> is used to debit the settlement. Its balance goes down, representing real money now held inside the system.</p>
<h3 id="heading-withdraw-debiting-the-user-crediting-the-settlement">Withdraw: Debiting the User, Crediting the Settlement</h3>
<p>Withdrawals are the mirror image of deposits. The key difference is the insufficient funds check, which must happen inside the transaction after the lock is acquired:</p>
<pre><code class="language-go">balanceDec, err := decimal.NewFromString(account.Balance)
if err != nil {
    return errors.New("invalid balance")
}
if balanceDec.LessThan(amount) {
    return ErrInsufficientFunds
}
</code></pre>
<p>Checking balance inside the transaction after <code>FOR UPDATE</code> is critical. Checking it before, outside the transaction, would create a classic time-of-check-to-time-of-use (TOCTOU) race. Two concurrent withdrawals could both pass the check, then both execute, overdrawing the account.</p>
<p>The entries for a $500 withdrawal look like this:</p>
<pre><code class="language-plaintext">| Account              | Debit   | Credit  |
|----------------------|---------|---------|
| User Account         | 500     |         |
| Settlement Account   |         | 500     |
</code></pre>
<p>The settlement is credited because real money is leaving the system, and it's being "returned" to the outside world.</p>
<h3 id="heading-transfer-user-to-user-no-settlement-involved">Transfer: User-to-User, No Settlement Involved</h3>
<p>Transfers move money directly between two user accounts. The settlement account isn't involved. Both accounts are locked, currency is validated, and an insufficient funds check runs before any entries are created:</p>
<pre><code class="language-go">func (s *LedgerService) Transfer(ctx context.Context, fromID, toID uuid.UUID, amountStr string) error {
    amount, err := validatePositiveAmount(amountStr)
    if err != nil { return err }
    if fromID == toID {
        return ErrSameAccountTransfer
    }
    return s.store.ExecTx(ctx, func(q *sqlc.Queries) error {
        fromAcc, err := q.GetAccountForUpdate(ctx, fromID)
        if err != nil { return err }
        toAcc, err := q.GetAccountForUpdate(ctx, toID)
        if err != nil { return err }
        if fromAcc.Currency != toAcc.Currency {
            return ErrCurrencyMismatch
        }
        fromBalance, _ := decimal.NewFromString(fromAcc.Balance)
        if fromBalance.LessThan(amount) {
            return ErrInsufficientFunds
        }
        txID := uuid.New()
        // Debit sender, credit receiver — same transaction ID
        // ... CreateEntry calls + UpdateAccountBalance calls
    })
}
</code></pre>
<p>A $200 transfer creates exactly two entries under the same <code>transaction_id</code>:</p>
<pre><code class="language-plaintext">| Account  | Debit   | Credit  |
|----------|---------|---------|
| Sender   | 200     |         |
| Receiver |         | 200     |
</code></pre>
<h3 id="heading-reconcileaccount-trust-but-verify">ReconcileAccount: Trust, But Verify</h3>
<p>Reconciliation is how you prove the system is correct. The <code>ReconcileAccount</code> function compares the stored <code>balance</code> column against the sum of all credits minus debits in the entries table:</p>
<pre><code class="language-go">func (s *LedgerService) ReconcileAccount(ctx context.Context, accountID uuid.UUID) (bool, error) {
    account, err := s.store.GetAccount(ctx, accountID)
    if err != nil { return false, fmt.Errorf("account not found: %w", err) }

    calculatedStr, err := s.store.GetAccountBalance(ctx, accountID)
    if err != nil { return false, fmt.Errorf("failed to calculate balance: %w", err) }

    calculated, _ := decimal.NewFromString(calculatedStr)
    stored, _ := decimal.NewFromString(account.Balance)

    if !stored.Equal(calculated) {
        log.Error().
            Str("stored_balance", account.Balance).
            Str("calculated", calculated.StringFixed(4)).
            Msg("Balance mismatch detected")
        return false, fmt.Errorf("balance mismatch: stored %s, calculated %s",
            account.Balance, calculated.StringFixed(4))
    }
    return true, nil
}
</code></pre>
<p>If they don't match, something has gone wrong: a bug, a direct database modification, or a race condition that slipped through. In production, this check can run as a background job to catch issues before they become incidents.</p>
<h2 id="heading-the-api-layer-secure-predictable-and-boring-by-design">The API Layer: Secure, Predictable, and Boring (By Design)</h2>
<p>The API layer is where your business logic meets the outside world. Its job is to be secure, predictable, and, if you've done things right, a little bit boring.</p>
<h3 id="heading-jwt-authentication-secrets-matter">JWT Authentication: Secrets Matter</h3>
<p>Authentication is handled with JWTs. The secret used to sign tokens must be at least 32 characters long (as shorter secrets are insecure and can be brute-forced). This is enforced at startup:</p>
<pre><code class="language-go">// internal/api/middleware.go
func InitTokenAuth(secret string) error {
    if secret == "" {
        return errors.New("JWT_SECRET environment variable is required")
    }
    if len(secret) &lt; 32 {
        return errors.New("JWT_SECRET must be at least 32 characters")
    }
    TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
    return nil
}
</code></pre>
<p>The server will refuse to start if the secret is missing or too short. There's no fallback and no default: the system fails loudly rather than running insecurely.</p>
<h3 id="heading-the-handler-pattern-parse-authorize-validate-call-respond">The Handler Pattern: Parse, Authorize, Validate, Call, Respond</h3>
<p>Every handler follows the same recipe: extract JWT claims, parse the account ID, fetch the account and verify ownership, decode the request body, call the service, and respond. Authorization always happens before calling the service layer. The service knows nothing about users, keeping business logic clean and testable.</p>
<pre><code class="language-go">// internal/api/handler.go
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := json.NewDecoder(r.Body).Decode(&amp;input); err != nil {
        respondError(w, http.StatusBadRequest, "invalid input")
        return
    }
    // ... hash password, create user, generate JWT ...
}
</code></pre>
<h3 id="heading-amount-normalization-defensive-by-default">Amount Normalization: Defensive by Default</h3>
<p>API clients send amounts in different formats –&nbsp;sometimes as strings, sometimes as numbers. The normalization logic ensures all amounts are handled safely:</p>
<pre><code class="language-go">// internal/api/amount.go
func normalizeAmountInput(value interface{}) (string, error) {
    switch v := value.(type) {
    case string:
        return strings.TrimSpace(v), nil
    case json.Number:
        return strings.TrimSpace(v.String()), nil
    case float64:
        return strconv.FormatFloat(v, 'f', -1, 64), nil
    default:
        return "", errors.New("amount must be a number or string")
    }
}
</code></pre>
<p>The decoder uses <code>dec.UseNumber()</code> so JSON numbers arrive as <code>json.Number</code> rather than <code>float64</code>, preserving full precision. The <code>float64</code> case exists as a safety fallback only.</p>
<h3 id="heading-frontend-deployment-boundary">Frontend Deployment Boundary</h3>
<p>The backend no longer serves static frontend files. The frontend is deployed separately at <code>https://golangbank.app</code> from its own repository: <code>https://github.com/PaulBabatuyi/double-entry-bank</code>.</p>
<h2 id="heading-running-it-locally-your-first-end-to-end-test">Running It Locally: Your First End-to-End Test</h2>
<pre><code class="language-bash">git clone https://github.com/PaulBabatuyi/double-entry-bank-Go.git
cd double-entry-bank-Go
cp .env.example .env
# Edit .env — set JWT_SECRET with: openssl rand -base64 32
make postgres
make migrate-up
make server
</code></pre>
<p>Once the server is running:</p>
<ul>
<li><p><strong>Frontend</strong>: <a href="https://golangbank.app">https://golangbank.app</a></p>
</li>
<li><p><strong>Swagger UI</strong>: <a href="http://localhost:8080/swagger/index.html">http://localhost:8080/swagger/index.html</a> (local dev) or <a href="https://golangbank.app/swagger">https://golangbank.app/swagger</a> (production)</p>
</li>
<li><p><strong>Health check</strong>: <a href="http://localhost:8080/health">http://localhost:8080/health</a></p>
</li>
</ul>
<p>The Swagger UI lets you explore every endpoint, authorize with your JWT token, and test operations directly in the browser.</p>
<h2 id="heading-testing-prove-the-system-works">Testing: Prove the System Works</h2>
<p>Testing financial systems is non-negotiable, and claims about correctness need to be backed by code. This project tests all three layers, each targeting a different kind of failure.</p>
<h3 id="heading-service-layer-core-financial-logic">Service Layer: Core Financial Logic</h3>
<p>The most important tests live in <code>internal/service/ledger_test.go</code>. They run against a real PostgreSQL database – not mocks –&nbsp;because mock-based tests can give a false sense of security. Real database tests catch issues that only appear in production-like environments.</p>
<pre><code class="language-go">func TestDeposit_Success(t *testing.T) {
    ledger := setupTestLedger(t)
    accountID := createTestAccount(t, ledger, "0.00")

    err := ledger.Deposit(context.Background(), accountID, "100.00")
    require.NoError(t, err)

    balance := getAccountBalance(t, ledger, accountID)
    assert.Equal(t, "100.0000", balance)
}

func TestWithdraw_InsufficientFunds(t *testing.T) {
    ledger := setupTestLedger(t)
    accountID := createTestAccount(t, ledger, "50.00")

    err := ledger.Withdraw(context.Background(), accountID, "100.00")
    assert.ErrorIs(t, err, ErrInsufficientFunds)
}
</code></pre>
<p>The <code>createTestAccount</code> helper uses the settlement account's currency automatically, which is important: all accounts must share a currency for transfers to work, and tests that silently use a different currency will fail in confusing ways.</p>
<h3 id="heading-concurrency-test-proving-serializable-isolation-works">Concurrency Test: Proving Serializable Isolation Works</h3>
<p>This is the most important test in the suite:</p>
<pre><code class="language-go">func TestConcurrentDeposits(t *testing.T) {
    ledger := setupTestLedger(t)
    accountID := createTestAccount(t, ledger, "0.00")

    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        defer wg.Done()
        _ = ledger.Deposit(context.Background(), accountID, "100.00")
    }()
    go func() {
        defer wg.Done()
        _ = ledger.Deposit(context.Background(), accountID, "100.00")
    }()
    wg.Wait()

    balance := getAccountBalance(t, ledger, accountID)
    assert.Equal(t, "200.0000", balance)
}
</code></pre>
<p>Two goroutines deposit simultaneously. The serializable isolation level and retry logic ensure both operations succeed and neither overwrites the other. Without the <code>FOR UPDATE</code> locks and transaction retry logic, this test would fail non-deterministically – which is exactly the kind of bug that's impossible to reproduce in development but devastating in production.</p>
<h3 id="heading-store-layer-transaction-mechanics">Store Layer: Transaction Mechanics</h3>
<p>Tests in <code>internal/db/store_test.go</code> verify the retry infrastructure itself, without needing a database connection:</p>
<pre><code class="language-go">func TestIsSerializationError(t *testing.T) {
    pqErr := &amp;pq.Error{Code: "40001"}
    assert.True(t, isSerializationError(pqErr))
    assert.False(t, isSerializationError(errors.New("some other error")))
}

func TestRetryWait(t *testing.T) {
    assert.Equal(t, 50*time.Millisecond, retryWait(0))
    assert.Equal(t, 100*time.Millisecond, retryWait(1))
    assert.Equal(t, 200*time.Millisecond, retryWait(2))
    assert.Equal(t, time.Second, retryWait(5)) // capped
}

func TestSleepWithContext_Cancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // cancel immediately
    err := sleepWithContext(ctx, 50*time.Millisecond)
    assert.Error(t, err) // should return immediately, not wait
}
</code></pre>
<h3 id="heading-api-layer-authentication-and-input-handling">API Layer: Authentication and Input Handling</h3>
<p>Handler tests in <code>internal/api/handler_test.go</code> verify that the HTTP layer behaves correctly at its boundaries:</p>
<pre><code class="language-go">func TestRegisterHandler_BadRequest(t *testing.T) {
    h := setupTestHandler(t)
    req := httptest.NewRequest(http.MethodPost, "/register", nil)
    rw := httptest.NewRecorder()
    h.Register(rw, req)
    assert.Equal(t, http.StatusBadRequest, rw.Code)
}

func TestRegisterHandler_Success(t *testing.T) {
    h := setupTestHandler(t)
    _ = InitTokenAuth("fV7sliKV3qn657I60wEFtw/Auk/0bNU9zdp30wFzfDg=")

    email := "testuser_" + uuid.New().String() + "@example.com"
    body, _ := json.Marshal(map[string]string{"email": email, "password": "testpassword123"})

    req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewReader(body))
    rw := httptest.NewRecorder()
    h.Register(rw, req)
    assert.Equal(t, http.StatusCreated, rw.Code)
}
</code></pre>
<p>Using <code>uuid.New().String()</code> in the email ensures each test run creates a unique user, preventing conflicts on repeated runs against the same database.</p>
<p>Middleware tests verify the security boundary itself:</p>
<pre><code class="language-go">func TestInitTokenAuthFromEnv_MissingSecret(t *testing.T) {
    os.Unsetenv("JWT_SECRET")
    err := InitTokenAuthFromEnv()
    assert.Error(t, err) // must fail without a secret
}
</code></pre>
<h3 id="heading-running-the-tests">Running the Tests</h3>
<pre><code class="language-bash"># Start the database
make postgres

# Run all tests with race detection
make test

# Run with coverage report
make coverage

# Run tests the same way CI does (includes migrations)
make ci-test
</code></pre>
<p>The <code>-race</code> flag is non-negotiable for financial code. It instruments the binary to detect data races at runtime –&nbsp;something static analysis can't catch. If a race exists, the race detector will find it.</p>
<h2 id="heading-deployment-engineering-decisions-that-matter-in-production">Deployment: Engineering Decisions That Matter in Production</h2>
<p>The deployment setup for this project reflects several engineering decisions worth understanding, regardless of what platform you deploy to.</p>
<h3 id="heading-migrations-on-container-start">Migrations on Container Start</h3>
<p>The Docker entrypoint runs <code>golang-migrate up</code> before starting the Go binary:</p>
<pre><code class="language-sh"># docker-entrypoint
migrate -path /app/postgres/migrations -database "$migrate_db_url" up
exec /usr/local/bin/ledger
</code></pre>
<p>Running migrations at startup rather than as a separate CI step has trade-offs. The upside is simplicity: the container is always self-consistent when it starts. The downside is that each deployment takes slightly longer. For a solo project or small team, this is the right call. At scale you'd separate migrations from deployment.</p>
<h3 id="heading-startup-retry-logic">Startup Retry Logic</h3>
<p>The entrypoint retries migrations up to 12 times with a 5-second sleep between attempts:</p>
<pre><code class="language-sh">max_attempts=12
attempt=1
while [ "\(attempt" -le "\)max_attempts" ]; do
    migration_output=$(migrate ... up 2&gt;&amp;1)
    # If "connection refused" or "timeout", keep retrying
    # If any other error, fail immediately
    attempt=$((attempt + 1))
done
</code></pre>
<p>The critical distinction is which errors trigger a retry. Network-transient errors (connection refused, timeout) are retried. Everything else&nbsp;–&nbsp;a bad migration SQL, a missing tabl&nbsp;–&nbsp;fails immediately. This avoids waiting the full 60 seconds when a deployment has a real problem.</p>
<h3 id="heading-db-url-fallback-chain">DB URL Fallback Chain</h3>
<p>In cloud environments, the internal database URL is often a different variable than what you configure locally. The <code>resolveDBURL</code> function handles this transparently:</p>
<pre><code class="language-go">func resolveDBURL() string {
    connStr := strings.TrimSpace(os.Getenv("DB_URL"))
    fallbackVars := []string{"INTERNAL_DATABASE_URL", "RENDER_DATABASE_URL", "DATABASE_URL"}
    // Falls back through the chain if DB_URL is empty or resolves to localhost
    ...
}
</code></pre>
<p>This pattern means local developers set <code>DB_URL</code> in <code>.env</code> and don't need to think about it, while the deployed container automatically uses the internal database connection without any manual wiring.</p>
<h3 id="heading-http-server-timeouts">HTTP Server Timeouts</h3>
<p>The server is configured with explicit timeouts:</p>
<pre><code class="language-go">srv := &amp;http.Server{
    Addr:              ":" + port,
    Handler:           r,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      15 * time.Second,
    IdleTimeout:       60 * time.Second,
    ReadHeaderTimeout: 5 * time.Second,
}
</code></pre>
<p>Without timeouts, a slow or malicious client can hold connections open indefinitely, eventually exhausting the server's resources. <code>ReadHeaderTimeout</code> is particularly important: it limits how long the server waits for the HTTP headers before closing the connection, protecting against Slowloris-style attacks.</p>
<h2 id="heading-conclusion-building-for-the-real-world">Conclusion: Building for the Real World</h2>
<p>You've just walked through the core patterns that power real fintech systems:</p>
<ul>
<li><p>Double-entry ledger with database-enforced constraints</p>
</li>
<li><p>Settlement account for tracking external cash flows</p>
</li>
<li><p>Serializable transactions with exponential backoff retry</p>
</li>
<li><p>Reconciliation endpoint for verifying correctness</p>
</li>
<li><p>Type-safe queries with sqlc</p>
</li>
<li><p>Row-level locking to prevent race conditions</p>
</li>
<li><p>Tests that prove correctness under concurrency</p>
</li>
</ul>
<p>These aren't just Go patterns. They're the same principles used at companies like Monzo, Stripe, and Nubank. The implementation details differ, but the underlying ideas are the same: every dollar is accounted for, every operation is atomic, and the system can always explain where every penny went.</p>
<p>What's next? Three concrete next steps:</p>
<ol>
<li><p><strong>Add idempotency keys</strong> to prevent duplicate transactions on retries. If a client retries a deposit because of a network timeout, you need to detect and reject the duplicate.</p>
</li>
<li><p><strong>Add Prometheus metrics</strong> for transaction latency and failure rates. You want to know when your p99 latency spikes before your users do.</p>
</li>
<li><p><strong>Add a scheduled reconciliation job</strong> that runs <code>ReconcileAccount</code> for every account on a schedule and alerts on mismatches. Catch bugs automatically, before they become customer complaints.</p>
</li>
</ol>
<p>The developer who stores balance as a single number and updates it directly will eventually have an incident. The developer who builds a ledger has an audit trail, a reconciliation tool, and a system that can explain every penny.</p>
<p>That's the real reason fintech engineers build this way: not because it's more complex, but because it's more honest about what money actually is.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
