Splitwise Low Level Design: Complete LLD Guide with Java Implementation

Master Splitwise LLD design for system design interviews. Learn entity modeling, design patterns, balance simplification algorithm, and complete Java implementation for expense sharing apps.

Splitwise Low Level Design: Complete LLD Guide with Java Implementation

Splitwise is one of the most frequently asked Low Level Design (LLD) interview questions at top tech companies like Amazon, Flipkart, Google, and Microsoft. In this comprehensive guide, we’ll design a production-ready Splitwise clone from scratch — complete with entity modeling, design patterns, balance simplification algorithms, and a full Java implementation.

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 makes it simple to track who owes whom.

“Splitwise solves a non-trivial graph optimization problem: How do you settle debts with the minimum number of transactions? Behind the scenes, it uses graph algorithms to minimize money flow.” — System Design Experts

Why This Matters for Interviews

When interviewers ask you to design Splitwise, they’re evaluating:

  • 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 these aspects in depth.


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

This is where the Strategy Design Pattern shines. Different split types implement different logic, but share a common interface.

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

Use a factory to create the appropriate split type dynamically.

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 and uses the Singleton Pattern to ensure a single instance.

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✅ 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

✅ 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);

API Design

For a RESTful API, 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


Conclusion

Designing Splitwise is an excellent way to demonstrate mastery of object-oriented design, design patterns, and algorithmic thinking. Here’s what we covered:

Entity Design — User, Group, Expense, Split, Transaction
Design Patterns — Singleton, Strategy, Factory, Observer
Split Types — Equal, Percent, Exact with polymorphic behavior
Balance Sheet — Pairwise balance tracking with O(1) lookups
Simplify Debts — Minimum cash flow algorithm using greedy approach
Thread Safety — ConcurrentHashMap and CopyOnWriteArrayList
Production Ready — BigDecimal, idempotency, audit logging

This design is extensible, testable, and follows SOLID principles — exactly what interviewers are looking for.

Pro Tip: In interviews, start with functional requirements, identify core entities, design the class hierarchy with interfaces, discuss design patterns, and then write code. Always mention trade-offs and how you’d scale the system.

Happy designing! 🚀

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.