Splitwise Low Level Design in Java: Complete LLD Guide

A full Splitwise LLD walkthrough for system design interviews, entity modeling, design patterns, the balance simplification algorithm, and a complete Java implementation you can run.

Splitwise Low Level Design in Java: Complete LLD Guide

A friend group I’m in used Splitwise for years. Every trip, every dinner, every “who paid for the groceries” moment, it all went through Splitwise. I never thought about how it worked until it came up in an interview. The interviewer asked me to design it from scratch, and I realized I had no idea how the “simplify debts” feature actually worked under the hood.

That sent me down a rabbit hole. Splitwise is a deceptively rich LLD problem, it touches entity modeling, multiple design patterns, graph algorithms for debt simplification, and thread safety. It shows up at Amazon, Google, Microsoft, and Flipkart for exactly that reason. Here’s the full design, with a Java implementation you can actually run.

What Is Splitwise?

Splitwise is an expense-sharing application that helps groups of people track shared expenses and settle debts efficiently. Whether it’s roommates splitting rent, friends dividing a vacation bill, or coworkers sharing lunch costs: Splitwise keeps track of who owes whom, so nobody has to.

The interesting part isn’t the UI or the notification system. It’s the graph optimization problem underneath: how do you settle debts with the minimum number of transactions?

What Interviewers Are Actually Evaluating

When someone asks you to design Splitwise, they’re checking for:

  • Object-Oriented Design: can you model real-world entities into clean, extensible classes?
  • Design Pattern Application: do you know when and how to apply Singleton, Factory, Strategy, and Observer patterns?
  • Algorithm Skills: can you implement the minimum cash flow algorithm to simplify debts?
  • Concurrency Awareness: can you handle thread-safe operations for shared state?
  • Scalability Thinking: is your design extensible for new features?

This guide covers all of those.


Functional Requirements

Before writing any code, let’s define what our Splitwise system should do:

Core Features

  1. User Management

    • Register and manage users with ID, name, email, and phone
    • View user’s balance sheet with all other users
  2. Group Management

    • Create groups with multiple members
    • Add/remove members from groups
    • View all expenses within a group
  3. Expense Management

    • Add expenses with a payer and multiple participants
    • Support multiple split types:
      • Equal Split - Divide equally among all participants
      • Exact Split - Divide by specific amounts
      • Percent Split - Divide by percentages (e.g., 60/40)
    • Edit and delete expenses
  4. Balance Sheet

    • Track pairwise balances between users
    • View “who owes whom” and “who is owed by whom”
    • Real-time balance updates
  5. Settlement & Simplification

    • Settle debts between two users
    • Simplify debts - Minimize the number of transactions using graph algorithms
    • Support partial settlements
  6. Notifications

    • Notify users when they’re added to an expense
    • Alert users when they owe money

Non-Functional Requirements

RequirementDescription
ScalabilityHandle millions of users and groups
Thread SafetyConcurrent expense additions must be safe
ExtensibilityEasy to add new split types (e.g., shares, ratios)
PerformanceO(1) balance lookup, O(n) expense creation
ConsistencyBalance sheet must always be accurate
PersistenceStore data in a relational database

System Architecture

Splitwise follows a layered architecture:

┌──────────────────────────────────────────────────┐
│                   API Layer                      │
│         (REST Controllers / CLI)                 │
├──────────────────────────────────────────────────┤
│                  Service Layer                   │
│     (SplitwiseService, NotificationService)      │
├──────────────────────────────────────────────────┤
│                  Domain Layer                    │
│    (User, Group, Expense, Split, Transaction)    │
├──────────────────────────────────────────────────┤
│                 Persistence Layer                │
│              (Database / Cache)                  │
└──────────────────────────────────────────────────┘

Core Entity Design

1. User

Represents a user in the system with a balance sheet tracking pairwise debts.

public class User {
    private final String id;
    private final String name;
    private final String email;
    private final String phone;

    // Balance sheet: userId -> amount owed
    // Positive: other user owes this user
    // Negative: this user owes other user
    private final Map<String, Double> balanceSheet;

    public User(String id, String name, String email, String phone) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.phone = phone;
        this.balanceSheet = new ConcurrentHashMap<>();
    }

    // Getters
    public String getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
    public String getPhone() { return phone; }
    public Map<String, Double> getBalanceSheet() { return balanceSheet; }

    public double getBalanceWith(String userId) {
        return balanceSheet.getOrDefault(userId, 0.0);
    }

    public void updateBalance(String userId, double amount) {
        balanceSheet.put(userId, balanceSheet.getOrDefault(userId, 0.0) + amount);
    }
}

