Episode 9 — System Design / 9.6 — LLD Problem Solving
9.6.d — Splitwise (Expense Sharing)
Problem: Design an expense-sharing application where users can create groups, add expenses with different split strategies (equal, exact, percentage), track balances, and simplify debts to minimize the number of transactions.
Table of Contents
- Requirements
- Entities and Enums
- Class Diagram
- Split Strategies
- Simplify Debts Algorithm
- Sequence Flow
- Complete Implementation
- Usage Walkthrough
- Edge Cases
- Key Takeaways
Requirements
Functional Requirements
- Users can register with a name, email, and phone.
- Users can create groups (e.g., "Trip to Goa", "Flat expenses").
- A user can add an expense to a group, specifying:
- Who paid (the payer).
- The total amount.
- How to split among members (equal, exact amounts, or percentages).
- The system tracks balances — who owes whom and how much.
- The system can simplify debts to minimize the number of transactions.
- Users can view their overall balance and per-group balance.
Non-Functional Requirements
- Amounts should be precise (handle rounding).
- Extensible split strategies (future: by shares, by items).
Out of Scope
- Payment integration.
- Expense categories and analytics.
- Recurring expenses.
Entities and Enums
┌────────────────────┐
│ SplitType │
│────────────────────│
│ EQUAL │
│ EXACT │
│ PERCENTAGE │
└────────────────────┘
How Each Split Works
| Split Type | Input | Example (Total: $300, 3 people) |
|---|---|---|
| EQUAL | Just the participants | Each pays $100 |
| EXACT | Exact amount per person | A: $150, B: $100, C: $50 |
| PERCENTAGE | Percentage per person | A: 50%, B: 30%, C: 20% -> $150, $90, $60 |
Class Diagram
┌──────────────────────────────────┐
│ User │
│──────────────────────────────────│
│ - id: string │
│ - name: string │
│ - email: string │
│ - phone: string │
└──────────────────────────────────┘
│
│ belongs to many
▼
┌──────────────────────────────────┐
│ Group │
│──────────────────────────────────│
│ - id: string │
│ - name: string │
│ - members: User[] │
│ - expenses: Expense[] │
│──────────────────────────────────│
│ + addMember(user): void │
│ + addExpense(expense): void │
│ + getBalances(): Map │
│ + simplifyDebts(): Transaction[] │
└──────────────┬───────────────────┘
│ has-many
▼
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ Expense │ │ SplitStrategy (iface) │
│──────────────────────────────────│ │──────────────────────────────│
│ - id: string │ │ + split(amount, splits): Map │
│ - description: string │ │ + validate(amount, splits) │
│ - amount: number │ └──────────┬───────────────────┘
│ - paidBy: User │ │ implemented by
│ - splits: Split[] │ ┌─────────┼──────────┐
│ - splitType: SplitType │ ▼ ▼ ▼
│ - createdAt: Date │ EqualSplit ExactSplit PercentSplit
│──────────────────────────────────│
│ + getSplitAmounts(): Map │
└──────────────────────────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ Split │ │ ExpenseService │
│──────────────────────────────────│ │──────────────────────────────│
│ - user: User │ │ - users: Map<id, User> │
│ - amount: number (for EXACT) │ │ - groups: Map<id, Group> │
│ - percentage: number (for %) │ │──────────────────────────────│
└──────────────────────────────────┘ │ + addUser(name, email, phone) │
│ + createGroup(name, members) │
│ + addExpense(...) │
│ + getBalances(groupId) │
│ + simplifyDebts(groupId) │
│ + getUserBalance(userId) │
└──────────────────────────────┘
Split Strategies
Equal Split
Total: $300 Participants: [Alice, Bob, Charlie]
Each share = 300 / 3 = $100
If Alice paid:
Bob owes Alice $100
Charlie owes Alice $100
Exact Split
Total: $300 Splits: [Alice: $150, Bob: $100, Charlie: $50]
Validation: 150 + 100 + 50 = 300 (must equal total)
If Alice paid:
Bob owes Alice $100
Charlie owes Alice $50
(Alice's own share of $150 cancels out)
Percentage Split
Total: $300 Splits: [Alice: 50%, Bob: 30%, Charlie: 20%]
Validation: 50 + 30 + 20 = 100 (must equal 100%)
Amounts: Alice: $150, Bob: $90, Charlie: $60
If Alice paid:
Bob owes Alice $90
Charlie owes Alice $60
Simplify Debts Algorithm
The key insight: we only care about each person's net balance (total owed - total paid). Then we match the biggest creditor with the biggest debtor.
BEFORE simplification:
Alice -> Bob: $50
Alice -> Charlie: $30
Bob -> Charlie: $20
Charlie -> Alice: $10
3 people, 4 transactions
STEP 1: Calculate net balances
Alice: -50 - 30 + 10 = -$70 (net debtor)
Bob: +50 - 20 = +$30 (net creditor)
Charlie: +30 + 20 - 10 = +$40 (net creditor)
STEP 2: Sort: debtors [-70] and creditors [+30, +40]
STEP 3: Greedily match largest debtor with largest creditor
Alice pays Charlie $40 -> Alice: -$30, Charlie: $0
Alice pays Bob $30 -> Alice: $0, Bob: $0
AFTER simplification:
Alice -> Charlie: $40
Alice -> Bob: $30
Only 2 transactions (reduced from 4)
Sequence Flow
Adding an Expense
User ExpenseService Group SplitStrategy
│ │ │ │
│── addExpense ─►│ │ │
│ (groupId, │── validate ─────────────────────────►│
│ paidBy, │◄── ok ──────────────────────────────│
│ amount, │ │ │
│ splitType, │── split() ──────────────────────────►│
│ splits) │◄── amounts map ─────────────────────│
│ │ │ │
│ │── addExpense() ─►│ │
│ │◄── done ─────────│ │
│◄── expense ────│ │ │
Complete Implementation
Enums and User
// ─── Enums ──────────────────────────────────────────────
const SplitType = Object.freeze({
EQUAL: 'EQUAL',
EXACT: 'EXACT',
PERCENTAGE: 'PERCENTAGE',
});
User
// ─── User ───────────────────────────────────────────────
class User {
static _counter = 0;
constructor(name, email, phone) {
this.id = `U-${++User._counter}`;
this.name = name;
this.email = email;
this.phone = phone;
}
}
Split Strategies (Strategy Pattern)
// ─── Split Strategies ───────────────────────────────────
class EqualSplitStrategy {
validate(amount, splits) {
if (splits.length === 0) {
throw new Error('At least one participant is required for equal split.');
}
return true;
}
split(amount, splits) {
// splits = [{ user }] (no amount/percentage needed)
const perPerson = Math.round((amount / splits.length) * 100) / 100;
const result = new Map();
let remaining = amount;
for (let i = 0; i < splits.length; i++) {
if (i === splits.length - 1) {
// Last person gets the remainder to handle rounding
result.set(splits[i].user.id, Math.round(remaining * 100) / 100);
} else {
result.set(splits[i].user.id, perPerson);
remaining -= perPerson;
}
}
return result;
}
}
class ExactSplitStrategy {
validate(amount, splits) {
const total = splits.reduce((sum, s) => sum + s.amount, 0);
if (Math.abs(total - amount) > 0.01) {
throw new Error(
`Exact split amounts ($${total}) do not add up to total ($${amount}).`
);
}
return true;
}
split(amount, splits) {
// splits = [{ user, amount }]
const result = new Map();
for (const s of splits) {
result.set(s.user.id, s.amount);
}
return result;
}
}
class PercentageSplitStrategy {
validate(amount, splits) {
const totalPercent = splits.reduce((sum, s) => sum + s.percentage, 0);
if (Math.abs(totalPercent - 100) > 0.01) {
throw new Error(
`Percentages add up to ${totalPercent}%, not 100%.`
);
}
return true;
}
split(amount, splits) {
// splits = [{ user, percentage }]
const result = new Map();
let remaining = amount;
for (let i = 0; i < splits.length; i++) {
if (i === splits.length - 1) {
result.set(splits[i].user.id, Math.round(remaining * 100) / 100);
} else {
const share = Math.round((amount * splits[i].percentage / 100) * 100) / 100;
result.set(splits[i].user.id, share);
remaining -= share;
}
}
return result;
}
}
// Strategy factory
const SPLIT_STRATEGIES = Object.freeze({
[SplitType.EQUAL]: new EqualSplitStrategy(),
[SplitType.EXACT]: new ExactSplitStrategy(),
[SplitType.PERCENTAGE]: new PercentageSplitStrategy(),
});
Expense
// ─── Expense ────────────────────────────────────────────
class Expense {
static _counter = 0;
constructor(description, amount, paidBy, splits, splitType) {
const strategy = SPLIT_STRATEGIES[splitType];
strategy.validate(amount, splits);
this.id = `EXP-${++Expense._counter}`;
this.description = description;
this.amount = amount;
this.paidBy = paidBy; // User who paid
this.splitType = splitType;
this.splitAmounts = strategy.split(amount, splits); // Map<userId, amount>
this.createdAt = new Date();
}
}
Group
// ─── Group ──────────────────────────────────────────────
class Group {
static _counter = 0;
constructor(name, members) {
this.id = `GRP-${++Group._counter}`;
this.name = name;
this.members = new Map(); // userId -> User
this.expenses = [];
for (const member of members) {
this.members.set(member.id, member);
}
}
addMember(user) {
this.members.set(user.id, user);
}
addExpense(expense) {
this.expenses.push(expense);
}
// ── Calculate balances ────────────────────────────────
// Returns a Map of: "payerId->owerId" -> amount
// Meaning: the balances between every pair of users
getBalances() {
// balanceMap: userId -> net amount (positive = owed money, negative = owes money)
const netBalances = new Map();
for (const [userId] of this.members) {
netBalances.set(userId, 0);
}
for (const expense of this.expenses) {
const payerId = expense.paidBy.id;
for (const [userId, share] of expense.splitAmounts) {
if (userId === payerId) continue; // Payer's own share
// userId owes payerId the share amount
// Payer's balance goes up (they are owed money)
netBalances.set(payerId, (netBalances.get(payerId) || 0) + share);
// Ower's balance goes down (they owe money)
netBalances.set(userId, (netBalances.get(userId) || 0) - share);
}
}
return netBalances;
}
// ── Simplify Debts ────────────────────────────────────
// Minimize number of transactions using greedy algorithm
simplifyDebts() {
const netBalances = this.getBalances();
// Separate into creditors (positive) and debtors (negative)
const creditors = []; // { userId, amount }
const debtors = []; // { userId, amount } (stored as positive)
for (const [userId, balance] of netBalances) {
if (balance > 0.01) {
creditors.push({ userId, amount: balance });
} else if (balance < -0.01) {
debtors.push({ userId, amount: -balance }); // Make positive
}
}
// Sort descending by amount
creditors.sort((a, b) => b.amount - a.amount);
debtors.sort((a, b) => b.amount - a.amount);
// Greedy matching
const transactions = [];
let i = 0; // creditor pointer
let j = 0; // debtor pointer
while (i < creditors.length && j < debtors.length) {
const creditor = creditors[i];
const debtor = debtors[j];
const settleAmount = Math.min(creditor.amount, debtor.amount);
transactions.push({
from: debtor.userId,
fromName: this.members.get(debtor.userId)?.name,
to: creditor.userId,
toName: this.members.get(creditor.userId)?.name,
amount: Math.round(settleAmount * 100) / 100,
});
creditor.amount -= settleAmount;
debtor.amount -= settleAmount;
if (creditor.amount < 0.01) i++;
if (debtor.amount < 0.01) j++;
}
return transactions;
}
}
ExpenseService (Controller)
// ─── ExpenseService ─────────────────────────────────────
class ExpenseService {
constructor() {
this.users = new Map(); // userId -> User
this.groups = new Map(); // groupId -> Group
}
// ── User management ───────────────────────────────────
addUser(name, email, phone) {
const user = new User(name, email, phone);
this.users.set(user.id, user);
console.log(`[USER] Created: ${user.name} (${user.id})`);
return user;
}
// ── Group management ──────────────────────────────────
createGroup(name, members) {
const group = new Group(name, members);
this.groups.set(group.id, group);
console.log(
`[GROUP] Created: "${group.name}" with ${members.map(m => m.name).join(', ')}`
);
return group;
}
// ── Expense management ────────────────────────────────
addExpense(groupId, description, amount, paidBy, splits, splitType) {
const group = this.groups.get(groupId);
if (!group) throw new Error(`Group ${groupId} not found.`);
const expense = new Expense(description, amount, paidBy, splits, splitType);
group.addExpense(expense);
console.log(
`[EXPENSE] "${description}" $${amount} paid by ${paidBy.name} ` +
`(${splitType} split) in "${group.name}"`
);
return expense;
}
// ── Balance queries ───────────────────────────────────
getGroupBalances(groupId) {
const group = this.groups.get(groupId);
if (!group) throw new Error(`Group ${groupId} not found.`);
const balances = group.getBalances();
const result = [];
for (const [userId, balance] of balances) {
const user = this.users.get(userId) || group.members.get(userId);
result.push({
user: user?.name || userId,
balance: Math.round(balance * 100) / 100,
status: balance > 0.01 ? 'is owed' : balance < -0.01 ? 'owes' : 'settled',
});
}
return result;
}
getSimplifiedDebts(groupId) {
const group = this.groups.get(groupId);
if (!group) throw new Error(`Group ${groupId} not found.`);
return group.simplifyDebts();
}
// Overall balance for a user across all groups
getUserBalance(userId) {
let netBalance = 0;
const details = [];
for (const [groupId, group] of this.groups) {
if (!group.members.has(userId)) continue;
const balances = group.getBalances();
const balance = balances.get(userId) || 0;
netBalance += balance;
if (Math.abs(balance) > 0.01) {
details.push({
group: group.name,
balance: Math.round(balance * 100) / 100,
});
}
}
return {
userId,
netBalance: Math.round(netBalance * 100) / 100,
groups: details,
};
}
}
Usage Walkthrough
// ─── Demo ───────────────────────────────────────────────
const service = new ExpenseService();
// Create users
const alice = service.addUser('Alice', 'alice@email.com', '111');
const bob = service.addUser('Bob', 'bob@email.com', '222');
const charlie = service.addUser('Charlie', 'charlie@email.com', '333');
const dave = service.addUser('Dave', 'dave@email.com', '444');
// Create a group
const tripGroup = service.createGroup('Goa Trip', [alice, bob, charlie, dave]);
// ── Expense 1: Hotel ($4000) paid by Alice, split equally ──
service.addExpense(
tripGroup.id,
'Hotel stay',
4000,
alice,
[{ user: alice }, { user: bob }, { user: charlie }, { user: dave }],
SplitType.EQUAL
);
// Each person's share: $1000
// Bob, Charlie, Dave each owe Alice $1000
// ── Expense 2: Dinner ($600) paid by Bob, exact split ──
service.addExpense(
tripGroup.id,
'Dinner',
600,
bob,
[
{ user: alice, amount: 100 },
{ user: bob, amount: 200 },
{ user: charlie, amount: 150 },
{ user: dave, amount: 150 },
],
SplitType.EXACT
);
// Alice owes Bob $100, Charlie owes Bob $150, Dave owes Bob $150
// ── Expense 3: Cab ($300) paid by Charlie, percentage split ──
service.addExpense(
tripGroup.id,
'Cab rides',
300,
charlie,
[
{ user: alice, percentage: 25 },
{ user: bob, percentage: 25 },
{ user: charlie, percentage: 25 },
{ user: dave, percentage: 25 },
],
SplitType.PERCENTAGE
);
// Each: $75. Alice, Bob, Dave each owe Charlie $75
// ── View balances ───────────────────────────────────────
console.log('\n--- Group Balances ---');
console.log(service.getGroupBalances(tripGroup.id));
// Alice: +1000 -100 -75 = +$825 (is owed)
// Bob: -1000 +400 -75 = -$675 (owes — net is +100+150+150-1000-75)
// Actually: Bob paid dinner(600), gets back 400. Owes hotel 1000, cab 75.
// Bob net: -1000 + (600-200) - 75 = -1000 + 400 - 75 = -675
// Charlie: -1000 -150 +225 = -$925 (owes)
// Dave: -1000 -150 -75 = -$1225 (owes) ... let's check
// ── Simplified debts ───────────────────────────────────
console.log('\n--- Simplified Debts ---');
const debts = service.getSimplifiedDebts(tripGroup.id);
for (const d of debts) {
console.log(` ${d.fromName} pays ${d.toName}: $${d.amount}`);
}
// Minimal number of transactions to settle all debts
// ── Per-user balance ────────────────────────────────────
console.log('\n--- Alice overall ---');
console.log(service.getUserBalance(alice.id));
Edge Cases
| Edge Case | How It Is Handled |
|---|---|
| Exact split does not add up to total | ExactSplitStrategy.validate() throws error |
| Percentages do not add up to 100% | PercentageSplitStrategy.validate() throws error |
| Rounding errors in equal split | Last person gets the remainder |
| User not in group adds expense | Should validate (add check in addExpense) |
| Group with 1 person | Equal split gives full amount to that person; no debts |
| All debts already settled | simplifyDebts() returns empty array |
| Payer is also in the split | Payer's own share is skipped in balance calculation |
Key Takeaways
- Strategy Pattern for split types makes adding new split methods (by shares, by items) trivial — just add a new strategy class.
- Net balance calculation is the core algorithm: for each expense, the payer gains credit and each participant gains debt.
- Debt simplification uses a greedy two-pointer approach on sorted creditors and debtors — this minimizes transactions.
- Rounding is a real-world concern: always round to 2 decimal places and give the remainder to the last person.
- The
ExpenseServiceacts as a facade, giving users a clean API without exposing internal Group/Expense logic.
Next -> 9.6.e — Uber/Ola