Skip to main content

Design a Financial Ledger Engine (Double-Entry Bookkeeping)

Problem Statement

Design a core financial ledger engine implementing double-entry bookkeeping rules (similar to the financial transaction systems inside Stripe, Razorpay, or banking ledgers). The engine must represent accounts (Assets, Liabilities, Equity), validate that every transaction is balanced (total debits equal total credits), prevent floating-point representation errors, and handle concurrency safely by avoiding deadlocks when performing multi-account transfers.

Asked In Companies
Stripe Razorpay Goldman Sachs

Design Decisions & Patterns Used

Financial software must guarantee absolute correctness. Double-entry bookkeeping dictates that every transaction consists of matching debits and credits, ensuring that the accounting equation (Assets = Liabilities + Equity) is always in balance. To prevent rounding issues, we store monetary amounts as long integers in the smallest currency unit (e.g., cents instead of dollars). To ensure high concurrency without deadlocks, we lock accounts in a sorted sequence by ID during transfers.

We will utilize the following Design Patterns:

  • Command Pattern: Encapsulating transfer requests as transactions containing atomic journal entries.
  • Builder Pattern: Providing a safe API to build multi-legged transactions with multiple debit and credit entries.

Functional Requirements

  • Support account types: ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE.
  • Ensure money is modeled as integers (cents) to avoid floating-point errors.
  • Enforce double-entry balance validation: a transaction can only be committed if its sum of debits equals its sum of credits.
  • Modify account balances dynamically based on account type conventions (e.g., debits increase assets, credits increase liabilities).
  • Prevent deadlocks during concurrent multi-account transactions using lock ordering.

Objects Required

  • AccountType (Enum defining the accounting behavior of different accounts)
  • EntryType (Enum mapping DEBIT and CREDIT directions)
  • Account (Entity storing balances and locking objects)
  • Entry (Value object linking an account to a debit or credit amount)
  • Transaction (Immutable command object representing a balanced group of entries)
  • Ledger (Orchestrator validating transactions and executing updates safely)

AccountType & EntryType Enums

The AccountType determines whether a debit or credit increases the account balance. The EntryType tracks the direction of each entry.


public enum AccountType {
    ASSET(true),      // Debits increase balance, Credits decrease
    EXPENSE(true),    // Debits increase balance, Credits decrease
    LIABILITY(false),  // Credits increase balance, Debits decrease
    EQUITY(false),     // Credits increase balance, Debits decrease
    REVENUE(false);    // Credits increase balance, Debits decrease

    private final boolean debitIncreases;

    AccountType(boolean debitIncreases) {
        this.debitIncreases = debitIncreases;
    }

    public boolean isDebitIncreases() { return debitIncreases; }
}

Let's define the EntryType enum:


public enum EntryType {
    DEBIT,
    CREDIT
}

These enums model double-entry rules cleanly, preventing sign errors.


Account & Entry Classes

The Account class represents an account entity, using long integers for balances and reentrant locks to handle concurrent access. The Entry class captures the details of an entry.


import java.util.concurrent.locks.ReentrantLock;

public class Account {
    private final String id;
    private final String name;
    private final AccountType type;
    private long balanceCents;
    private final ReentrantLock lock;

    public Account(String id, String name, AccountType type) {
        this.id = id;
        this.name = name;
        this.type = type;
        this.balanceCents = 0;
        this.lock = new ReentrantLock();
    }

    public void applyEntry(EntryType entryType, long amountCents) {
        boolean isDebit = (entryType == EntryType.DEBIT);
        // Determine balance sign based on account type conventions
        boolean increase = (isDebit == type.isDebitIncreases());

        if (increase) {
            balanceCents += amountCents;
        } else {
            balanceCents -= amountCents;
        }
    }

    public String getId() { return id; }
    public String getName() { return name; }
    public long getBalanceCents() { return balanceCents; }
    public ReentrantLock getLock() { return lock; }
}

The constructor configures the type, initializes the cents balance to 0, and instantiates a lock. applyEntry() evaluates whether the entry type increases or decreases the balance.


public class Entry {
    private final String accountId;
    private final EntryType type;
    private final long amountCents;

    public Entry(String accountId, EntryType type, long amountCents) {
        if (amountCents <= 0) {
            throw new IllegalArgumentException("Entry amount must be greater than zero.");
        }
        this.accountId = accountId;
        this.type = type;
        this.amountCents = amountCents;
    }