2. Group

A container for users and their shared expenses.

public class Group {
    private final String id;
    private final String name;
    private final String description;
    private final List<User> members;
    private final List<Expense> expenses;

    public Group(String id, String name, String description) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.members = new CopyOnWriteArrayList<>();
        this.expenses = new CopyOnWriteArrayList<>();
    }

    // Getters
    public String getId() { return id; }
    public String getName() { return name; }
    public String getDescription() { return description; }
    public List<User> getMembers() { return members; }
    public List<Expense> getExpenses() { return expenses; }

    public void addMember(User user) {
        if (!members.contains(user)) {
            members.add(user);
        }
    }

    public void removeMember(User user) {
        members.remove(user);
    }

    public void addExpense(Expense expense) {
        expenses.add(expense);
    }
}

3. Expense

Represents a single expense with a payer and multiple splits.

public class Expense {
    private final String id;
    private final String description;
    private final double amount;
    private final User paidBy;
    private final LocalDateTime timestamp;
    private final List<Split> splits;

    public Expense(String id, String description, double amount, User paidBy) {
        this.id = id;
        this.description = description;
        this.amount = amount;
        this.paidBy = paidBy;
        this.timestamp = LocalDateTime.now();
        this.splits = new ArrayList<>();
    }

    // Getters
    public String getId() { return id; }
    public String getDescription() { return description; }
    public double getAmount() { return amount; }
    public User getPaidBy() { return paidBy; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public List<Split> getSplits() { return splits; }

    public void addSplit(Split split) {
        splits.add(split);
    }
}

4. Transaction

Represents a settlement between two users.

public class Transaction {
    private final String id;
    private final User sender;
    private final User receiver;
    private final double amount;
    private final LocalDateTime timestamp;

    public Transaction(String id, User sender, User receiver, double amount) {
        this.id = id;
        this.sender = sender;
        this.receiver = receiver;
        this.amount = amount;
        this.timestamp = LocalDateTime.now();
    }

    // Getters
    public String getId() { return id; }
    public User getSender() { return sender; }
    public User getReceiver() { return receiver; }
    public double getAmount() { return amount; }
    public LocalDateTime getTimestamp() { return timestamp; }
}

Split Strategy: The Strategy Pattern

Different split types, equal, exact, percent, share a common interface but implement different logic. That’s the Strategy pattern.

Split Interface

public interface Split {
    User getUser();
    double getAmount();
}

Abstract Base Class

public abstract class AbstractSplit implements Split {
    protected final User user;
    protected double amount;

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

    @Override
    public User getUser() {
        return user;
    }

    @Override
    public double getAmount() {
        return amount;
    }

    public void setAmount(double amount) {
        this.amount = amount;
    }
}

Equal Split

Divides the expense equally among all participants.

public class EqualSplit extends AbstractSplit {

    public EqualSplit(User user) {
        super(user);
    }

    public static List<EqualSplit> createSplits(List<User> users, double totalAmount) {
        double splitAmount = totalAmount / users.size();
        List<EqualSplit> splits = new ArrayList<>();
        for (User user : users) {
            EqualSplit split = new EqualSplit(user);
            split.setAmount(Math.round(splitAmount * 100.0) / 100.0);
            splits.add(split);
        }
        return splits;
    }
}

Percent Split

Divides the expense by specified percentages.

public class PercentSplit extends AbstractSplit {
    private final double percent;

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

    public double getPercent() {
        return percent;
    }

    public static List<PercentSplit> createSplits(
            List<User> users,
            List<Double> percentages,
            double totalAmount
    ) {
        if (users.size() != percentages.size()) {
            throw new IllegalArgumentException("Users and percentages must match in size");
        }

        double totalPercent = percentages.stream().mapToDouble(Double::doubleValue).sum();
        if (Math.abs(totalPercent - 100.0) > 0.01) {
            throw new IllegalArgumentException("Percentages must sum to 100");
        }

        List<PercentSplit> splits = new ArrayList<>();
        for (int i = 0; i < users.size(); i++) {
            PercentSplit split = new PercentSplit(users.get(i), percentages.get(i));
            split.setAmount(Math.round((totalAmount * percentages.get(i) / 100.0) * 100.0) / 100.0);
            splits.add(split);
        }
        return splits;
    }
}

Exact Split

Divides the expense by exact specified amounts.

public class ExactSplit extends AbstractSplit {

    public ExactSplit(User user) {
        super(user);
    }

