Skip to content
1 min read L2

Design Splitwise (Expense Sharing)

Interview Context

Asked at: Google, Amazon, Uber, Flipkart | Level: L4-L6 | Time: 45 minutes | Type: LLD/OOP Design | Difficulty: Medium-Hard


Requirements

Functional

  • Users can create groups and add members
  • Add expenses with multiple participants and a payer
  • Support split types: Equal, Exact amount, Percentage
  • Track balances between every pair of users
  • Simplify debts — minimize the number of transactions needed to settle
  • Show per-user balance summary (total owed, total owing)

Non-Functional

  • Accurate to the cent (no rounding drift)
  • Handle concurrent expense additions within the same group
  • Low latency balance queries (precomputed net balances)
  • Extensible to new split types without modifying existing code

Class Diagram

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
classDiagram
    class User {
        -String userId
        -String name
        -String email
    }

    class Group {
        -String groupId
        -String name
        -List~User~ members
        -List~Expense~ expenses
        +addExpense(Expense) void
        +getBalances() Map
    }

    class Expense {
        -String expenseId
        -double amount
        -User paidBy
        -List~Split~ splits
        -String description
        -LocalDateTime createdAt
    }

    class Split {
        -User user
        -double amount
    }

    class SplitStrategy {
        <<interface>>
        +split(double, List~User~, Map~String,Double~) List~Split~
        +validate(double, List~User~, Map~String,Double~) boolean
    }

    class EqualSplitStrategy {
        +split(double, List~User~, Map) List~Split~
        +validate(double, List~User~, Map) boolean
    }

    class ExactSplitStrategy {
        +split(double, List~User~, Map) List~Split~
        +validate(double, List~User~, Map) boolean
    }

    class PercentageSplitStrategy {
        +split(double, List~User~, Map) List~Split~
        +validate(double, List~User~, Map) boolean
    }

    class ExpenseService {
        -Map~String,Group~ groups
        -BalanceSheet balanceSheet
        +addExpense(String, Expense) void
        +getBalances(String) Map
    }

    class BalanceSheet {
        -Map~String,Map~String,Double~~ balanceGraph
        +update(User, List~Split~) void
        +getNetBalances() Map
        +simplifyDebts() List~Transaction~
    }

    class Transaction {
        -User from
        -User to
        -double amount
    }

    Group "1" --> "*" User
    Group "1" --> "*" Expense
    Expense "1" --> "*" Split
    Expense --> User : paidBy
    Split --> User
    ExpenseService --> BalanceSheet
    ExpenseService --> SplitStrategy
    EqualSplitStrategy ..|> SplitStrategy
    ExactSplitStrategy ..|> SplitStrategy
    PercentageSplitStrategy ..|> SplitStrategy
    BalanceSheet --> Transaction

Balance Graph Simplification

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
graph LR
    subgraph Before["Before: 5 debts"]
        A1[Alice] -->|$40| B1[Bob]
        B1 -->|$20| C1[Charlie]
        C1 -->|$50| A1
        A1 -->|$10| D1[Dave]
        D1 -->|$30| B1
    end

    subgraph After["After: 2 transactions"]
        A2[Alice] -->|$20| C2[Charlie]
        D2[Dave] -->|$20| B2[Bob]
    end

Net calculation: Alice: -40+50-10 = 0 (net zero after simplification reveals she owes Charlie $20). Bob: +40-20+30 = +50 (owed $50 total). Charlie: +20-50 = -30 (owes $30). Dave: +10-30 = -20 (owes $20). The greedy algorithm settles max creditor with max debtor iteratively, reducing 5 debts to 2 transactions.


Key Design Decisions

Decision Choice Why
Split calculation Strategy Pattern Equal, Exact, Percentage — new types added without modifying core
Balance tracking Adjacency map (graph) O(1) lookup for any user-pair balance
Debt simplification Greedy algorithm Minimizes transactions; optimal for interviews
Rounding Assign remainder to first user Prevents penny-loss drift on equal splits
Expense immutability Immutable after creation Simplifies concurrency, audit trail

Java Implementation

Java
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;
    }
    // getters
}

public class Split {
    private final User user;
    private final double amount;

    public Split(User user, double amount) {
        this.user = user;
        this.amount = amount;
    }
    // getters
}

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

    public Expense(String id, double amount, User paidBy,
                   List<Split> splits, String description) {
        this.expenseId = id;
        this.amount = amount;
        this.paidBy = paidBy;
        this.splits = List.copyOf(splits); // immutable
        this.description = description;
        this.createdAt = LocalDateTime.now();
    }
}

public class Transaction {
    private final User from;
    private final User to;
    private final double amount;

