Lesson 23 of 38 10 minDesign Track

LLD Masterclass: Designing a Split-Expense App (Splitwise)

Master the object-oriented design, dynamic expense splitting strategies, and transaction simplification algorithms for a production-ready group expense sharing platform.

Reading Mode

Hide the curriculum rail and keep the lesson centered for focused reading.

Key Takeaways

  • **Functional Requirements**: Manage Users, Groups, multi-mode Expense Splitting (Equal, Exact, Percent), and dynamic balances.
  • **Simplification Engine**: Implement a Heap-based Greedy Cash-Flow Minimization algorithm to optimize ledger transactions.
  • **SOLID Design & Thread-Safety**: Leverage the Strategy pattern for expansion safety and ConcurrentHashMap locks to prevent race conditions during concurrent postings.
Recommended Prerequisites
LLD Masterclass Module 1: Foundational LLD & SOLID Principles

Premium outcome

Bridge the gap between architecture diagrams and implementation details.

Engineers preparing for LLD rounds or leveling up their software design depth.

What you unlock

  • Cleaner reasoning around SOLID, patterns, responsibilities, and schema design
  • A usable bridge between HLD whiteboard thinking and concrete Java classes
  • Case-study practice across common interview-style design systems

1. Requirements (The Scope)

A group expense sharing platform like Splitwise processes multi-user financial transactions. To model this systematically, we define the requirements under strict functional and engineering headings:

Functional Requirements:

  1. User & Group Management: Users can register and form groups (e.g., "Flatmates", "Trip to Tokyo").
  2. Flexible Splitting Modes: Support three expense splitting strategies:
    • Equal: Split costs identically among all participants.
    • Exact: Assign precise custom amounts to each participant (must sum to total).
    • Percentage: Split costs using custom percentages (must sum to 100%).
  3. Dynamic Balance Sheet: Retrieve individual user balances (e.g., "User A owes User B $20").
  4. Transaction Simplification: Provide a mechanism to simplify balances within a group to minimize the actual number of transactions required to settle up.

Non-Functional Requirements:

  1. High Transactional Safety: Ensure all expense additions, balance updates, and splits are mathematically exact (using BigDecimal instead of double to prevent floating-point precision leakage).
  2. Concurrency Safety: Support concurrent expense additions to a group without dirty reads or inconsistent states.
  3. Open for Extensions: Enable adding new splitting strategies (e.g., custom shares, weight ratios) without modifying the main expense registration pipeline (SOLID Open-Closed Principle).

2. High-Fidelity Diagrams

A. The Core Class Diagram & Strategy Architecture

Below is the structural relationship of our Splitwise domain model, highlighting the Strategy pattern for splits:

classDiagram
    class User {
        +String userId
        +String name
        +String email
    }
    class Split {
        +User user
        +BigDecimal amount
    }
    class Expense {
        +String id
        +String description
        +BigDecimal totalAmount
        +User paidBy
        +List~Split~ splits
        +ExpenseType type
    }
    class ExpenseSplitStrategy {
        <<interface>>
        +validateSplits(List~Split~ splits, BigDecimal total) boolean
    }
    class EqualSplitStrategy {
        +validateSplits(List~Split~ splits, BigDecimal total) boolean
    }
    class ExactSplitStrategy {
        +validateSplits(List~Split~ splits, BigDecimal total) boolean
    }
    
    ExpenseSplitStrategy <|.. EqualSplitStrategy
    ExpenseSplitStrategy <|.. ExactSplitStrategy
    Expense *-- Split
    Expense o-- User
    Split o-- User

B. Sequence Diagram: Registering an Expense

The sequence below illustrates the flow of adding a new expense with strategy validation:

