Low-Level Design (LLD): Building Splitwise (Expense Splitting App)
1. Why Do We Need This? (Problem Statement & Status Quo)
In modern social life, group activities—such as hostel trips, dinners, and flat-sharing expenses—frequently involve shared expenses where one person pays upfront and others owe proportionate shares. Keeping track of these IOUs mentally or through ad-hoc messaging apps quickly becomes chaotic and error-prone.
A production-grade expense-sharing application (like Splitwise) must solve several core distributed state management and logic problems:
- Group Expense Tracking: Managing multi-user groups where members join, leave, and add various types of shared expenses.
- Flexible Splitting Strategies: Supporting multiple mathematical algorithms to partition a single bill (e.g., equal splits, percentage splits, exact/unequal splits).
- Guaranteed Financial Integrity (Safety Constraints): Preventing members from abandoning a group while they still have outstanding debts or unsettled balances with other users.
- Individual vs. Group Isolation: Managing both group-level expenses and one-on-one (peer-to-peer) transactions outside formal groups.
- Transaction Simplification: Minimizing the total number of physical transfers required to settle all debts across a group using a greedy algorithmic approach.
- Asynchronous Notifications: Alerting all affected group members in real-time whenever an expense is added or settled.
2. Core Mental Model & Real-Life Analogy
Real-Life Analogy: The Shared Household Whiteboard & The Central Clearing House
Imagine living in a college apartment with four roommates (Aditya, Rohit, Manish, and सौरभ). Whenever someone buys groceries, orders food, or pays utility bills, they write it down on a shared kitchen whiteboard: "Aditya paid ₹800 for lunch, split equally among all four."
Instead of demanding immediate physical cash transfers after every single transaction (which would require hundreds of constant micro-payments), the apartment maintains a Running Balance Sheet. At any given moment, the whiteboard tracks who owes what to whom. When someone wants to move out, they cannot simply walk away while their name still has a negative balance on the board; they must first settle their accounts or transfer their liabilities to someone else.
3. High-Level Requirements & Functional Scope
Before designing our Unified Modeling Language (UML) class diagrams and writing concrete code, we must outline the exact functional requirements discussed in the lecture:
4. Architectural Patterns & Design Choices
To keep the application modular, maintainable, and extensible, we employ several classic Object-Oriented Design Patterns:
- Strategy Pattern (
SplitStrategy): Encapsulates interchangeable splitting algorithms (EqualSplit,ExactSplit,PercentageSplit) behind a common interface (CalculateSplit). - Factory Pattern (
SplitFactory): Centralizes the instantiation of concrete splitting strategies based on an incoming enum type (SplitType), keeping client code decoupled from specific algorithm classes. - Observer Pattern (
Observer&Observable): Decouples the notification engine.UserimplementsObserver(receiving updates viaupdate()), whileGroupacts as the concrete observable subject. - Facade Pattern (
SplitWise): Acts as a high-level orchestrator / entry point for client requests, hiding the complex subsystems of users, groups, expenses, and transaction simplify engines.
5. Step-by-Step UML Design & Class Structure
Component Relationships Breakdown
Usermanages its own profile (id,name,email) and a personal profile-level balance map (balance: map<string, double>) tracking peer-to-peer debts outside groups.Expenseencapsulates an individual bill, holding theexpenseId,description,amount,paidByUserId,groupId, and a collection ofSplitobjects.Splitis a simple data holder class pairing auserIdwith its corresponding assignedamount.Groupmanages group membership (list<User>), maintains historical audit logs of expenses (map<string, Expense>), and stores a nested ledger (map<string, map<string, double>>) representing who owes whom within the group.SplitWise(Facade) holds master maps of all system users, groups, and global expenses, delegating operations downward.
6. Code Evolution: From Naive Implementation to Decoupled Architecture
Step 1: The Splitting Strategy Hierarchy
Instead of cluttering our Expense class with complex conditional if-else blocks for division math, we define a clean Strategy interface:
enum SplitType { EQUAL, EXACT, PERCENTAGE };
class Split {
private:
string userId;
double amount;
public:
Split(string userId, double amount) : userId(userId), amount(amount) {}
string getUserId() const { return userId; }
double getAmount() const { return amount; }
};
class SplitStrategy {
public:
virtual vector<Split> calculateSplit(double totalAmount, const vector<string>& userIds, const vector<double>& values = {}) = 0;
virtual ~SplitStrategy() = default;
};
class EqualSplit : public SplitStrategy {
public:
vector<Split> calculateSplit(double totalAmount, const vector<string>& userIds, const vector<double>& values) override {
vector<Split> splits;
int n = userIds.size();
double amountPerUser = totalAmount / n;
for (const string& id : userIds) {
splits.push_back(Split(id, amountPerUser));
}
return splits;
}
};
class ExactSplit : public SplitStrategy {
public:
vector<Split> calculateSplit(double totalAmount, const vector<string>& userIds, const vector<double>& values) override {
vector<Split> splits;
for (size_t i = 0; i < userIds.size(); ++i) {
splits.push_back(Split(userIds[i], values[i]));
}
return splits;
}
};
class PercentageSplit : public SplitStrategy {
public:
vector<Split> calculateSplit(double totalAmount, const vector<string>& userIds, const vector<double>& values) override {
vector<Split> splits;
for (size_t i = 0; i < userIds.size(); ++i) {
double exactAmount = (totalAmount * values[i]) / 100.0;
splits.push_back(Split(userIds[i], exactAmount));
}
return splits;
}
};
Step 2: The Split Factory
The Factory creates the appropriate splitting strategy on demand, preventing the client from needing to know concrete subclass names:
class SplitFactory {
public:
static shared_ptr<SplitStrategy> getSplitStrategy(SplitType type) {
switch (type) {
case SplitType::EQUAL:
return make_shared<EqualSplit>();
case SplitType::EXACT:
return make_shared<ExactSplit>();
case SplitType::PERCENTAGE:
return make_shared<PercentageSplit>();
default:
return make_shared<EqualSplit>();
}
}
};
Step 3: Observer Pattern Implementation (Observer & User)
class Observer {
public:
virtual void update(const string& message) = 0;
virtual ~Observer() = default;
};
class User : public Observer {
private:
string userId;
string name;
string email;
// Map of otherUserId -> amount (positive = they owe me, negative = I owe them)
map<string, double> balances;
static int nextId;
public:
User(string name, string email) : name(name), email(email) {
this->userId = "U" + to_string(++nextId);
}
string getUserId() const { return userId; }
string getName() const { return name; }
void update(const string& message) override {
cout << "[Notification to " << name << " (" << userId << ")]: " << message << endl;
}
void updateBalance(const string& otherUserId, double amount) {
balances[otherUserId] += amount;
if (abs(balances[otherUserId]) < 0.01) {
balances.erase(otherUserId);
}
}
};
int User::nextId = 0;
Step 4: The Core Group Engine & Business Logic
The Group class handles expense additions, balance recalculation matrices, notifications, and leaving restrictions:
class Expense {
private:
string expenseId;
string description;
double amount;
string paidByUserId;
vector<Split> splits;
string groupId;
static int nextId;
public:
Expense(string desc, double amt, string paidBy, vector<Split> splits, string gId)
: description(desc), amount(amt), paidByUserId(paidBy), splits(splits), groupId(gId) {
this->expenseId = "E" + to_string(++nextId);
}
string getDescription() const { return description; }
double getAmount() const { return amount; }
string getPaidByUserId() const { return paidByUserId; }
const vector<Split>& getSplits() const { return splits; }
};
int Expense::nextId = 0;
class Group {
private:
string groupId;
string groupName;
vector<shared_ptr<User>> members;
map<string, shared_ptr<Expense>> expenses;
// Nested Ledger: groupBalance[fromUser][toUser] = amount
map<string, map<string, double>> groupBalances;
static int nextId;
public:
Group(string name) : groupName(name) {
this->groupId = "G" + to_string(++nextId);
}
string getGroupId() const { return groupId; }
string getGroupName() const { return groupName; }
void addMember(shared_ptr<User> user) {
members.push_back(user);
groupBalances[user->getUserId()] = map<string, double>();
cout << "User " << user->getName() << " joined group " << groupName << endl;
}
bool canUserLeave(const string& userId) {
if (groupBalances.find(userId) == groupBalances.end()) return true;
for (const auto& pair : groupBalances[userId]) {
if (abs(pair.second) > 0.01) {
return false; // Outstanding balance exists!
}
}
return false; // Also check if others owe this user
}
void removeMember(const string& userId) {
if (!canUserLeave(userId)) {
cout << "Error: User " << userId << " cannot leave group with pending expenses!" << endl;
return;
}
// Remove from member list and ledger maps
members.erase(remove_if(members.begin(), members.end(), [&](const shared_ptr<User>& u) {
return u->getUserId() == userId;
}), members.end());
groupBalances.erase(userId);
for (auto& pair : groupBalances) {
pair.second.erase(userId);
}
cout << "User " << userId << " successfully left group " << groupName << endl;
}
void notifyMembers(const string& message) {
for (auto& member : members) {
member->update(message);
}
}
void updateBalance(string fromUser, string toUser, double amount) {
if (fromUser == toUser) return;
groupBalances[fromUser][toUser] += amount;
groupBalances[toUser][fromUser] -= amount;
if (abs(groupBalances[fromUser][toUser]) < 0.01) groupBalances[fromUser].erase(toUser);
if (abs(groupBalances[toUser][fromUser]) < 0.01) groupBalances[toUser].erase(fromUser);
}
void addExpense(string desc, double amount, string paidBy, vector<string> userIds, SplitType splitType, vector<double> values = {}) {
auto strategy = SplitFactory::getSplitStrategy(splitType);
vector<Split> calculatedSplits = strategy->calculateSplit(amount, userIds, values);
auto expense = make_shared<Expense>(desc, amount, paidBy, calculatedSplits, groupId);
expenses[expenseId] = expense;
for (const auto& split : calculatedSplits) {
if (split.getUserId() != paidBy) {
updateBalance(split.getUserId(), paidBy, split.getAmount());
}
}
notifyMembers("New Expense Added: " + desc + " of amount " + to_string(amount) + " paid by " + paidBy);
}
void settlePayment(string fromUser, string toUser, double amount) {
updateBalance(fromUser, toUser, -amount);
notifyMembers("Settlement: " + fromUser + " paid " + to_string(amount) + " to " + toUser);
}
void printGroupBalance() {
cout << "\n--- Ledger for Group: " << groupName << " ---" << endl;
for (const auto& outer : groupBalances) {
for (const auto& inner : outer.second) {
if (inner.second > 0) {
cout << outer.first << " owes " << inner.first << " : " << inner.second << endl;
}
}
}
cout << "--------------------------------------\n" << endl;
}
};
int Group::nextId = 0;
7. Debt Simplification Engine (Greedy Algorithm)
When multiple members owe each other across complex chains of transactions, we want to minimize the total number of physical transfers required.
The Greedy Net-Balance Algorithm Workflow:
- Compute Net Balance: Calculate the net financial position for every user in the group. If total credits received minus total debits paid is positive, the user is a Creditor (net receiver). If negative, they are a Debtor (net payer).
- Partition & Sort: Separate users into two lists:
Debtors(owing money) andCreditors(waiting to receive money), and sort both lists in descending order of absolute balances. - Greedy Matching: Match the largest debtor with the largest creditor. Settle the minimum of their balances, update the lists, and repeat until all net balances equal zero.
class DebtSimplifier {
public:
static map<string, map<string, double>> simplify(const map<string, map<string, double>>& currentLedger) {
map<string, double> netBalance;
// Step 1: Compute net balances for all users
for (const auto& outer : currentLedger) {
string userA = outer.first;
for (const auto& inner : outer.second) {
string userB = inner.first;
double amount = inner.second; // userA owes userB 'amount'
netBalance[userA] -= amount;
netBalance[userB] += amount;
}
}
// Step 2: Separate into debtors and creditors
vector<pair<string, double>> debtors;
vector<pair<string, double>> creditors;
for (const auto& pair : netBalance) {
if (pair.second < -0.01) {
debtors.push_back({pair.first, -pair.second}); // store positive debt amount
} else if (pair.second > 0.01) {
creditors.push_back({pair.first, pair.second});
}
}
// Sort descending
sort(debtors.begin(), debtors.end(), [](auto& a, auto& b) { return a.second > b.second; });
sort(creditors.begin(), creditors.end(), [](auto& a, auto& b) { return a.second > b.second; });
map<string, map<string, double>> simplifiedLedger;
size_t d = 0, c = 0;
while (d < debtors.size() && c < creditors.size()) {
string debtor = debtors[d].first;
string creditor = creditors[c].first;
double settleAmount = min(debtors[d].second, creditors[c].second);
simplifiedLedger[debtor][creditor] = settleAmount;
debtors[d].second -= settleAmount;
creditors[c].second -= settleAmount;
if (debtors[d].second < 0.01) d++;
if (creditors[c].second < 0.01) c++;
}
return simplifiedLedger;
}
};
8. Industry Reality & Production Pitfalls
When scaling an expense-splitting application from a Local Low-Level Design mockup to a distributed cloud production system, engineers encounter several harsh realities:
- Concurrency & Race Conditions (
409 Conflict): If two users simultaneously add expenses or settle bills in the same group, database row locks or optimistic locking (@Versioncolumns in JPA/Hibernate) are required to prevent ledger corruption. - Floating-Point Precision Errors (
doublevslong): Using IEEE 754 floating-point numbers (double) for currency leads to rounding errors (e.g.,0.1 + 0.2 != 0.3). In production systems, all currency amounts must be stored as integers representing cents/paise (e.g., ₹800.00 stored as80000). - Distributed Notifications & Outbox Pattern: Relying on synchronous method calls (
notifyMembers()) can block threads if the notification service goes down. Production systems utilize message brokers (Kafka/RabbitMQ) combined with the Transactional Outbox Pattern to guarantee reliable event delivery. - Idempotency in Settlements: If a user taps "Settle Up" twice due to poor network latency, duplicate settlement records could be posted. Client-side idempotency keys ensure requests are safely retried.
9. Summary and Key Takeaways
- Modular Strategy Integration: Utilizing the Strategy Pattern for splitting algorithms (
Equal,Exact,Percentage) allows seamless integration of new financial rules without modifying existing business code. - Factory Decoupling: The Split Factory pattern abstracts object creation away from the high-level orchestrators.
- Observer-Driven Notifications: Real-time alerts are cleanly decoupled from the transaction engine using the Observer Pattern.
- Financial Safety Constraints: Enforcing group leave-validation guarantees that no user can evade their financial liabilities.
- Greedy Debt Reduction: The Net-Balance Simplification Algorithm dramatically reduces transaction friction across complex group graphs.
- Clean Facade Orchestration: The
SplitWisesingleton/facade class centralizes routing while delegating low-level state management to specialized domain entities (Group,User,Expense).