    public static List<ExactSplit> createSplits(
            List<User> users,
            List<Double> amounts,
            double totalAmount
    ) {
        if (users.size() != amounts.size()) {
            throw new IllegalArgumentException("Users and amounts must match in size");
        }

        double totalExact = amounts.stream().mapToDouble(Double::doubleValue).sum();
        if (Math.abs(totalExact - totalAmount) > 0.01) {
            throw new IllegalArgumentException(
                String.format("Exact amounts %.2f must sum to total %.2f", totalExact, totalAmount)
            );
        }

        List<ExactSplit> splits = new ArrayList<>();
        for (int i = 0; i < users.size(); i++) {
            ExactSplit split = new ExactSplit(users.get(i));
            split.setAmount(amounts.get(i));
            splits.add(split);
        }
        return splits;
    }
}

Split Factory: The Factory Pattern

A factory creates the appropriate split type dynamically based on user input. This keeps the caller from needing to know which concrete split class to instantiate.

public enum SplitType {
    EQUAL,
    PERCENT,
    EXACT
}

public class SplitFactory {

    public static List<Split> createSplits(
            SplitType type,
            List<User> users,
            double totalAmount,
            Map<String, Object> params
    ) {
        return switch (type) {
            case EQUAL -> {
                @SuppressWarnings("unchecked")
                List<EqualSplit> splits = (List<EqualSplit>) EqualSplit.createSplits(users, totalAmount);
                yield new ArrayList<>(splits);
            }
            case PERCENT -> {
                @SuppressWarnings("unchecked")
                List<Double> percentages = (List<Double>) params.get("percentages");
                @SuppressWarnings("unchecked")
                List<PercentSplit> splits = (List<PercentSplit>) PercentSplit.createSplits(
                    users, percentages, totalAmount
                );
                yield new ArrayList<>(splits);
            }
            case EXACT -> {
                @SuppressWarnings("unchecked")
                List<Double> amounts = (List<Double>) params.get("amounts");
                @SuppressWarnings("unchecked")
                List<ExactSplit> splits = (List<ExactSplit>) ExactSplit.createSplits(
                    users, amounts, totalAmount
                );
                yield new ArrayList<>(splits);
            }
        };
    }
}

Splitwise Service: The Singleton Pattern

The core service manages all operations. I’m using the Singleton pattern here because you want a single, coordinated view of all users, groups, and expenses. In a real system you’d probably replace this with a proper dependency-injected service, but for an LLD the Singleton communicates the intent clearly.

public class SplitwiseService {

    // Singleton instance
    private static volatile SplitwiseService instance;

    // Data stores
    private final Map<String, User> users;
    private final Map<String, Group> groups;
    private final List<Expense> allExpenses;
    private final List<Transaction> allTransactions;

    // Notification observers
    private final List<ExpenseObserver> observers;

    private SplitwiseService() {
        this.users = new ConcurrentHashMap<>();
        this.groups = new ConcurrentHashMap<>();
        this.allExpenses = new CopyOnWriteArrayList<>();
        this.allTransactions = new CopyOnWriteArrayList<>();
        this.observers = new CopyOnWriteArrayList<>();
    }

    // Thread-safe Singleton
    public static SplitwiseService getInstance() {
        if (instance == null) {
            synchronized (SplitwiseService.class) {
                if (instance == null) {
                    instance = new SplitwiseService();
                }
            }
        }
        return instance;
    }

    // ==================== User Management ====================

    public User addUser(String id, String name, String email, String phone) {
        User user = new User(id, name, email, phone);
        users.put(id, user);
        return user;
    }

    public User getUser(String userId) {
        return users.get(userId);
    }

    public List<User> getAllUsers() {
        return new ArrayList<>(users.values());
    }

    // ==================== Group Management ====================

    public Group createGroup(String id, String name, String description, List<User> members) {
        Group group = new Group(id, name, description);
        members.forEach(group::addMember);
        groups.put(id, group);
        return group;
    }

    public Group getGroup(String groupId) {
        return groups.get(groupId);
    }

    public void addMemberToGroup(String groupId, User user) {
        Group group = groups.get(groupId);
        if (group != null) {
            group.addMember(user);
        }
    }

    // ==================== Expense Management ====================

