11 — LLD: Splitwise
Understanding the Problem
Splitwise is an expense-sharing app where groups of people track shared costs and figure out who owes whom. You go on a trip with friends, one person pays for dinner, another pays for the hotel, and at the end you want to settle up with the fewest possible transactions.
What is Splitwise?
At its core, Splitwise is a directed graph of debts. Every expense creates edges (who owes whom), and the "simplify debts" feature minimizes the number of edges while preserving net balances. The interesting LLD challenge is modeling split types, computing balances efficiently, and implementing debt simplification.
Requirements
Clarifying Questions
You: The core operation is adding an expense paid by one person and split among several people. Should I support different ways of splitting — equal, exact amounts, percentage?
Interviewer: Yes, all three. Equal splits are most common, but exact and percentage splits come up too.
You: When you say "exact amounts," do you mean the payer specifies how much each person owes?
Interviewer: Correct. And for percentage splits, the percentages must add up to 100.
You: Should I support group expenses? Like a "Trip to Goa" group where all expenses are tracked together?
Interviewer: Yes. Users can belong to multiple groups, and each group has its own set of expenses.
You: For settling up — is it a direct payment between two people, or should the system suggest optimal settlement paths?
Interviewer: Both. A user can settle directly with another user, and the system should also offer a "simplify debts" feature that minimizes transactions.
You: Should I handle multiple currencies?
Interviewer: Let us keep it single-currency for the core design. You can discuss how you would extend it.
You: Do I need to handle concurrent expense creation — like two people adding expenses to the same group at the same time?
Interviewer: Mention it, but focus on the core logic first.
Final Requirements
- Users can create expenses with three split types: EQUAL, EXACT, PERCENTAGE.
- Expenses are associated with a group.
- The system maintains a running balance between every pair of users.
- Users can settle up (record a payment from one user to another).
- A "simplify debts" algorithm minimizes the number of transactions needed to settle all debts in a group.
- Expenses are immutable once created (no editing, only adding new ones).
Out of scope: Currency conversion, recurring expenses, expense categories/tags, user authentication.
Core Entities and Relationships
| Entity | Responsibility |
|---|---|
User | Identity — name and ID |
SplitType | Enum: EQUAL, EXACT, PERCENTAGE |
Split | How much a single user owes for a single expense |
Expense | A payment made by one user, split among multiple users |
Group | A collection of users and their shared expenses |
BalanceSheet | Tracks net balances between all pairs of users |
ExpenseService | Orchestrates expense creation, validation, and balance updates |
DebtSimplifier | Implements the debt minimization algorithm |
Why is Split its own class? Because each participant in an expense can owe a different amount. The Split captures the per-person breakdown. An expense with 4 participants has 4 Split objects.
Why separate BalanceSheet from Group? The balance sheet is a computational concern (efficient lookups, updates). The group is a domain concept (membership, expenses). Mixing them violates SRP.
Class Design
Split
State:
user: Useramount: number— what this user owes for this expense
Expense
State:
expenseId: stringdescription: stringamount: number— total amount paidpaidBy: Usersplits: Split[]splitType: SplitTypecreatedAt: Date
Validation: The sum of all split amounts must equal the total expense amount.
BalanceSheet
State:
balances: Map<string, number>— net balance between pairs (keyed by canonical pair key)
The key convention: balances.get("A:B") > 0 means A is owed money by B (i.e., B owes A). We always store with the canonical key where A < B and the sign indicates direction.
Methods:
update(payer, payee, amount)— adjust the balance between two usersgetBalance(userA, userB) -> number— how much userA is owed by userBgetUserBalances(user) -> Map<User, number>— all balances involving a usergetAllDebts() -> Array<[string, string, number]>— all non-zero debts
DebtSimplifier
Methods:
simplify(debts) -> Array<[string, string, number]>— returns minimized transaction list
Final Class Design
enum SplitType {
EQUAL = "equal",
EXACT = "exact",
PERCENTAGE = "percentage",
}
class User {
constructor(
public readonly userId: string,
public readonly name: string,
) {}
}
class Split {
constructor(
public readonly user: User,
public amount: number = 0,
) {}
}
class Expense {
public readonly createdAt: Date;
constructor(
public readonly expenseId: string,
public readonly description: string,
public readonly amount: number,
public readonly paidBy: User,
public readonly splits: Split[],
public readonly splitType: SplitType,
) {
this.createdAt = new Date();
}
}Design principles at play:
- Single Responsibility:
BalanceSheetonly tracks numbers.DebtSimplifieronly runs the algorithm.ExpenseServiceonly orchestrates. - Strategy Pattern (implicit): The three split types are different strategies for computing per-person amounts. We use a factory method to create splits based on type.
- Open/Closed: Adding a new split type (e.g., SHARES — split by ratio) means adding one more branch in the split calculator without changing existing logic.
Implementation
Core Logic: Split Calculation
class SplitCalculator {
static calculate(
splitType: SplitType,
totalAmount: number,
participants: User[],
exactAmounts?: Record<string, number>,
percentages?: Record<string, number>,
): Split[] {
if (splitType === SplitType.EQUAL) {
const perPerson = Math.round((totalAmount / participants.length) * 100) / 100;
const splits = participants.map((u) => new Split(u, perPerson));
// Handle rounding — assign remainder to first person
const remainder =
Math.round((totalAmount - perPerson * participants.length) * 100) / 100;
if (remainder !== 0) {
splits[0].amount = Math.round((splits[0].amount + remainder) * 100) / 100;
}
return splits;
}
if (splitType === SplitType.EXACT) {
if (!exactAmounts) {
throw new Error("Exact amounts required for EXACT split");
}
const total = Object.values(exactAmounts).reduce((a, b) => a + b, 0);
if (Math.abs(total - totalAmount) > 0.01) {
throw new Error(
`Exact amounts sum to ${total}, expected ${totalAmount}`
);
}
return participants.map(
(u) => new Split(u, exactAmounts[u.userId]),
);
}
if (splitType === SplitType.PERCENTAGE) {
if (!percentages) {
throw new Error("Percentages required for PERCENTAGE split");
}
const totalPct = Object.values(percentages).reduce((a, b) => a + b, 0);
if (Math.abs(totalPct - 100.0) > 0.01) {
throw new Error(`Percentages sum to ${totalPct}, expected 100`);
}
return participants.map(
(u) =>
new Split(
u,
Math.round((totalAmount * percentages[u.userId]) / 100 * 100) / 100,
),
);
}
throw new Error(`Unknown split type: ${splitType}`);
}
}Core Logic: Balance Sheet
class BalanceSheet {
// balances.get("a:b") = amount a is owed by b
// Positive means b owes a. We always store with min_id first.
private balances: Map<string, number> = new Map();
private key(aId: string, bId: string): string {
return aId <= bId ? `${aId}:${bId}` : `${bId}:${aId}`;
}
private sign(aId: string, bId: string): number {
return aId <= bId ? 1 : -1;
}
update(creditorId: string, debtorId: string, amount: number): void {
if (creditorId === debtorId) return;
const k = this.key(creditorId, debtorId);
const s = this.sign(creditorId, debtorId);
this.balances.set(k, (this.balances.get(k) ?? 0) + s * amount);
}
getBalance(aId: string, bId: string): number {
const k = this.key(aId, bId);
const s = this.sign(aId, bId);
return (this.balances.get(k) ?? 0) * s;
}
getAllDebts(): Array<[string, string, number]> {
const debts: Array<[string, string, number]> = [];
for (const [key, amount] of this.balances) {
if (Math.abs(amount) < 0.01) continue;
const [aId, bId] = key.split(":");
if (amount > 0) {
debts.push([aId, bId, amount]); // b owes a
} else {
debts.push([bId, aId, -amount]); // a owes b
}
}
return debts;
}
getNetBalances(): Map<string, number> {
const net = new Map<string, number>();
for (const [key, amount] of this.balances) {
const [aId, bId] = key.split(":");
net.set(aId, (net.get(aId) ?? 0) + amount);
net.set(bId, (net.get(bId) ?? 0) - amount);
}
return net;
}
}Core Logic: Debt Simplification
The key insight: to minimize transactions, you only care about each person's net balance. If Alice is owed $50 net and Bob owes $30 net and Carol owes $20 net, the optimal settlement is Bob pays Alice $30 and Carol pays Alice $20 — regardless of the original expense structure.
Bad approach: Settle each pairwise debt individually. If 5 people have expenses, you could have up to 10 pairwise debts.
Good approach: Compute net balances, then greedily match the largest creditor with the largest debtor.
Great approach: Compute net balances, separate into creditors (positive) and debtors (negative), then use a greedy two-pointer approach. This gives at most n-1 transactions for n people.
class DebtSimplifier {
static simplify(
netBalances: Map<string, number>,
): Array<[string, string, number]> {
/**
* Given net balances, return minimal transactions to settle all debts.
* Returns: array of [payerId, payeeId, amount]
*/
const creditors: [string, number][] = []; // [userId, amountOwedToThem]
const debtors: [string, number][] = []; // [userId, amountTheyOwe]
for (const [userId, balance] of netBalances) {
if (balance > 0.01) {
creditors.push([userId, balance]);
} else if (balance < -0.01) {
debtors.push([userId, -balance]);
}
}
// Sort both by amount descending for greedy matching
creditors.sort((a, b) => b[1] - a[1]);
debtors.sort((a, b) => b[1] - a[1]);
const transactions: Array<[string, string, number]> = [];
let i = 0;
let j = 0;
while (i < creditors.length && j < debtors.length) {
const [creditorId, credit] = creditors[i];
const [debtorId, debt] = debtors[j];
const settleAmount = Math.min(credit, debt);
transactions.push([debtorId, creditorId, Math.round(settleAmount * 100) / 100]);
creditors[i] = [creditorId, credit - settleAmount];
debtors[j] = [debtorId, debt - settleAmount];
if (creditors[i][1] < 0.01) i++;
if (debtors[j][1] < 0.01) j++;
}
return transactions;
}
}Core Logic: Expense Service
class Group {
public members: Map<string, User> = new Map();
public expenses: Expense[] = [];
public balanceSheet = new BalanceSheet();
constructor(
public readonly groupId: string,
public readonly name: string,
) {}
addMember(user: User): void {
this.members.set(user.userId, user);
}
addExpense(expense: Expense): void {
this.expenses.push(expense);
// Update balances: each split user owes the payer
for (const split of expense.splits) {
if (split.user.userId !== expense.paidBy.userId) {
this.balanceSheet.update(
expense.paidBy.userId,
split.user.userId,
split.amount,
);
}
}
}
settleUp(payerId: string, payeeId: string, amount: number): void {
this.balanceSheet.update(payerId, payeeId, amount);
}
getSimplifiedDebts(): Array<[string, string, number]> {
const net = this.balanceSheet.getNetBalances();
return DebtSimplifier.simplify(net);
}
}
class ExpenseService {
public users: Map<string, User> = new Map();
public groups: Map<string, Group> = new Map();
createUser(userId: string, name: string): User {
const user = new User(userId, name);
this.users.set(userId, user);
return user;
}
createGroup(groupId: string, name: string, memberIds: string[]): Group {
const group = new Group(groupId, name);
for (const mid of memberIds) {
const user = this.users.get(mid);
if (user) {
group.addMember(user);
}
}
this.groups.set(groupId, group);
return group;
}
addExpense(
groupId: string,
expenseId: string,
description: string,
amount: number,
paidById: string,
participantIds: string[],
splitType: SplitType,
exactAmounts?: Record<string, number>,
percentages?: Record<string, number>,
): Expense {
const group = this.groups.get(groupId)!;
const payer = this.users.get(paidById)!;
const participants = participantIds.map((uid) => this.users.get(uid)!);
const splits = SplitCalculator.calculate(
splitType,
amount,
participants,
exactAmounts,
percentages,
);
const expense = new Expense(
expenseId,
description,
amount,
payer,
splits,
splitType,
);
group.addExpense(expense);
return expense;
}
}Edge Cases
- Rounding errors in equal splits — $100 split 3 ways is $33.33, $33.33, $33.34. The calculator assigns the extra cent to the first participant.
- Payer is also a participant — Common case (you pay for dinner you ate too). The payer's split is excluded from balance updates because you do not owe yourself.
- Zero-amount splits — A participant with 0% in a percentage split gets a $0 split. This is valid but does not create a balance entry.
- Single-person expense — If the payer is the only participant, no balance changes occur. This is an expense you paid entirely for yourself.
- Exact amounts do not add up — Validation catches this and raises an error.
Verification
Setup: Group "Trip to Goa" with Alice (A), Bob (B), and Carol (C).
Step 1: Alice pays $300 for hotel, split equally.
- Per person: $100 each.
- Alice paid, so: B owes A $100, C owes A $100.
- Balance sheet: A->B: +100, A->C: +100.
Step 2: Bob pays $150 for dinner, split equally.
- Per person: $50 each.
- Bob paid, so: A owes B $50, C owes B $50.
- Balance sheet: A->B: +100-50 = +50, A->C: +100, B->C: +50.
Step 3: Carol pays $90 for cab, exact split: A=$40, B=$30, C=$20.
- Carol paid. A owes C $40, B owes C $30.
- Balance sheet: A->B: +50, A->C: +100-40 = +60, B->C: +50-30 = +20.
Step 4: Check pairwise debts.
- B owes A $50.
- C owes A $60.
- C owes B $20.
- Total: 3 transactions.
Step 5: Simplify debts.
- Net balances: A = +50+60 = +110, B = -50+20 = -30, C = -60-20 = -80.
- Creditors: [(A, 110)]. Debtors: [(C, 80), (B, 30)].
- Transaction 1: C pays A $80. A still owed $30.
- Transaction 2: B pays A $30. Done.
- Result: 2 transactions instead of 3.
Complete Code Implementation
enum SplitType {
EQUAL = "equal",
EXACT = "exact",
PERCENTAGE = "percentage",
}
class User {
constructor(
public readonly userId: string,
public readonly name: string,
) {}
}
class Split {
constructor(
public readonly user: User,
public amount: number = 0,
) {}
}
class Expense {
public readonly createdAt: Date;
constructor(
public readonly expenseId: string,
public readonly description: string,
public readonly amount: number,
public readonly paidBy: User,
public readonly splits: Split[],
public readonly splitType: SplitType,
) {
this.createdAt = new Date();
}
}
class SplitCalculator {
static calculate(
splitType: SplitType,
totalAmount: number,
participants: User[],
exactAmounts?: Record<string, number>,
percentages?: Record<string, number>,
): Split[] {
if (splitType === SplitType.EQUAL) {
const perPerson = Math.round((totalAmount / participants.length) * 100) / 100;
const splits = participants.map((u) => new Split(u, perPerson));
const remainder =
Math.round((totalAmount - perPerson * participants.length) * 100) / 100;
if (remainder !== 0) {
splits[0].amount = Math.round((splits[0].amount + remainder) * 100) / 100;
}
return splits;
}
if (splitType === SplitType.EXACT) {
if (!exactAmounts) {
throw new Error("Exact amounts required");
}
const total = Object.values(exactAmounts).reduce((a, b) => a + b, 0);
if (Math.abs(total - totalAmount) > 0.01) {
throw new Error(`Amounts sum to ${total}, expected ${totalAmount}`);
}
return participants.map((u) => new Split(u, exactAmounts[u.userId]));
}
if (splitType === SplitType.PERCENTAGE) {
if (!percentages) {
throw new Error("Percentages required");
}
const totalPct = Object.values(percentages).reduce((a, b) => a + b, 0);
if (Math.abs(totalPct - 100.0) > 0.01) {
throw new Error(`Percentages sum to ${totalPct}, expected 100`);
}
return participants.map(
(u) =>
new Split(
u,
Math.round((totalAmount * percentages[u.userId]) / 100 * 100) / 100,
),
);
}
throw new Error(`Unknown split type: ${splitType}`);
}
}
class BalanceSheet {
private balances: Map<string, number> = new Map();
private key(aId: string, bId: string): string {
return aId <= bId ? `${aId}:${bId}` : `${bId}:${aId}`;
}
private sign(aId: string, bId: string): number {
return aId <= bId ? 1 : -1;
}
update(creditorId: string, debtorId: string, amount: number): void {
if (creditorId === debtorId) return;
const k = this.key(creditorId, debtorId);
const s = this.sign(creditorId, debtorId);
this.balances.set(k, (this.balances.get(k) ?? 0) + s * amount);
}
getBalance(aId: string, bId: string): number {
const k = this.key(aId, bId);
const s = this.sign(aId, bId);
return (this.balances.get(k) ?? 0) * s;
}
getNetBalances(): Map<string, number> {
const net = new Map<string, number>();
for (const [key, amount] of this.balances) {
const [aId, bId] = key.split(":");
net.set(aId, (net.get(aId) ?? 0) + amount);
net.set(bId, (net.get(bId) ?? 0) - amount);
}
return net;
}
}
class DebtSimplifier {
static simplify(
netBalances: Map<string, number>,
): Array<[string, string, number]> {
const creditors: [string, number][] = [];
const debtors: [string, number][] = [];
for (const [userId, balance] of netBalances) {
if (balance > 0.01) {
creditors.push([userId, balance]);
} else if (balance < -0.01) {
debtors.push([userId, -balance]);
}
}
creditors.sort((a, b) => b[1] - a[1]);
debtors.sort((a, b) => b[1] - a[1]);
const transactions: Array<[string, string, number]> = [];
let i = 0;
let j = 0;
while (i < creditors.length && j < debtors.length) {
const [creditorId, credit] = creditors[i];
const [debtorId, debt] = debtors[j];
const settleAmount = Math.min(credit, debt);
transactions.push([debtorId, creditorId, Math.round(settleAmount * 100) / 100]);
creditors[i] = [creditorId, credit - settleAmount];
debtors[j] = [debtorId, debt - settleAmount];
if (creditors[i][1] < 0.01) i++;
if (debtors[j][1] < 0.01) j++;
}
return transactions;
}
}
class Group {
public members: Map<string, User> = new Map();
public expenses: Expense[] = [];
public balanceSheet = new BalanceSheet();
constructor(
public readonly groupId: string,
public readonly name: string,
) {}
addMember(user: User): void {
this.members.set(user.userId, user);
}
addExpense(expense: Expense): void {
this.expenses.push(expense);
for (const split of expense.splits) {
if (split.user.userId !== expense.paidBy.userId) {
this.balanceSheet.update(
expense.paidBy.userId,
split.user.userId,
split.amount,
);
}
}
}
settleUp(payerId: string, payeeId: string, amount: number): void {
this.balanceSheet.update(payerId, payeeId, amount);
}
getSimplifiedDebts(): Array<[string, string, number]> {
const net = this.balanceSheet.getNetBalances();
return DebtSimplifier.simplify(net);
}
}
class ExpenseService {
public users: Map<string, User> = new Map();
public groups: Map<string, Group> = new Map();
createUser(userId: string, name: string): User {
const user = new User(userId, name);
this.users.set(userId, user);
return user;
}
createGroup(groupId: string, name: string, memberIds: string[]): Group {
const group = new Group(groupId, name);
for (const mid of memberIds) {
const user = this.users.get(mid);
if (user) group.addMember(user);
}
this.groups.set(groupId, group);
return group;
}
addExpense(
groupId: string,
expenseId: string,
description: string,
amount: number,
paidById: string,
participantIds: string[],
splitType: SplitType,
exactAmounts?: Record<string, number>,
percentages?: Record<string, number>,
): Expense {
const group = this.groups.get(groupId)!;
const payer = this.users.get(paidById)!;
const participants = participantIds.map((uid) => this.users.get(uid)!);
const splits = SplitCalculator.calculate(
splitType,
amount,
participants,
exactAmounts,
percentages,
);
const expense = new Expense(
expenseId,
description,
amount,
payer,
splits,
splitType,
);
group.addExpense(expense);
return expense;
}
}
// ---- Demo ----
const svc = new ExpenseService();
const alice = svc.createUser("A", "Alice");
const bob = svc.createUser("B", "Bob");
const carol = svc.createUser("C", "Carol");
const group = svc.createGroup("G1", "Trip to Goa", ["A", "B", "C"]);
// Alice pays $300 for hotel, equal split
svc.addExpense("G1", "E1", "Hotel", 300, "A", ["A", "B", "C"], SplitType.EQUAL);
// Bob pays $150 for dinner, equal split
svc.addExpense("G1", "E2", "Dinner", 150, "B", ["A", "B", "C"], SplitType.EQUAL);
// Carol pays $90 for cab, exact split
svc.addExpense(
"G1", "E3", "Cab", 90, "C", ["A", "B", "C"],
SplitType.EXACT, { A: 40, B: 30, C: 20 },
);
console.log("Simplified debts:");
for (const [payer, payee, amount] of group.getSimplifiedDebts()) {
console.log(` ${payer} pays ${payee}: $${amount}`);
}Extensibility
1. Adding a New Split Type: SHARES
If users want to split by ratio (e.g., 2:1:1), add a SHARES variant to SplitType and a new branch in SplitCalculator:
// Inside SplitCalculator.calculate:
if (splitType === SplitType.SHARES) {
const totalShares = Object.values(shares!).reduce((a, b) => a + b, 0);
return participants.map(
(u) =>
new Split(
u,
Math.round((totalAmount * shares![u.userId]) / totalShares * 100) / 100,
),
);
}Tradeoff: Each new split type is one more branch. If you expect many split types, refactor to a Strategy pattern where each type is a class implementing calculate(amount, participants, params) -> Split[]. For 4-5 types, an if/else is cleaner.
2. Multi-Currency Support
Add a currency: string field to Expense. The BalanceSheet becomes per-currency, or you introduce a CurrencyConverter that normalizes all amounts to a base currency before computing net balances.
class CurrencyConverter {
constructor(private rates: Record<string, number>) {}
// e.g., { USD: 1.0, INR: 0.012 }
toBase(amount: number, currency: string): number {
return amount * this.rates[currency];
}
}Tradeoff: Real-time exchange rates fluctuate. Do you lock the rate at expense creation time or at settlement time? Most apps lock at creation time for predictability.
3. Expense Editing and Deletion
Currently expenses are immutable. To support editing, you would create a "reverse" expense that undoes the balance changes of the original, then create a new expense with updated values. This is the event-sourcing approach — you never mutate, you append corrections.
Tradeoff: More storage, but you get a complete audit trail. For a personal expense-sharing app, this is overkill. For a fintech product, it is essential.
What is Expected at Each Level
| Level | Expectations |
|---|---|
| Junior | Model User, Expense, and Group. Implement equal splits and pairwise balance tracking. May not handle debt simplification. |
| Mid-level | All three split types with validation. Clean balance sheet abstraction. Basic debt simplification using net balances. Discuss rounding edge cases. |
| Senior | Everything above plus: greedy debt simplification algorithm with correctness argument, canonical key approach for balance sheet to avoid duplicate entries, event-sourcing discussion for auditability, multi-currency extension strategy, analysis of why greedy works (net balances are conserved). |