sequenceDiagram
    autonumber
    actor U as User (Payer)
    participant GM as GroupManager
    participant G as Group
    participant S as ExpenseSplitStrategy
    participant BS as BalanceSheetController

    U->>GM: addExpense(groupId, payerId, amount, description, splits, type)
    activate GM
    GM->>GM: getStrategy(type)
    GM->>S: validateSplits(splits, amount)
    alt is invalid
        S-->>GM: return false
        GM-->>U: throw InvalidExpenseException
    else is valid
        S-->>GM: return true
        GM->>G: createAndAddExpense(payer, amount, splits)
        G->>BS: updateBalances(payer, splits)
        activate BS
        BS-->>G: balance sheet updated
        deactivate BS
        GM-->>U: return success
    end
    deactivate GM

3. Production-Grade Java Implementation

Let's build the complete, thread-safe Java codebase. We use standard packaging paradigms and precise currency processing structures:

A. The Splitting Contracts & Models

package com.codesprintpro.splitwise.model;

public class User {
    private final String userId;
    private final String name;
    private final String email;

    public User(String userId, String name, String email) {
        this.userId = userId;
        this.name = name;
        this.email = email;
    }

    public String getUserId() { return userId; }
    public String getName() { return name; }
}
package com.codesprintpro.splitwise.model;

import java.math.BigDecimal;

public abstract class Split {
    private final User user;
    protected BigDecimal amount;

    public Split(User user) {
        this.user = user;
    }

    public User getUser() { return user; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
}

public class EqualSplit extends Split {
    public EqualSplit(User user) { super(user); }
}

public class ExactSplit extends Split {
    public ExactSplit(User user, BigDecimal amount) {
        super(user);
        this.amount = amount;
    }
}

public class PercentSplit extends Split {
    private final double percent;

    public PercentSplit(User user, double percent) {
        super(user);
        this.percent = percent;
    }

    public double getPercent() { return percent; }
}

B. Splitting Strategy Pipeline

package com.codesprintpro.splitwise.strategy;

import com.codesprintpro.splitwise.model.Split;
import java.math.BigDecimal;
import java.util.List;

public interface ExpenseSplitStrategy {
    boolean validateSplits(List<Split> splits, BigDecimal totalAmount);
}
package com.codesprintpro.splitwise.strategy;

import com.codesprintpro.splitwise.model.*;
import java.math.BigDecimal;
import java.util.List;

public class EqualSplitStrategy implements ExpenseSplitStrategy {
    @Override
    public boolean validateSplits(List<Split> splits, BigDecimal totalAmount) {
        // Equal splits automatically generate fair fractional balances
        return splits != null && !splits.isEmpty();
    }
}

public class ExactSplitStrategy implements ExpenseSplitStrategy {
    @Override
    public boolean validateSplits(List<Split> splits, BigDecimal totalAmount) {
        if (splits == null || splits.isEmpty()) return false;
        BigDecimal sum = BigDecimal.ZERO;
        for (Split split : splits) {
            if (split.getAmount() == null) return false;
            sum = sum.add(split.getAmount());
        }
        return sum.compareTo(totalAmount) == 0;
    }
}

public class PercentSplitStrategy implements ExpenseSplitStrategy {
    @Override
    public boolean validateSplits(List<Split> splits, BigDecimal totalAmount) {
        if (splits == null || splits.isEmpty()) return false;
        double percentSum = 0;
        for (Split split : splits) {
            if (!(split instanceof PercentSplit)) return false;
            percentSum += ((PercentSplit) split).getPercent();
        }
        return Math.abs(percentSum - 100.0) < 0.0001;
    }
}

C. The Group Ledger & Concurrency Models

package com.codesprintpro.splitwise.model;

import java.math.BigDecimal;
import java.util.List;

public class Expense {
    private final String id;
    private final String description;
    private final BigDecimal totalAmount;
    private final User paidBy;
    private final List<Split> splits;

    public Expense(String id, String description, BigDecimal totalAmount, User paidBy, List<Split> splits) {
        this.id = id;
        this.description = description;
        this.totalAmount = totalAmount;
        this.paidBy = paidBy;
        this.splits = splits;
    }