    public String getAccountId() { return accountId; }
    public EntryType getType() { return type; }
    public long getAmountCents() { return amountCents; }
}

The constructor asserts that entry values are positive. Getter methods expose the properties to the validation engine.


Transaction Class (Command)

The Transaction class packages multiple entries into a single unit and exposes the double-entry validation checks.


import java.util.ArrayList;
import java.util.List;

public class Transaction {
    private final String id;
    private final List<Entry> entries;
    private final long timestamp;

    public Transaction(String id) {
        this.id = id;
        this.entries = new ArrayList<>();
        this.timestamp = System.currentTimeMillis();
    }

    public void addEntry(String accountId, EntryType type, long amountCents) {
        entries.add(new Entry(accountId, type, amountCents));
    }

    public boolean isBalanced() {
        if (entries.isEmpty()) return false;
        
        long totalDebits = 0;
        long totalCredits = 0;

        for (Entry entry : entries) {
            if (entry.getType() == EntryType.DEBIT) {
                totalDebits += entry.getAmountCents();
            } else {
                totalCredits += entry.getAmountCents();
            }
        }
        return totalDebits == totalCredits;
    }

    public String getId() { return id; }
    public List<Entry> getEntries() { return entries; }
    public long getTimestamp() { return timestamp; }
}

The constructor initializes tracking arrays. addEntry() adds a new entry to the transaction, and isBalanced() evaluates if the sum of debits matches the sum of credits.


Ledger Class

The Ledger registry manages accounts. It validates transactions and executes balanced updates safely using lock sorting to prevent deadlocks.


import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class Ledger {
    private final Map<String, Account> accounts;
    private final List<Transaction> journal;

    public Ledger() {
        this.accounts = new ConcurrentHashMap<>();
        this.journal = new ArrayList<>();
    }

    public void createAccount(String id, String name, AccountType type) {
        accounts.put(id, new Account(id, name, type));
        System.out.println("Created Account: " + name + " [" + type + "]");
    }

    public Account getAccount(String id) {
        return accounts.get(id);
    }

    public void commit(Transaction transaction) {
        if (!transaction.isBalanced()) {
            throw new IllegalArgumentException("Transaction unbalanced! Total debits must equal total credits.");
        }

        // Fetch accounts involved in the transaction
        List<Account> lockSequence = new ArrayList<>();
        for (Entry entry : transaction.getEntries()) {
            Account account = accounts.get(entry.getAccountId());
            if (account == null) {
                throw new IllegalArgumentException("Account not found: " + entry.getAccountId());
            }
            if (!lockSequence.contains(account)) {
                lockSequence.add(account);
            }
        }

        // Lock ordering strategy: Sort accounts by ID to prevent deadlocks
        lockSequence.sort(Comparator.comparing(Account::getId));

        // Acquire locks in sorted order
        for (Account account : lockSequence) {
            account.getLock().lock();
        }

        try {
            // Apply entries to the accounts
            for (Entry entry : transaction.getEntries()) {
                Account account = accounts.get(entry.getAccountId());
                account.applyEntry(entry.getType(), entry.getAmountCents());
            }
            journal.add(transaction);
            System.out.println("Committed Transaction: " + transaction.getId());
        } finally {
            // Release locks in reverse order
            for (int i = lockSequence.size() - 1; i >= 0; i--) {
                lockSequence.get(i).getLock().unlock();
            }
        }
    }
}

Here is an explanation of the core operations in the Ledger class:

  • The constructor configures concurrent hash maps to store accounts and creates a list to act as the journal log.
  • commit() validates the transaction. If balanced, it extracts the accounts involved, sorts them alphabetically by ID, acquires locks sequentially to prevent deadlocks, applies the entries, and appends the transaction to the journal.

Main Driver Class

This class tests our double-entry ledger. It creates accounts, executes balanced entries, and verifies that unbalanced transactions are rejected.