    public Expense addExpense(
            String groupId,
            String expenseId,
            String description,
            double amount,
            User paidBy,
            SplitType splitType,
            List<User> participants,
            Map<String, Object> splitParams
    ) {
        Group group = groups.get(groupId);
        if (group == null) {
            throw new IllegalArgumentException("Group not found: " + groupId);
        }

        // Validate participants are group members
        for (User participant : participants) {
            if (!group.getMembers().contains(participant)) {
                throw new IllegalArgumentException(
                    "User " + participant.getName() + " is not a member of group " + group.getName()
                );
            }
        }

        // Create splits using factory
        List<Split> splits = SplitFactory.createSplits(
            splitType, participants, amount, splitParams
        );

        // Create expense
        Expense expense = new Expense(expenseId, description, amount, paidBy);
        splits.forEach(expense::addSplit);

        // Add to group
        group.addExpense(expense);
        allExpenses.add(expense);

        // Update balance sheet
        updateBalanceSheet(expense);

        // Notify observers
        notifyObservers(expense);

        return expense;
    }

    // ==================== Balance Sheet Update ====================

    private void updateBalanceSheet(Expense expense) {
        User paidBy = expense.getPaidBy();

        for (Split split : expense.getSplits()) {
            User participant = split.getUser();
            double amountOwed = split.getAmount();

            // Skip if payer and participant are the same
            if (paidBy.getId().equals(participant.getId())) {
                continue;
            }

            // Participant owes the payer
            participant.updateBalance(paidBy.getId(), amountOwed);
            paidBy.updateBalance(participant.getId(), -amountOwed);
        }
    }

    // ==================== Settlement ====================

    public List<Transaction> settleBalance(String userId1, String userId2) {
        User user1 = users.get(userId1);
        User user2 = users.get(userId2);

        if (user1 == null || user2 == null) {
            throw new IllegalArgumentException("User not found");
        }

        double balance = user1.getBalanceWith(userId2);
        List<Transaction> transactions = new ArrayList<>();

        if (Math.abs(balance) > 0.01) {
            String transactionId = "txn_" + UUID.randomUUID();

            if (balance > 0) {
                // user1 owes user2
                Transaction txn = new Transaction(transactionId, user1, user2, Math.abs(balance));
                transactions.add(txn);
            } else {
                // user2 owes user1
                Transaction txn = new Transaction(transactionId, user2, user1, Math.abs(balance));
                transactions.add(txn);
            }

            // Reset balances
            user1.updateBalance(userId2, -balance);
            user2.updateBalance(userId1, balance);

            allTransactions.addAll(transactions);
        }

        return transactions;
    }

    // ==================== Simplify Debts (Min Cash Flow) ====================

    public List<Transaction> simplifyDebts(List<User> usersToSettle) {
        int n = usersToSettle.size();
        double[][] graph = new double[n][n];

        // Build the debt graph from balance sheets
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (i != j) {
                    double balance = usersToSettle.get(i).getBalanceWith(
                        usersToSettle.get(j).getId()
                    );
                    if (balance < 0) {
                        graph[i][j] = -balance; // i owes j
                    }
                }
            }
        }

        return minimizeCashFlow(usersToSettle, graph);
    }

    // ==================== Minimum Cash Flow Algorithm ====================

    /**
     * Greedy algorithm to minimize total transactions.
     * Time Complexity: O(n²) where n is number of users
     * Space Complexity: O(n²) for the result matrix
     */
    private List<Transaction> minimizeCashFlow(List<User> users, double[][] graph) {
        int n = users.size();
        List<Transaction> transactions = new ArrayList<>();

        // Calculate net balance for each person
        double[] netAmount = new double[n];
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                netAmount[i] += graph[j][i]; // receives
                netAmount[i] -= graph[i][j]; // gives
            }
        }

        // Max-heaps for debtors (negative) and creditors (positive)
        PriorityQueue<Pair> debtors = new PriorityQueue<>(
            (a, b) -> Double.compare(b.amount, a.amount)
        );
        PriorityQueue<Pair> creditors = new PriorityQueue<>(
            (a, b) -> Double.compare(b.amount, a.amount)
        );

        for (int i = 0; i < n; i++) {
            if (netAmount[i] < -0.01) {
                debtors.offer(new Pair(Math.abs(netAmount[i]), i));
            } else if (netAmount[i] > 0.01) {
                creditors.offer(new Pair(netAmount[i], i));
            }
        }

        // Greedily settle largest debtor with largest creditor
        while (!debtors.isEmpty() && !creditors.isEmpty()) {
            Pair debtor = debtors.poll();
            Pair creditor = creditors.poll();

            double transferAmount = Math.min(debtor.amount, creditor.amount);

            String txnId = "txn_" + UUID.randomUUID();
            Transaction txn = new Transaction(
                txnId,
                users.get(debtor.index),
                users.get(creditor.index),
                transferAmount
            );
            transactions.add(txn);

            // Push back remaining amounts
            if (debtor.amount > transferAmount + 0.01) {
                debtors.offer(new Pair(debtor.amount - transferAmount, debtor.index));
            }
            if (creditor.amount > transferAmount + 0.01) {
                creditors.offer(new Pair(creditor.amount - transferAmount, creditor.index));
            }
        }

        return transactions;
    }

    // Helper class for priority queue
    private static class Pair {
        double amount;
        int index;

        Pair(double amount, int index) {
            this.amount = amount;
            this.index = index;
        }
    }

    // ==================== Observer Pattern (Notifications) ====================

    public void addObserver(ExpenseObserver observer) {
        observers.add(observer);
    }

    public void removeObserver(ExpenseObserver observer) {
        observers.remove(observer);
    }

    private void notifyObservers(Expense expense) {
        for (ExpenseObserver observer : observers) {
            observer.onExpenseAdded(expense);
        }
    }

    // ==================== Utility Methods ====================

    public void printUserBalance(User user) {
        System.out.println("\nBalance sheet for " + user.getName() + ":");
        user.getBalanceSheet().forEach((userId, amount) -> {
            User otherUser = users.get(userId);
            if (otherUser != null && Math.abs(amount) > 0.01) {
                if (amount > 0) {
                    System.out.printf("  %s owes %s: $%.2f%n",
                        user.getName(), otherUser.getName(), amount);
                } else {
                    System.out.printf("  %s owes %s: $%.2f%n",
                        otherUser.getName(), user.getName(), Math.abs(amount));
                }
            }
        });
    }

    public void printAllTransactions() {
        System.out.println("\nAll Transactions:");
        allTransactions.forEach(txn ->
            System.out.printf("  %s pays %s: $%.2f%n",
                txn.getSender().getName(),
                txn.getReceiver().getName(),
                txn.getAmount())
        );
    }
}

