Hirestack
Hirestack
Chapter 11

Problem 7: Design Splitwise

📖 5 min read · 10 sections · May 2026
Asked at: Amazon Razorpay Splitwise Flipkart

The setup

"Design Splitwise. Users in groups split expenses (dinner, rent, trips). The system tracks who owes whom; supports equal, exact, and percentage splits; settles debts efficiently."

This problem combines domain modeling (Group, Expense, Split, Balance) with an interesting algorithmic challenge (debt simplification: minimize the number of transactions to settle). Strong candidates handle both well.

Clarifying questions

QuestionStory answer
Split types?Equal, exact (specific amounts), percentage.
Multi-currency?Single currency for v1.
Group structure — nested groups?Flat groups (a group is just a set of users).
Settlement — actual payment or just record?Record only — Splitwise tracks, doesn't process money.
Notifications?Mention briefly; not core.

Core entities

EntityResponsibility
UserA person; has id, name, email
GroupCollection of users; may have a name
ExpenseAn incurred cost paid by one user, owed by multiple
Split (abstract)How an expense is divided. EqualSplit, ExactSplit, PercentSplit
BalanceSheetPer-user-pair running balance: "A owes B ₹X"
SplitwiseServiceFacade for adding expenses, getting balances, settling

The Split hierarchy — Strategy pattern

Each split type computes per-user amounts differently. Same interface, different algorithms:

abstract class Split {
  User user;
  BigDecimal amount;
}

interface SplitStrategy {
  // For an expense of `totalAmount` paid by `payer`, divide among `participants`
  // and return list of Splits (user → amount they owe)
  List compute(BigDecimal totalAmount, User payer, List participants, Map params);
}

class EqualSplitStrategy implements SplitStrategy {
  public List compute(BigDecimal total, User payer, List participants, Map params) {
    BigDecimal share = total.divide(BigDecimal.valueOf(participants.size()), 2, RoundingMode.HALF_UP);
    return participants.stream()
      .map(u -> new Split(u, share))
      .collect(toList());
  }
}

class ExactSplitStrategy implements SplitStrategy {
  public List compute(BigDecimal total, User payer, List participants, Map params) {
    Map exactAmounts = (Map) params.get("exactAmounts");
    // Validate: sum of exact amounts equals total
    BigDecimal sum = exactAmounts.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
    if (!sum.equals(total)) throw new InvalidSplitException("Exact amounts must sum to total");
    return participants.stream()
      .map(u -> new Split(u, exactAmounts.get(u)))
      .collect(toList());
  }
}

class PercentSplitStrategy implements SplitStrategy {
  public List compute(BigDecimal total, User payer, List participants, Map params) {
    Map percentages = (Map) params.get("percentages");
    BigDecimal sumPct = percentages.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
    if (sumPct.compareTo(new BigDecimal("100")) != 0) {
      throw new InvalidSplitException("Percentages must sum to 100");
    }
    return participants.stream()
      .map(u -> new Split(u, total.multiply(percentages.get(u)).divide(new BigDecimal("100"))))
      .collect(toList());
  }
}

The Balance Sheet

Track who owes whom. Simple representation: a map from (user, user) pair to balance.

class BalanceSheet {
  // (debtor, creditor) → amount debtor owes creditor
  private Map balances = new HashMap<>();
  
  public void addExpense(User payer, List splits) {
    for (Split split : splits) {
      if (split.user.equals(payer)) continue;  // payer doesn't owe themselves
      // split.user owes payer split.amount
      UserPair pair = new UserPair(split.user, payer);
      balances.merge(pair, split.amount, BigDecimal::add);
      // Optimization: if other direction has balance, net them
      UserPair reverse = new UserPair(payer, split.user);
      if (balances.containsKey(reverse)) {
        BigDecimal owedByPayer = balances.get(reverse);
        BigDecimal owedToPayer = balances.get(pair);
        BigDecimal net = owedToPayer.subtract(owedByPayer);
        if (net.signum() > 0) {
          balances.put(pair, net);
          balances.remove(reverse);
        } else {
          balances.put(reverse, net.abs());
          balances.remove(pair);
        }
      }
    }
  }
  
  public BigDecimal getBalance(User debtor, User creditor) {
    return balances.getOrDefault(new UserPair(debtor, creditor), BigDecimal.ZERO);
  }
  