    public String getId() { return id; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public User getPaidBy() { return paidBy; }
    public List<Split> getSplits() { return splits; }
}
package com.codesprintpro.splitwise.model;

import com.codesprintpro.splitwise.strategy.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

public class Group {
    private final String groupId;
    private final String name;
    private final List<User> members = new CopyOnWriteArrayList<>();
    private final List<Expense> expenses = new CopyOnWriteArrayList<>();
    
    // Nested balance structure: UserIdA -> (UserIdB -> Amount) 
    // Positive means A owes B; Negative means B owes A
    private final Map<String, Map<String, BigDecimal>> balanceSheet = new ConcurrentHashMap<>();
    private final Object writeLock = new Object(); // Synchronizes ledger writes

    public Group(String groupId, String name) {
        this.groupId = groupId;
        this.name = name;
    }

    public void addMember(User user) {
        members.add(user);
        balanceSheet.putIfAbsent(user.getUserId(), new ConcurrentHashMap<>());
    }

    public void addExpense(String id, String description, BigDecimal amount, User paidBy, List<Split> splits, ExpenseSplitStrategy strategy) {
        if (!strategy.validateSplits(splits, amount)) {
            throw new IllegalArgumentException("[Splitwise] Validation FAILED! Splitting values do not reconcile with total: " + amount);
        }

        // Auto-assign amounts for Equal and Percentage splits to ensure transaction integrity
        reconcileSplitAmounts(splits, amount);

        synchronized (writeLock) {
            Expense expense = new Expense(id, description, amount, paidBy, splits);
            expenses.add(expense);

            // Mutate Ledger Balance sheets
            for (Split split : splits) {
                User debtor = split.getUser();
                if (debtor.getUserId().equals(paidBy.getUserId())) continue;

                BigDecimal splitAmount = split.getAmount();
                updateBalances(debtor.getUserId(), paidBy.getUserId(), splitAmount);
            }
        }
    }

    private void reconcileSplitAmounts(List<Split> splits, BigDecimal total) {
        if (splits.get(0) instanceof EqualSplit) {
            BigDecimal size = new BigDecimal(splits.size());
            BigDecimal share = total.divide(size, 2, RoundingMode.HALF_UP);
            BigDecimal remainder = total.subtract(share.multiply(size));

            for (int i = 0; i < splits.size(); i++) {
                splits.get(i).setAmount(share);
            }
            // Distribute fractional pennies safely
            if (remainder.compareTo(BigDecimal.ZERO) != 0) {
                splits.get(0).setAmount(splits.get(0).getAmount().add(remainder));
            }
        } else if (splits.get(0) instanceof PercentSplit) {
            for (Split split : splits) {
                PercentSplit ps = (PercentSplit) split;
                BigDecimal share = total.multiply(BigDecimal.valueOf(ps.getPercent() / 100.0)).setScale(2, RoundingMode.HALF_UP);
                ps.setAmount(share);
            }
        }
    }

    private void updateBalances(String debtorId, String creditorId, BigDecimal amount) {
        // Debtor owes Creditor
        Map<String, BigDecimal> debtorBalances = balanceSheet.get(debtorId);
        BigDecimal currentDebtorBalance = debtorBalances.getOrDefault(creditorId, BigDecimal.ZERO);
        debtorBalances.put(creditorId, currentDebtorBalance.add(amount));

        // Creditor is owed by Debtor (symmetric negative balance)
        Map<String, BigDecimal> creditorBalances = balanceSheet.get(creditorId);
        BigDecimal currentCreditorBalance = creditorBalances.getOrDefault(debtorId, BigDecimal.ZERO);
        creditorBalances.put(debtorId, currentCreditorBalance.subtract(amount));
    }