// Observer interface
public interface ExpenseObserver {
    void onExpenseAdded(Expense expense);
}

Putting It All Together: Demo

Here’s a complete demo that showcases all the features:

public class SplitwiseDemo {

    public static void main(String[] args) {
        SplitwiseService service = SplitwiseService.getInstance();

        // Add observer for notifications
        service.addObserver(expense ->
            System.out.println("[NOTIFICATION] New expense added: " +
                expense.getDescription() + " by " + expense.getPaidBy().getName())
        );

        // Step 1: Add Users
        System.out.println("=== Step 1: Adding Users ===");
        User alice = service.addUser("u1", "Alice", "alice@email.com", "1234567890");
        User bob = service.addUser("u2", "Bob", "bob@email.com", "0987654321");
        User charlie = service.addUser("u3", "Charlie", "charlie@email.com", "1122334455");
        User diana = service.addUser("u4", "Diana", "diana@email.com", "5566778899");

        System.out.println("Users: " + alice.getName() + ", " + bob.getName() +
            ", " + charlie.getName() + ", " + diana.getName());

        // Step 2: Create Groups
        System.out.println("\n=== Step 2: Creating Groups ===");
        Group trip = service.createGroup("g1", "Goa Trip", "Friends trip to Goa",
            List.of(alice, bob, charlie, diana));

        Group house = service.createGroup("g2", "Household", "Roommate expenses",
            List.of(alice, bob));

        System.out.println("Created groups: '" + trip.getName() + "' and '" + house.getName() + "'");

        // Step 3: Add Expenses - Equal Split
        System.out.println("\n=== Step 3: Adding Expenses ===");
        service.addExpense(
            "g1", "exp1", "Hotel booking", 4000.0,
            alice, SplitType.EQUAL,
            List.of(alice, bob, charlie, diana),
            Map.of()
        );
        System.out.println("Added: Hotel booking - ₹4000 (split equally among 4)");

        // Step 4: Add Expenses - Percent Split
        service.addExpense(
            "g1", "exp2", "Groceries", 1500.0,
            bob, SplitType.PERCENT,
            List.of(alice, bob, charlie, diana),
            Map.of("percentages", List.of(40.0, 30.0, 20.0, 10.0))
        );
        System.out.println("Added: Groceries - ₹1500 (split 40/30/20/10)");

        // Step 5: Add Expenses - Exact Split
        Map<String, Object> exactParams = Map.of("amounts", List.of(2000.0, 1000.0));
        service.addExpense(
            "g2", "exp3", "Electricity bill", 3000.0,
            alice, SplitType.EXACT,
            List.of(alice, bob),
            exactParams
        );
        System.out.println("Added: Electricity bill - ₹3000 (split ₹2000/₹1000)");

        // Step 6: Print Balance Sheets
        System.out.println("\n=== Step 6: Balance Sheets ===");
        service.printUserBalance(alice);
        service.printUserBalance(bob);
        service.printUserBalance(charlie);
        service.printUserBalance(diana);

        // Step 7: Simplify Debts
        System.out.println("\n=== Step 7: Simplified Settlement (Goa Trip) ===");
        List<Transaction> simplified = service.simplifyDebts(
            List.of(alice, bob, charlie, diana)
        );
        System.out.println("\nOptimized transactions:");
        simplified.forEach(txn ->
            System.out.printf("  %s pays %s: ₹%.2f%n",
                txn.getSender().getName(),
                txn.getReceiver().getName(),
                txn.getAmount())
        );

        // Step 8: Settle Specific Balance
        System.out.println("\n=== Step 8: Settle Alice-Bob Balance ===");
        List<Transaction> settled = service.settleBalance("u1", "u2");
        settled.forEach(txn ->
            System.out.printf("  %s pays %s: ₹%.2f%n",
                txn.getSender().getName(),
                txn.getReceiver().getName(),
                txn.getAmount())
        );

        // Step 9: Print All Transactions
        System.out.println("\n=== Step 9: All Transactions ===");
        service.printAllTransactions();

        System.out.println("\n[x] Splitwise Demo Completed Successfully!");
    }
}

