A friend group I’m in used Splitwise for years. Every trip, every dinner, every “who paid for the groceries” moment, it all went through Splitwise. I never thought about how it worked until it came up in an interview. The interviewer asked me to design it from scratch, and I realized I had no idea how the “simplify debts” feature actually worked under the hood.
That sent me down a rabbit hole. Splitwise is a deceptively rich LLD problem, it touches entity modeling, multiple design patterns, graph algorithms for debt simplification, and thread safety. It shows up at Amazon, Google, Microsoft, and Flipkart for exactly that reason. Here’s the full design, with a Java implementation you can actually run.
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 keeps track of who owes whom, so nobody has to.
The interesting part isn’t the UI or the notification system. It’s the graph optimization problem underneath: how do you settle debts with the minimum number of transactions?
What Interviewers Are Actually Evaluating
When someone asks you to design Splitwise, they’re checking for:
- 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 those.
Functional Requirements
Before writing any code, let’s define what our Splitwise system should do:
Core Features
-
User Management
- Register and manage users with ID, name, email, and phone
- View user’s balance sheet with all other users
-
Group Management
- Create groups with multiple members
- Add/remove members from groups
- View all expenses within a group
-
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
-
Balance Sheet
- Track pairwise balances between users
- View “who owes whom” and “who is owed by whom”
- Real-time balance updates
-
Settlement & Simplification
- Settle debts between two users
- Simplify debts - Minimize the number of transactions using graph algorithms
- Support partial settlements
-
Notifications
- Notify users when they’re added to an expense
- Alert users when they owe money
Non-Functional Requirements
| Requirement | Description |
|---|---|
| Scalability | Handle millions of users and groups |
| Thread Safety | Concurrent expense additions must be safe |
| Extensibility | Easy to add new split types (e.g., shares, ratios) |
| Performance | O(1) balance lookup, O(n) expense creation |
| Consistency | Balance sheet must always be accurate |
| Persistence | Store 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
Different split types, equal, exact, percent, share a common interface but implement different logic. That’s the Strategy pattern.
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
A factory creates the appropriate split type dynamically based on user input. This keeps the caller from needing to know which concrete split class to instantiate.
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. I’m using the Singleton pattern here because you want a single, coordinated view of all users, groups, and expenses. In a real system you’d probably replace this with a proper dependency-injected service, but for an LLD the Singleton communicates the intent clearly.
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[x] 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
[x] Splitwise Demo Completed Successfully!
Design Patterns Summary
| Pattern | Where Used | Why |
|---|---|---|
| Singleton | SplitwiseService | Single system-wide instance |
| Strategy | Split interface + implementations | Pluggable split algorithms |
| Factory | SplitFactory | Dynamic split type creation |
| Observer | ExpenseObserver | Notification on expense changes |
| Builder | Expense/Group creation | Clean 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);
REST API Design
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
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| Add User | O(1) | O(1) |
| Add Group | O(m) where m = members | O(m) |
| Add Expense | O(n) where n = participants | O(n) |
| Update Balance Sheet | O(n) | O(n²) total |
| Get Balance | O(1) | O(1) |
| Settle Two Users | O(1) | O(1) |
| Simplify Debts | O(n²) | O(n²) |
| Get Group Expenses | O(e) where e = expenses | O(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:
- Calculate each person’s net balance (total owed - total owes)
- Separate into debtors (negative) and creditors (positive)
- Always settle the largest debtor with the largest creditor
- 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
- Awesome Low Level Design - Splitwise
- GeeksforGeeks - Minimize Cash Flow
- Splitwise Official Website
- System Design Primer
- Refactoring Guru - Design Patterns
What I Took Away
The thing that surprised me most about this design is how much of it is about communication between objects rather than any single clever algorithm. The Strategy pattern for splits, the Factory for creating them, the Observer for notifications, each one solves a coordination problem.
The debt simplification algorithm is the interesting bit mathematically, but the entity modeling and pattern choices are what interviewers spend the most time probing. They want to see that you can think through the full picture, not just implement a graph algorithm.
If you’re prepping for an LLD interview, I’d suggest implementing this end-to-end, then asking yourself: what would break if a thousand users were adding expenses at the same time? What if someone adds an expense to a group they just left? Those follow-up questions are where the real signal is.
Member discussion
0 commentsStart the conversation
Become a member of >hacksubset_ to start commenting.
Already a member? Sign in