    public Map<String, Map<String, BigDecimal>> getBalanceSheet() { return balanceSheet; }
    public List<User> getMembers() { return members; }
}

4. The Transaction Simplification Algorithm

If Alice owes Bob $10, and Bob owes Charlie $10, the optimal operational pathway is Alice paying Charlie $10 directly. This reduces transactions from two to one.

Heap-Based Cash Flow Minimization:

We implement a highly optimized, greedy optimization engine. We calculate the net net balance for each user. Users with negative net balances are creditors (they are owed money); users with positive net balances are debtors (they owe money). We push creditors to a Max-Heap and debtors to a Min-Heap (or Max-Heap based on absolute debit sizes) and resolve settlements recursively:

package com.codesprintpro.splitwise.service;

import com.codesprintpro.splitwise.model.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;

public class TransactionSimplifier {
    
    public static class Transaction {
        private final String debtorName;
        private final String creditorName;
        private final BigDecimal amount;

        public Transaction(String debtorName, String creditorName, BigDecimal amount) {
            this.debtorName = debtorName;
            this.creditorName = creditorName;
            this.amount = amount;
        }

        @Override
        public String toString() {
            return debtorName + " pays " + creditorName + " : $" + amount.setScale(2, RoundingMode.HALF_UP);
        }
    }

    private static class UserBalance implements Comparable<UserBalance> {
        String userName;
        BigDecimal netBalance; // positive = owed (creditor), negative = owes (debtor)

        UserBalance(String userName, BigDecimal netBalance) {
            this.userName = userName;
            this.netBalance = netBalance;
        }

        @Override
        public int compareTo(UserBalance other) {
            return other.netBalance.abs().compareTo(this.netBalance.abs()); // Sorting by descending absolute value
        }
    }

    public static List<Transaction> simplifyBalances(Group group) {
        Map<String, BigDecimal> netBalances = new HashMap<>();
        
        // Initialize members
        for (User u : group.getMembers()) {
            netBalances.put(u.getName(), BigDecimal.ZERO);
        }

        // Accumulate Net Net balances from balance sheet
        Map<String, Map<String, BigDecimal>> sheets = group.getBalanceSheet();
        for (User member : group.getMembers()) {
            String memberName = member.getName();
            Map<String, BigDecimal> relations = sheets.get(member.getUserId());
            
            BigDecimal net = BigDecimal.ZERO;
            if (relations != null) {
                for (BigDecimal value : relations.values()) {
                    net = net.subtract(value); // Debts added as negative, credits added as positive
                }
            }
            netBalances.put(memberName, net);
        }

        // Priority Queues for Greedy settlement matches
        PriorityQueue<UserBalance> creditors = new PriorityQueue<>(); // Persons to receive cash (Net Net balance > 0)
        PriorityQueue<UserBalance> debtors = new PriorityQueue<>();   // Persons to pay cash (Net Net balance < 0)

        for (Map.Entry<String, BigDecimal> entry : netBalances.entrySet()) {
            BigDecimal bal = entry.getValue();
            if (bal.compareTo(BigDecimal.ZERO) > 0) {
                creditors.add(new UserBalance(entry.getKey(), bal));
            } else if (bal.compareTo(BigDecimal.ZERO) < 0) {
                debtors.add(new UserBalance(entry.getKey(), bal.negate())); // Store as positive absolute for max-heap
            }
        }

        List<Transaction> transactions = new ArrayList<>();

        while (!creditors.isEmpty() && !debtors.isEmpty()) {
            UserBalance maxCreditor = creditors.poll();
            UserBalance maxDebtor = debtors.poll();

            BigDecimal settlementAmount = maxCreditor.netBalance.min(maxDebtor.netBalance);
            transactions.add(new Transaction(maxDebtor.userName, maxCreditor.userName, settlementAmount));

            maxCreditor.netBalance = maxCreditor.netBalance.subtract(settlementAmount);
            maxDebtor.netBalance = maxDebtor.netBalance.subtract(settlementAmount);

            // Re-queue remaining balances if not fully settled
            if (maxCreditor.netBalance.compareTo(BigDecimal.valueOf(0.01)) > 0) {
                creditors.add(maxCreditor);
            }
            if (maxDebtor.netBalance.compareTo(BigDecimal.valueOf(0.01)) > 0) {
                debtors.add(maxDebtor);
            }
        }

        return transactions;
    }
}

5. SOLID Design Alignment