Expected Output:

=== Step 1: Adding Users ===
Users: Alice, Bob, Charlie, Diana

=== Step 2: Creating Groups ===
Created groups: 'Goa Trip' and 'Household'

=== Step 3: Adding Expenses ===
[NOTIFICATION] New expense added: Hotel booking by Alice
Added: Hotel booking - ₹4000 (split equally among 4)
[NOTIFICATION] New expense added: Groceries by Bob
Added: Groceries - ₹1500 (split 40/30/20/10)
[NOTIFICATION] New expense added: Electricity bill by Alice
Added: Electricity bill - ₹3000 (split ₹2000/₹1000)

=== Step 6: Balance Sheets ===
Balance sheet for Alice:
  Alice owes Bob: ₹1200.00
  Bob owes Alice: ₹500.00
  Charlie owes Alice: ₹1000.00
  Diana owes Alice: ₹1000.00

=== Step 7: Simplified Settlement (Goa Trip) ===
Optimized transactions:
  Charlie pays Alice: ₹1150.00
  Diana pays Alice: ₹1050.00
  Bob pays Alice: ₹300.00

=== Step 8: Settle Alice-Bob Balance ===
  Alice pays Bob: ₹700.00

[x] Splitwise Demo Completed Successfully!

Design Patterns Summary

PatternWhere UsedWhy
SingletonSplitwiseServiceSingle system-wide instance
StrategySplit interface + implementationsPluggable split algorithms
FactorySplitFactoryDynamic split type creation
ObserverExpenseObserverNotification on expense changes
BuilderExpense/Group creationClean object construction

Database Schema Design

For a production system, here’s the relational database schema:

