Splitwise is one of the most frequently asked Low Level Design (LLD) interview questions at top tech companies like Amazon, Flipkart, Google, and Microsoft. In this comprehensive guide, we’ll design a production-ready Splitwise clone from scratch — complete with entity modeling, design patterns, balance simplification algorithms, and a full Java implementation.
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 makes it simple to track who owes whom.
“Splitwise solves a non-trivial graph optimization problem: How do you settle debts with the minimum number of transactions? Behind the scenes, it uses graph algorithms to minimize money flow.” — System Design Experts
Why This Matters for Interviews
When interviewers ask you to design Splitwise, they’re evaluating:
- 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 these aspects in depth.
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
This is where the Strategy Design Pattern shines. Different split types implement different logic, but share a common interface.
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
Use a factory to create the appropriate split type dynamically.
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 and uses the Singleton Pattern to ensure a single instance.
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✅ 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
✅ 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);
API Design
For a RESTful API, 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
Conclusion
Designing Splitwise is an excellent way to demonstrate mastery of object-oriented design, design patterns, and algorithmic thinking. Here’s what we covered:
✅ Entity Design — User, Group, Expense, Split, Transaction
✅ Design Patterns — Singleton, Strategy, Factory, Observer
✅ Split Types — Equal, Percent, Exact with polymorphic behavior
✅ Balance Sheet — Pairwise balance tracking with O(1) lookups
✅ Simplify Debts — Minimum cash flow algorithm using greedy approach
✅ Thread Safety — ConcurrentHashMap and CopyOnWriteArrayList
✅ Production Ready — BigDecimal, idempotency, audit logging
This design is extensible, testable, and follows SOLID principles — exactly what interviewers are looking for.
Pro Tip: In interviews, start with functional requirements, identify core entities, design the class hierarchy with interfaces, discuss design patterns, and then write code. Always mention trade-offs and how you’d scale the system.
Happy designing! 🚀
Member discussion
0 commentsStart the conversation
Become a member of >hacksubset_ to start commenting.
Already a member? Sign in