  1. SRP (Single Responsibility): The Group stores raw ledgers, ExpenseSplitStrategy validates arithmetic splits, and TransactionSimplifier solves algorithmic settlements.
  2. OCP (Open-Closed): To add a new ShareSplitStrategy or custom tax calculations, we define a new strategy class extending ExpenseSplitStrategy without editing any structural codebase.
  3. LSP (Liskov Substitution): EqualSplit, ExactSplit, and PercentSplit are safely substitutable subclasses of Split.
  4. DIP (Dependency Inversion): The group coordinator uses abstract strategy abstractions (ExpenseSplitStrategy) rather than relying on direct class evaluations.

6. Verification Playground (Ledger Simulation)

Run the operational verification class to see the splitting engine and cashflow optimizer calculate exact payments:

import com.codesprintpro.splitwise.model.*;
import com.codesprintpro.splitwise.strategy.*;
import com.codesprintpro.splitwise.service.TransactionSimplifier;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        System.out.println("--- Starting Splitwise Ledger Simulation ---\n");

        // 1. Setup users
        User alice = new User("U1", "Alice", "alice@pro.com");
        User bob = new User("U2", "Bob", "bob@pro.com");
        User charlie = new User("U3", "Charlie", "charlie@pro.com");

        Group tripGroup = new Group("G1", "Tokyo Trip 2026");
        tripGroup.addMember(alice);
        tripGroup.addMember(bob);
        tripGroup.addMember(charlie);

        // 2. Registrations strategies
        ExpenseSplitStrategy equalStrategy = new EqualSplitStrategy();
        ExpenseSplitStrategy exactStrategy = new ExactSplitStrategy();

        // Transaction A: Alice pays $300 for Hotel, split equally
        List<Split> hotelSplits = new ArrayList<>();
        hotelSplits.add(new EqualSplit(alice));
        hotelSplits.add(new EqualSplit(bob));
        hotelSplits.add(new EqualSplit(charlie));
        
        tripGroup.addExpense("E1", "Hotel Booking", BigDecimal.valueOf(300.00), alice, hotelSplits, equalStrategy);
        System.out.println("[Ledger] Alice paid $300 (split equally among Alice, Bob, Charlie).");

        // Transaction B: Bob pays $90 for Bullet Train. Alice owes $40, Charlie owes $50
        List<Split> trainSplits = new ArrayList<>();
        trainSplits.add(new ExactSplit(alice, BigDecimal.valueOf(40.00)));
        trainSplits.add(new ExactSplit(charlie, BigDecimal.valueOf(50.00)));
        trainSplits.add(new ExactSplit(bob, BigDecimal.ZERO)); // Bob paid, owes nothing to himself

        tripGroup.addExpense("E2", "Shinkansen Tickets", BigDecimal.valueOf(90.00), bob, trainSplits, exactStrategy);
        System.out.println("[Ledger] Bob paid $90 (exact split: Alice owes $40, Charlie owes $50).");

        // 3. Compute Simplified Settlement
        System.out.println("\n--- Calculating Optimized Balance Settlements ---");
        List<TransactionSimplifier.Transaction> optimalReceipts = TransactionSimplifier.simplifyBalances(tripGroup);
        
        for (TransactionSimplifier.Transaction tx : optimalReceipts) {
            System.out.println(" ✅ " + tx);
        }

        System.out.println("\n--- Simulation Complete. Cashflows minimized successfully. ---");
    }
}

Knowledge Check

Java · 3 Questions

Test Your Understanding

Ready to test yourself?

Answer 3 quick questions to reinforce what you just learned. Takes under 2 minutes.

Want to track your progress?

Sign in to save your progress, track completed lessons, and pick up where you left off.