  public Map getOverallBalance(User user) {
    // Net balance for this user across all their relationships
    Map result = new HashMap<>();
    for (var entry : balances.entrySet()) {
      UserPair pair = entry.getKey();
      BigDecimal amount = entry.getValue();
      if (pair.debtor.equals(user)) {
        result.merge(pair.creditor, amount.negate(), BigDecimal::add);
      } else if (pair.creditor.equals(user)) {
        result.merge(pair.debtor, amount, BigDecimal::add);
      }
    }
    return result;
  }
}

The SplitwiseService

class SplitwiseService {
  private final BalanceSheet balanceSheet;
  private final Map strategies = Map.of(
    SplitType.EQUAL, new EqualSplitStrategy(),
    SplitType.EXACT, new ExactSplitStrategy(),
    SplitType.PERCENT, new PercentSplitStrategy()
  );
  
  public Expense addExpense(User payer, BigDecimal amount, List participants, 
                             SplitType type, Map splitParams) {
    SplitStrategy strategy = strategies.get(type);
    List splits = strategy.compute(amount, payer, participants, splitParams);
    Expense expense = new Expense(payer, amount, splits);
    balanceSheet.addExpense(payer, splits);
    return expense;
  }
  
  public Map getBalanceFor(User user) {
    return balanceSheet.getOverallBalance(user);
  }
}

Design Decision: debt simplification

An interesting algorithmic problem. If A owes B ₹100, B owes C ₹100, then A directly owes C ₹100 — we can eliminate B from the picture. Minimum transaction settlement is an instance of the cash-flow simplification problem.

class DebtSimplifier {
  // Given net balances per user, output minimum-transaction settlement
  public List simplify(Map netBalances) {
    // Positive balance: net creditor (others owe them); negative: net debtor
    PriorityQueue creditors = new PriorityQueue<>((a, b) -> b.amount.compareTo(a.amount));
    PriorityQueue debtors = new PriorityQueue<>((a, b) -> b.amount.compareTo(a.amount));
    
    for (var entry : netBalances.entrySet()) {
      if (entry.getValue().signum() > 0) creditors.offer(new UserAmount(entry.getKey(), entry.getValue()));
      else if (entry.getValue().signum() < 0) debtors.offer(new UserAmount(entry.getKey(), entry.getValue().abs()));
    }
    
    List result = new ArrayList<>();
    while (!creditors.isEmpty() && !debtors.isEmpty()) {
      UserAmount creditor = creditors.poll();
      UserAmount debtor = debtors.poll();
      BigDecimal settled = creditor.amount.min(debtor.amount);
      result.add(new Transaction(debtor.user, creditor.user, settled));
      BigDecimal remCredit = creditor.amount.subtract(settled);
      BigDecimal remDebt = debtor.amount.subtract(settled);
      if (remCredit.signum() > 0) creditors.offer(new UserAmount(creditor.user, remCredit));
      if (remDebt.signum() > 0) debtors.offer(new UserAmount(debtor.user, remDebt));
    }
    return result;
  }
}

This greedy approach (largest creditor matched with largest debtor) doesn't always produce the minimum number of transactions (which is NP-hard in general), but it's a good heuristic that's correct for typical cases.

Cross-examination round 1: floating-point precision

Interviewer: Three people split ₹100 equally. Each owes ₹33.33. But 33.33 × 3 = 99.99 ≠ 100. Issue?
Classic rounding issue. Use BigDecimal for currency, never double. For unequal-but-equal splits, allocate the remainder to one person: 33.34 + 33.33 + 33.33 = 100. Pick the recipient deterministically (e.g., first user, or rotating). Document the rule. Production payment systems handle this explicitly to avoid disputes.

Cross-examination round 2: concurrency

Interviewer: Two users add expenses to the same group simultaneously. Race conditions in BalanceSheet?
The BalanceSheet's addExpense mutates the balances map. Two concurrent calls can interleave reads and writes, producing wrong balances. Mitigations: (a) synchronize the addExpense method — simple, works at small scale. (b) Per-user-pair locks — more concurrent, complex. (c) Use a transactional database where addExpense is wrapped in a DB transaction with optimistic locking. Production: option (c) for any real Splitwise-style service.

Takeaway

Splitwise is the canonical Strategy-pattern example: split types are interchangeable algorithms with the same shape. The interesting bit beyond the pattern is the balance-sheet bookkeeping and the debt-simplification algorithm. Together they demonstrate that LLD isn't just about classes — it's about modeling the domain accurately and handling the algorithmic concerns that fall out.