    public Transaction(User from, User to, double amount) {
        this.from = from;
        this.to = to;
        this.amount = amount;
    }
}
Java
public class ExpenseService {
    private final Map<String, Group> groups = new ConcurrentHashMap<>();
    private final BalanceSheet balanceSheet = new BalanceSheet();
    private final Map<SplitType, SplitStrategy> strategies;

    public ExpenseService() {
        strategies = Map.of(
            SplitType.EQUAL, new EqualSplitStrategy(),
            SplitType.EXACT, new ExactSplitStrategy(),
            SplitType.PERCENTAGE, new PercentageSplitStrategy()
        );
    }

    public void addExpense(String groupId, User paidBy, double amount,
                           SplitType type, List<User> participants,
                           Map<String, Double> params) {
        SplitStrategy strategy = strategies.get(type);
        if (!strategy.validate(amount, participants, params)) {
            throw new IllegalArgumentException("Invalid split parameters");
        }

        List<Split> splits = strategy.split(amount, participants, params);
        Expense expense = new Expense(
            UUID.randomUUID().toString(), amount, paidBy, splits, "Expense"
        );

        groups.get(groupId).addExpense(expense);
        balanceSheet.update(paidBy, splits);
    }

    public Map<String, Double> getUserBalances(String userId) {
        return balanceSheet.getBalancesForUser(userId);
    }

    public List<Transaction> settleUp(String groupId) {
        return balanceSheet.simplifyDebts(
            groups.get(groupId).getMembers()
        );
    }
}
Java
public enum SplitType { EQUAL, EXACT, PERCENTAGE }

public interface SplitStrategy {
    List<Split> split(double amount, List<User> participants,
                      Map<String, Double> params);
    boolean validate(double amount, List<User> participants,
                     Map<String, Double> params);
}

public class EqualSplitStrategy implements SplitStrategy {
    @Override
    public List<Split> split(double amount, List<User> participants,
                             Map<String, Double> params) {
        int n = participants.size();
        double each = Math.floor(amount * 100 / n) / 100.0;
        double remainder = amount - (each * n);

        List<Split> splits = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            double share = (i == 0) ? each + remainder : each;
            splits.add(new Split(participants.get(i), share));
        }
        return splits;
    }

    @Override
    public boolean validate(double amount, List<User> participants,
                            Map<String, Double> params) {
        return amount > 0 && participants.size() > 0;
    }
}

public class ExactSplitStrategy implements SplitStrategy {
    @Override
    public List<Split> split(double amount, List<User> participants,
                             Map<String, Double> params) {
        return participants.stream()
            .map(u -> new Split(u, params.get(u.getUserId())))
            .toList();
    }

    @Override
    public boolean validate(double amount, List<User> participants,
                            Map<String, Double> params) {
        double sum = participants.stream()
            .mapToDouble(u -> params.getOrDefault(u.getUserId(), 0.0))
            .sum();
        return Math.abs(sum - amount) < 0.01;
    }
}

public class PercentageSplitStrategy implements SplitStrategy {
    @Override
    public List<Split> split(double amount, List<User> participants,
                             Map<String, Double> params) {
        return participants.stream()
            .map(u -> new Split(u,
                amount * params.get(u.getUserId()) / 100.0))
            .toList();
    }

    @Override
    public boolean validate(double amount, List<User> participants,
                            Map<String, Double> params) {
        double totalPercent = participants.stream()
            .mapToDouble(u -> params.getOrDefault(u.getUserId(), 0.0))
            .sum();
        return Math.abs(totalPercent - 100.0) < 0.01;
    }
}
Java
public class BalanceSheet {
    // balanceGraph[A][B] > 0 means A owes B that amount
    private final Map<String, Map<String, Double>> balanceGraph =
        new ConcurrentHashMap<>();

    public void update(User paidBy, List<Split> splits) {
        for (Split split : splits) {
            if (split.getUser().equals(paidBy)) continue;

            String payerId = paidBy.getUserId();
            String owerId = split.getUser().getUserId();

            // owerId owes payerId
            addBalance(owerId, payerId, split.getAmount());
            // net off: reduce reverse direction
            addBalance(payerId, owerId, -split.getAmount());
        }
    }

    private synchronized void addBalance(String from, String to, double amount) {
        balanceGraph
            .computeIfAbsent(from, k -> new ConcurrentHashMap<>())
            .merge(to, amount, Double::sum);
    }

    public Map<String, Double> getBalancesForUser(String userId) {
        Map<String, Double> result = new HashMap<>();
        Map<String, Double> owes = balanceGraph.getOrDefault(userId, Map.of());
        owes.forEach((other, amt) -> {
            if (Math.abs(amt) > 0.01) result.put(other, amt);
        });
        return result;
    }