CREATE TABLE users (
    id VARCHAR(36) PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    phone VARCHAR(20),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE groups (
    id VARCHAR(36) PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    created_by VARCHAR(36) REFERENCES users(id),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE group_members (
    group_id VARCHAR(36) REFERENCES groups(id),
    user_id VARCHAR(36) REFERENCES users(id),
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (group_id, user_id)
);

CREATE TABLE expenses (
    id VARCHAR(36) PRIMARY KEY,
    group_id VARCHAR(36) REFERENCES groups(id),
    description TEXT,
    amount DECIMAL(10, 2) NOT NULL,
    paid_by VARCHAR(36) REFERENCES users(id),
    split_type ENUM('EQUAL', 'PERCENT', 'EXACT') NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE expense_splits (
    id VARCHAR(36) PRIMARY KEY,
    expense_id VARCHAR(36) REFERENCES expenses(id),
    user_id VARCHAR(36) REFERENCES users(id),
    amount DECIMAL(10, 2) NOT NULL,
    percent_share DECIMAL(5, 2)  -- For PERCENT split
);

CREATE TABLE transactions (
    id VARCHAR(36) PRIMARY KEY,
    sender_id VARCHAR(36) REFERENCES users(id),
    receiver_id VARCHAR(36) REFERENCES users(id),
    amount DECIMAL(10, 2) NOT NULL,
    status ENUM('PENDING', 'COMPLETED') DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_expenses_group ON expenses(group_id);
CREATE INDEX idx_splits_expense ON expense_splits(expense_id);
CREATE INDEX idx_transactions_sender ON transactions(sender_id);
CREATE INDEX idx_transactions_receiver ON transactions(receiver_id);

REST API Design

Here are the key endpoints:

# User Management
POST   /api/users                    # Create user
GET    /api/users/{id}               # Get user
GET    /api/users/{id}/balances      # Get balance sheet

# Group Management
POST   /api/groups                   # Create group
GET    /api/groups/{id}              # Get group details
POST   /api/groups/{id}/members      # Add member
DELETE /api/groups/{id}/members/{uid} # Remove member

# Expense Management
POST   /api/groups/{id}/expenses     # Add expense
GET    /api/groups/{id}/expenses     # List expenses
PUT    /api/expenses/{id}            # Update expense
DELETE /api/expenses/{id}            # Delete expense

# Settlement
POST   /api/users/{id}/settle/{uid}  # Settle balance
POST   /api/groups/{id}/simplify     # Simplify all debts
GET    /api/users/{id}/transactions  # Get transaction history

Advanced Features & Extensions

1. Currency Support

public enum Currency {
    USD("$", "US Dollar"),
    EUR("€", "Euro"),
    INR("₹", "Indian Rupee"),
    GBP("£", "British Pound");

    private final String symbol;
    private final String name;

    Currency(String symbol, String name) {
        this.symbol = symbol;
        this.name = name;
    }

    public String getSymbol() { return symbol; }
    public String getName() { return name; }
}

// Add to Expense class
public class Expense {
    // ... existing fields
    private final Currency currency;

    public Expense(String id, String description, double amount,
                   User paidBy, Currency currency) {
        // ... initialization
        this.currency = currency;
    }

    public Currency getCurrency() { return currency; }

    public String getFormattedAmount() {
        return currency.getSymbol() + String.format("%.2f", amount);
    }
}

2. Expense Category & Tags

public enum ExpenseCategory {
    FOOD,
    TRANSPORT,
    ACCOMMODATION,
    ENTERTAINMENT,
    UTILITIES,
    GROCERIES,
    OTHER
}

// Add to Expense
public class Expense {
    // ... existing fields
    private ExpenseCategory category;
    private List<String> tags;
    private String receiptImageUrl;

    // Add receipt upload, categorization, etc.
}

3. Recurring Expenses

public class RecurringExpense {
    private final String id;
    private final Expense template;
    private final RecurrencePattern pattern;
    private final LocalDateTime startDate;
    private final LocalDateTime endDate;

    public enum RecurrencePattern {
        DAILY, WEEKLY, MONTHLY, YEARLY
    }

    // Scheduled service to auto-create expenses
}

4. Comments & Activity Feed

public class Comment {
    private final String id;
    private final User author;
    private final Expense expense;
    private final String content;
    private final LocalDateTime timestamp;
}

public class ActivityLog {
    private final String id;
    private final User actor;
    private final String action;
    private final String entityType;
    private final String entityId;
    private final LocalDateTime timestamp;
}

Complexity Analysis

OperationTime ComplexitySpace Complexity
Add UserO(1)O(1)
Add GroupO(m) where m = membersO(m)
Add ExpenseO(n) where n = participantsO(n)
Update Balance SheetO(n)O(n²) total
Get BalanceO(1)O(1)
Settle Two UsersO(1)O(1)
Simplify DebtsO(n²)O(n²)
Get Group ExpensesO(e) where e = expensesO(e)

Common Interview Follow-ups

Q1: How would you handle floating-point precision issues?

// Use BigDecimal for financial calculations
import java.math.BigDecimal;
import java.math.RoundingMode;

public class Expense {
    private final BigDecimal amount;

    public Expense(String id, String description, BigDecimal amount, User paidBy) {
        this.amount = amount.setScale(2, RoundingMode.HALF_UP);
    }

    // All calculations use BigDecimal
    private BigDecimal calculateSplit(BigDecimal total, int count) {
        return total.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP);
    }
}

Q2: How do you handle concurrent expense additions?

We use ConcurrentHashMap and CopyOnWriteArrayList in our implementation. For production systems:

// Option 1: ReentrantReadWriteLock for fine-grained locking
private final ReentrantReadWriteLock balanceLock = new ReentrantReadWriteLock();

public void updateBalanceSheet(Expense expense) {
    balanceLock.writeLock().lock();
    try {
        // update balances
    } finally {
        balanceLock.writeLock().unlock();
    }
}

// Option 2: Optimistic locking with version field
// Option 3: Database-level transactions with ACID guarantees

Q3: How would you scale this to millions of users?

  • Microservices: split into UserService, GroupService, ExpenseService, BalanceService
  • Caching: Redis for frequently accessed balance sheets
  • Database Sharding: Shard by user ID or group ID
  • Message Queue: Kafka/RabbitMQ for async notifications
  • CDN: For receipt images and attachments

Q4: How does Splitwise’s “Simplify Debts” actually work?

The algorithm converts the pairwise debt graph into net balances, then uses a greedy two-pointer approach with max-heaps:

  1. Calculate each person’s net balance (total owed - total owes)
  2. Separate into debtors (negative) and creditors (positive)
  3. Always settle the largest debtor with the largest creditor
  4. Repeat until all balances are zero

This minimizes the number of transactions, though not necessarily the minimum possible (the exact minimum is NP-hard, but the greedy approach is optimal for practical use).


Testing Your Implementation

class SplitwiseServiceTest {

    private SplitwiseService service;

    @BeforeEach
    void setUp() {
        service = SplitwiseService.getInstance();
    }

    @Test
    void shouldSplitExpenseEqually() {
        User alice = service.addUser("u1", "Alice", "alice@test.com", "123");
        User bob = service.addUser("u2", "Bob", "bob@test.com", "456");

        Group group = service.createGroup("g1", "Test", "", List.of(alice, bob));

        service.addExpense(
            "g1", "exp1", "Lunch", 100.0,
            alice, SplitType.EQUAL,
            List.of(alice, bob),
            Map.of()
        );

        assertEquals(50.0, bob.getBalanceWith(alice.getId()), 0.01);
        assertEquals(-50.0, alice.getBalanceWith(bob.getId()), 0.01);
    }

    @Test
    void shouldSimplifyDebtsCorrectly() {
        // Set up three users with circular debts
        User a = service.addUser("u1", "A", "a@test.com", "1");
        User b = service.addUser("u2", "B", "b@test.com", "2");
        User c = service.addUser("u3", "C", "c@test.com", "3");

        // A owes B: 10, B owes C: 10, C owes A: 10
        a.updateBalance(b.getId(), 10.0);
        b.updateBalance(a.getId(), -10.0);
        b.updateBalance(c.getId(), 10.0);
        c.updateBalance(b.getId(), -10.0);
        c.updateBalance(a.getId(), 10.0);
        a.updateBalance(c.getId(), -10.0);

        List<Transaction> simplified = service.simplifyDebts(List.of(a, b, c));

        // Net balance for everyone is 0, so no transactions needed
        assertTrue(simplified.isEmpty());
    }

    @Test
    void shouldHandlePercentSplit() {
        User a = service.addUser("u1", "A", "a@test.com", "1");
        User b = service.addUser("u2", "B", "b@test.com", "2");

        Group group = service.createGroup("g1", "Test", "", List.of(a, b));

        Map<String, Object> params = Map.of("percentages", List.of(70.0, 30.0));

        service.addExpense(
            "g1", "exp1", "Rent", 1000.0,
            a, SplitType.PERCENT,
            List.of(a, b),
            params
        );

        assertEquals(300.0, b.getBalanceWith(a.getId()), 0.01);
    }
}

Best Practices for Production

1. Use BigDecimal for All Financial Calculations

// Never use double for money
BigDecimal amount = new BigDecimal("100.00");
BigDecimal split = amount.divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP);

2. Implement Idempotent Operations

// Prevent duplicate expense creation
private final Set<String> processedExpenseIds = ConcurrentHashMap.newKeySet();

public Expense addExpense(String expenseId, ...) {
    if (!processedExpenseIds.add(expenseId)) {
        throw new DuplicateExpenseException("Expense already processed: " + expenseId);
    }
    // ... proceed
}

3. Add Audit Logging

public class AuditLogger {
    public void logBalanceChange(User user, String reason, double amount) {
        log.info("User {} balance changed by {} for reason: {}",
            user.getName(), amount, reason);
    }
}

4. Implement Rate Limiting

@RestController
public class ExpenseController {

    @RateLimiter(name = "expenseCreation", fallbackMethod = "rateLimitFallback")
    @PostMapping("/api/groups/{id}/expenses")
    public ResponseEntity<Expense> addExpense(...) {
        // ...
    }
}

Resources for Further Learning


What I Took Away

The thing that surprised me most about this design is how much of it is about communication between objects rather than any single clever algorithm. The Strategy pattern for splits, the Factory for creating them, the Observer for notifications, each one solves a coordination problem.

The debt simplification algorithm is the interesting bit mathematically, but the entity modeling and pattern choices are what interviewers spend the most time probing. They want to see that you can think through the full picture, not just implement a graph algorithm.

If you’re prepping for an LLD interview, I’d suggest implementing this end-to-end, then asking yourself: what would break if a thousand users were adding expenses at the same time? What if someone adds an expense to a group they just left? Those follow-up questions are where the real signal is.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.