public class Main {
    public static void main(String[] args) {
        Ledger ledger = new Ledger();

        // Initialize Accounts
        ledger.createAccount("A01", "Cash Assets", AccountType.ASSET);
        ledger.createAccount("L01", "Credit Card Debt", AccountType.LIABILITY);
        ledger.createAccount("R01", "SaaS Revenue", AccountType.REVENUE);

        System.out.println("\n--- Scenario 1: Processing Balanced Transaction (Customer Buy SaaS) ---");
        // Customer buys a subscription: Debit Cash (Asset increases) $100, Credit SaaS Revenue (Revenue increases) $100
        Transaction txn1 = new Transaction("TXN-101");
        txn1.addEntry("A01", EntryType.DEBIT, 10000); // $100.00
        txn1.addEntry("R01", EntryType.CREDIT, 10000); // $100.00

        ledger.commit(txn1);
        System.out.println("Cash Balance: $" + (ledger.getAccount("A01").getBalanceCents() / 100.0));
        System.out.println("Revenue Balance: $" + (ledger.getAccount("R01").getBalanceCents() / 100.0));

        System.out.println("\n--- Scenario 2: Processing Complex Transaction (Paying credit card bill) ---");
        // Paying $50.00 credit card bill: Debit CC Debt (Liability decreases) $50, Credit Cash (Asset decreases) $50
        Transaction txn2 = new Transaction("TXN-102");
        txn2.addEntry("L01", EntryType.DEBIT, 5000);
        txn2.addEntry("A01", EntryType.CREDIT, 5000);

        ledger.commit(txn2);
        System.out.println("CC Debt Balance: $" + (ledger.getAccount("L01").getBalanceCents() / 100.0));
        System.out.println("Cash Balance: $" + (ledger.getAccount("A01").getBalanceCents() / 100.0));

        System.out.println("\n--- Scenario 3: Rejecting Unbalanced Transaction ---");
        Transaction txn3 = new Transaction("TXN-103");
        txn3.addEntry("A01", EntryType.DEBIT, 1000); // $10.00
        txn3.addEntry("R01", EntryType.CREDIT, 900);   // $9.00 (Unbalanced!)

        try {
            ledger.commit(txn3);
        } catch (Exception e) {
            System.out.println("Caught Expected Exception: " + e.getMessage());
        }
    }
}

The main() driver configures ledger accounts, triggers balanced payment and debit workflows, prints balances, and asserts that unbalanced transactions are blocked.


Also See

Comments

Popular posts from this blog

Designing a Parking Lot - Low Level Design

Problem Statement Design a parking lot that can handle vehicles entering and leaving while managing parking across multiple floors. Each vehicle should be assigned a suitable parking spot based on its type, and the spot should be freed once the vehicle exits. The design should also support generating a ticket at entry and optionally calculating the parking fee based on the duration of stay. Asked In Companies Amazon Google Microsoft Uber Walmart Flipkart Meta PayPal Oracle Salesforce Adobe Apple Intuit LinkedIn Atlassian Functional Requirements The design should support multiple vehicle types such as bikes, cars, and trucks A vehicle must be assigned a parking spot compatible with its type A parking spot cannot be assigned to more than one vehicle at a time The parking lot should support multiple levels (floors) The design should search and allocate an availa...

Most Frequently Asked Low Level Design(LLD) Interview Questions

Below are the curated list of most commonly asked Low Level Design (LLD) interview problems. Each problem includes a short description and a link to the complete solution with code and class diagrams. Design Parking Lot System The system should handle parking for different vehicle types such as bikes, cars, and trucks. It should manage slot allocation, availability tracking, and entry/exit flow. The design also ensures efficient usage of parking space under varying load conditions. View Solution Design Elevator / Lift System The system should support multiple elevators operating across floors with request handling logic. It focuses on scheduling algorithms to minimize wait time and optimize movement. It also manages direction control and concurrent floor requests. View Solution Design Movie Ticket Booking System The system should allow users to browse movies, select shows, and book seats. It handles seat ...

Software Design Patterns for LLD Interviews: A Complete Guide

Software Design Patterns for LLD Interviews: A Complete Guide In Software Development Engineer (SDE) interviews—especially for mid-level and senior roles—low-level design (LLD) rounds assess your ability to write clean, reusable, maintainable, and extensible code. The foundation of resolving these architectural challenges lies in the standard Gang of Four (GoF) Design Patterns. Rather than memorizing theoretical definitions, interviewers expect you to apply these patterns to real-world scenarios, identifying the trade-offs of each. Below is a comprehensive guide to the 12 most frequently asked design patterns in LLD interviews, categorized by their classification (Creational, Structural, and Behavioral). Each pattern contains a concrete, real-world Java implementation and a detailed breakdown of design decisions. Creational Design Patterns Creational design patterns deal with object creation mechanisms. They abstract the instantiation process, making a system independent of how...