    /**
     * Greedy settlement: minimize number of transactions.
     * 1. Compute net balance for each user
     * 2. Repeatedly settle max creditor with max debtor
     */
    public List<Transaction> simplifyDebts(List<User> members) {
        // Step 1: compute net amounts
        Map<String, Double> netBalance = new HashMap<>();
        Map<String, User> userMap = new HashMap<>();
        for (User u : members) {
            userMap.put(u.getUserId(), u);
            netBalance.put(u.getUserId(), 0.0);
        }

        for (var entry : balanceGraph.entrySet()) {
            String from = entry.getKey();
            for (var inner : entry.getValue().entrySet()) {
                String to = inner.getKey();
                double amt = inner.getValue();
                if (members.stream().anyMatch(m ->
                        m.getUserId().equals(from))) {
                    netBalance.merge(from, -amt, Double::sum);
                    netBalance.merge(to, amt, Double::sum);
                }
            }
        }

        // Step 2: greedy matching
        List<Transaction> transactions = new ArrayList<>();
        PriorityQueue<Map.Entry<String, Double>> creditors =
            new PriorityQueue<>((a, b) ->
                Double.compare(b.getValue(), a.getValue()));
        PriorityQueue<Map.Entry<String, Double>> debtors =
            new PriorityQueue<>(Comparator.comparingDouble(
                Map.Entry::getValue));

        netBalance.forEach((uid, amt) -> {
            if (amt > 0.01) creditors.offer(Map.entry(uid, amt));
            else if (amt < -0.01) debtors.offer(Map.entry(uid, amt));
        });

        while (!creditors.isEmpty() && !debtors.isEmpty()) {
            var creditor = creditors.poll();
            var debtor = debtors.poll();

            double settled = Math.min(
                creditor.getValue(), -debtor.getValue());
            transactions.add(new Transaction(
                userMap.get(debtor.getKey()),
                userMap.get(creditor.getKey()),
                settled
            ));

            double credRem = creditor.getValue() - settled;
            double debtRem = debtor.getValue() + settled;

            if (credRem > 0.01)
                creditors.offer(Map.entry(creditor.getKey(), credRem));
            if (debtRem < -0.01)
                debtors.offer(Map.entry(debtor.getKey(), debtRem));
        }
        return transactions;
    }
}

SOLID Principles Applied

Principle How Applied
S — Single Responsibility BalanceSheet handles balances only; SplitStrategy handles split math only; ExpenseService orchestrates
O — Open/Closed New split types (e.g., ShareBasedSplit) added by implementing SplitStrategy — zero changes to existing code
L — Liskov Substitution Any SplitStrategy impl substitutes without caller knowing the concrete type
I — Interface Segregation SplitStrategy has only split() + validate() — no bloated interface
D — Dependency Inversion ExpenseService depends on SplitStrategy interface, injected via map — not hardcoded

Scaling Considerations (If Interviewer Asks)

"What if..." Answer
Millions of users Shard balance graph by group; each group is independent
Real-time notifications Publish expense events to message queue, push via WebSocket
Currency conversion Add Currency field to Expense, convert at settlement time
Recurring expenses Scheduler creates expense copies on cron (decorator around ExpenseService)
Audit trail Event sourcing — store all expense events, recompute balances from log
Concurrent edits Optimistic locking on group version; retry on conflict

Common Interview Mistakes

Mistake Why It's Wrong
Not simplifying debts The entire point of Splitwise — 5 debts between 4 people must reduce to 2
Hardcoding split logic with if/else Violates OCP — use Strategy pattern for split types
Storing only pairwise debts without netting Leads to circular debts (A owes B, B owes A) — always net off
Ignoring rounding on equal splits $100 / 3 = 33.33 x 3 = 99.99 — one penny vanishes
Over-engineering with microservices This is an in-process OOP design, not a system design question
Forgetting validation in split strategies Exact amounts must sum to total; percentages must sum to 100

Interview Walkthrough (45 minutes)

Time What to Do
0-5 min Clarify: split types needed, group support, settlement algorithm expectations
5-12 min Draw class diagram — User, Group, Expense, Split, SplitStrategy, BalanceSheet
12-20 min Explain balance graph and simplification algorithm with a 4-person example
20-32 min Code: SplitStrategy implementations, BalanceSheet with greedy settlement
32-40 min Walk through an example: 3 expenses, show how balances update, then simplify
40-45 min Discuss: SOLID adherence, extensibility (new split types), edge cases (rounding)