Requirements & System Scope
Functional Scope (In-Scope)
- Pluggable Splitting Strategies: Supports interchangeable Equal, Exact, and Percentage calculations to divide bills dynamically.
- Balance Graph Modeling: Keeps a single aggregated net balance grid (rather than storing redundant copy lines per expense).
- Minimum Cash Flow Settlement: Solves the min-cash-flow transaction graph reduction problem to close debts optimally.
- Precision Rounding Protection: Handles floating point fractions (e.g. splitting $10.00 equally between 3 users leaves a 1-cent remaining mismatch) by assigning offsets to one member dynamically.
Explicit Boundaries (Out-of-Scope)
- No Real Currency Bank Integration: Ignores actual payment gateways or third-party bank webhooks.
- No Dynamic Currency Swaps: Assumes a single unified functional currency for all calculations.
Class Diagram & Entity Relationships
Structured layout showcasing decoupling of split styles and central service operations:
Loading...
- Strategy Inheritance:
Splitacts as a polymorphic abstract base, enabling split type behaviors (Equal vs Percentage) to extend dynamically. - Graph Balance Sheet:
BalanceSheetstores a consolidated directed graph representation (Map<User, Map<User, Double>>) of net values.
Design Patterns & SOLID Principles
- Strategy Pattern (Splits Allocation): Swapping split strategies (Equal vs Percentage) is encapsulated inside split subclasses, keeping the central service closed to modifications (Open-Closed Principle).
- Graph Settle Solver: Decoupling debt settlement algorithms from storage collections allows you to upgrade standard greedy solvers to complex cycle-detection systems without refactoring the core Balance maps.
Core Execution Workflows
1. Expense Ingestion Flow
- Client invokes
addExpense(id, amount, paidBy, splits). - Verify total splits sum precisely matches total amount.
- For every split recipient user:
- If user matches payer, skip balance update.
- Otherwise, increment payer's credit for user:
balance[payer][user] += splitAmount. - Decrement user's balance for payer:
balance[user][payer] -= splitAmount.
2. Debt Graph Settlement Flow
- Aggregate net balance values per user.
- Categorize users into two separate lists: Debtors (net balance < 0) and Creditors (net balance > 0).
- Sort debtors ascending (greatest debtor first) and creditors descending (greatest creditor first).
- Execute greedy match:
- Resolve transaction value:
settled = min(-debtorAmount, creditorAmount). - Log payment transaction.
- Update remaining balances and re-sort lists.
- Resolve transaction value:
- Terminate when all balance graphs are close to zero.
Concurrency & Thread Safety Strategy
In shared flat/group scenarios, multiple members add overlapping expenses at the same time. This concurrent modification can cause dirty reads or race conditions in balance maps:
- Concurrent Map Operations: Use synchronized methods or reentrant segment locks to coordinate expense inserts.
- Atomic Settle Operations: Ensure the settlement plan runs on a point-in-time immutable clone of the balance graph to prevent race conditions from concurrent updates.
Complete Clean Code Blueprint
Production reference implementations showing robust split allocations and graph settlement algorithms in Java and Python:
// โโโ JAVA BLUEPRINT โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
class User {
private final String id;
private final String name;
private final String email;
public User(String id, String name, String email) {
this.id = Objects.requireNonNull(id);
this.name = Objects.requireNonNull(name);
this.email = Objects.requireNonNull(email);
}
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}
enum SplitType { EQUAL, EXACT, PERCENT }
abstract class Split {
private final User user;
protected double amount;
public Split(User user) {
this.user = Objects.requireNonNull(user);
}
public User getUser() { return user; }
public double getAmount() { return amount; }
public void setAmount(double amount) { this.amount = amount; }
}
class EqualSplit extends Split {
public EqualSplit(User user) { super(user); }
}
class ExactSplit extends Split {
public ExactSplit(User user, double amount) {
super(user);
this.amount = amount;
}
}
class PercentSplit extends Split {
private final double percent;
public PercentSplit(User user, double percent) {
super(user);
this.percent = percent;
}
public double getPercent() { return percent; }
}
interface ExpenseSplitStrategy {
void calculateAndValidate(List<Split> splits, double totalAmount);
}
class EqualSplitStrategy implements ExpenseSplitStrategy {
@Override
public void calculateAndValidate(List<Split> splits, double totalAmount) {
int n = splits.size();
if (n == 0) throw new IllegalArgumentException("Splits list cannot be empty.");
double share = Math.floor((totalAmount / n) * 100.0) / 100.0;
double totalCalculated = 0.0;
for (Split split : splits) {
split.setAmount(share);
totalCalculated += share;
}
// Precision Rounding Protection
double difference = totalAmount - totalCalculated;
if (Math.abs(difference) > 0.001) {
double adjustment = Math.round(difference * 100.0) / 100.0;
splits.get(0).setAmount(splits.get(0).getAmount() + adjustment);
}
}
}
class ExactSplitStrategy implements ExpenseSplitStrategy {
@Override
public void calculateAndValidate(List<Split> splits, double totalAmount) {
double totalSplitAmount = 0.0;
for (Split split : splits) {
totalSplitAmount += split.getAmount();
}
if (Math.abs(totalSplitAmount - totalAmount) > 0.01) {
throw new IllegalArgumentException("Sum of exact splits (" + totalSplitAmount + ") must equal total amount (" + totalAmount + ").");
}
}
}
class PercentSplitStrategy implements ExpenseSplitStrategy {
@Override
public void calculateAndValidate(List<Split> splits, double totalAmount) {
double totalPercent = 0.0;
for (Split split : splits) {
if (!(split instanceof PercentSplit)) {
throw new IllegalArgumentException("Split must be of type PercentSplit");
}
totalPercent += ((PercentSplit) split).getPercent();
}
if (Math.abs(totalPercent - 100.0) > 0.01) {
throw new IllegalArgumentException("Total percentage must equal 100%. Got: " + totalPercent);
}
double totalCalculated = 0.0;
for (Split split : splits) {
PercentSplit percentSplit = (PercentSplit) split;
double share = Math.floor((totalAmount * percentSplit.getPercent() / 100.0) * 100.0) / 100.0;
split.setAmount(share);
totalCalculated += share;
}
// Precision Rounding Protection
double difference = totalAmount - totalCalculated;
if (Math.abs(difference) > 0.001) {
double adjustment = Math.round(difference * 100.0) / 100.0;
splits.get(0).setAmount(splits.get(0).getAmount() + adjustment);
}
}
}
class ExpenseSplitStrategyFactory {
private static final Map<SplitType, ExpenseSplitStrategy> strategies = new HashMap<>();
static {
strategies.put(SplitType.EQUAL, new EqualSplitStrategy());
strategies.put(SplitType.EXACT, new ExactSplitStrategy());
strategies.put(SplitType.PERCENT, new PercentSplitStrategy());
}
public static ExpenseSplitStrategy getStrategy(SplitType type) {
ExpenseSplitStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new IllegalArgumentException("Invalid split type: " + type);
}
return strategy;
}
}
class Expense {
private final String id;
private final String description;
private final double amount;
private final User paidBy;
private final List<Split> splits;
private final SplitType splitType;
public Expense(String id, String description, double amount, User paidBy, List<Split> splits, SplitType splitType) {
this.id = Objects.requireNonNull(id);
this.description = Objects.requireNonNull(description);
this.amount = amount;
this.paidBy = Objects.requireNonNull(paidBy);
this.splits = Objects.requireNonNull(splits);
this.splitType = Objects.requireNonNull(splitType);
}
public String getId() { return id; }
public String getDescription() { return description; }
public double getAmount() { return amount; }
public User getPaidBy() { return paidBy; }
public List<Split> getSplits() { return splits; }
public SplitType getSplitType() { return splitType; }
}
class SplitwiseService {
private final Map<String, User> users = new ConcurrentHashMap<>();
private final Map<String, Map<String, Double>> balanceSheet = new ConcurrentHashMap<>();
private final List<Expense> expenses = new CopyOnWriteArrayList<>();
private final ReentrantLock lock = new ReentrantLock();
public void registerUser(User user) {
users.put(user.getId(), user);
}
public User getUser(String id) {
return users.get(id);
}
public void addExpense(String description, double amount, String paidById, List<Split> splits, SplitType splitType) {
User paidBy = users.get(paidById);
if (paidBy == null) {
throw new IllegalArgumentException("User not registered: " + paidById);
}
// Resolve split strategy and calculate exact share values
ExpenseSplitStrategy strategy = ExpenseSplitStrategyFactory.getStrategy(splitType);
strategy.calculateAndValidate(splits, amount);
lock.lock();
try {
String expenseId = "EXP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
Expense expense = new Expense(expenseId, description, amount, paidBy, splits, splitType);
expenses.add(expense);
for (Split split : splits) {
String oweUserId = split.getUser().getId();
if (oweUserId.equals(paidById)) continue;
double shareAmount = split.getAmount();
// paidBy lent shareAmount to oweUserId
updateBalance(paidById, oweUserId, shareAmount);
updateBalance(oweUserId, paidById, -shareAmount);
}
System.out.println("[Splitwise] Added expense: '" + description + "' of $" + amount + " paid by " + paidBy.getName());
} finally {
lock.unlock();
}
}
private void updateBalance(String userA, String userB, double amount) {
balanceSheet.computeIfAbsent(userA, k -> new ConcurrentHashMap<>());
Map<String, Double> balances = balanceSheet.get(userA);
balances.put(userB, balances.getOrDefault(userB, 0.0) + amount);
}
public List<String> settleUp() {
lock.lock();
try {
Map<String, Double> netBalances = new HashMap<>();
// Aggregate global net positions
for (String userA : balanceSheet.keySet()) {
for (Map.Entry<String, Double> entry : balanceSheet.get(userA).entrySet()) {
netBalances.put(userA, netBalances.getOrDefault(userA, 0.0) + entry.getValue());
}
}
// Max Heap for Creditors, Min Heap for Debtors
PriorityQueue<Map.Entry<String, Double>> debtors = new PriorityQueue<>(
Comparator.comparingDouble(Map.Entry::getValue)
);
PriorityQueue<Map.Entry<String, Double>> creditors = new PriorityQueue<>(
(a, b) -> Double.compare(b.getValue(), a.getValue())
);
for (Map.Entry<String, Double> entry : netBalances.entrySet()) {
double val = entry.getValue();
if (val < -0.01) {
debtors.add(new AbstractMap.SimpleEntry<>(entry.getKey(), val));
} else if (val > 0.01) {
creditors.add(new AbstractMap.SimpleEntry<>(entry.getKey(), val));
}
}
List<String> transactions = new ArrayList<>();
while (!debtors.isEmpty() && !creditors.isEmpty()) {
Map.Entry<String, Double> debtor = debtors.poll();
Map.Entry<String, Double> creditor = creditors.poll();
double debtAmount = -debtor.getValue();
double creditAmount = creditor.getValue();
double settledValue = Math.min(debtAmount, creditAmount);
User debtorUser = users.get(debtor.getKey());
User creditorUser = users.get(creditor.getKey());
transactions.add(debtorUser.getName() + " pays " + creditorUser.getName() + " $" + String.format("%.2f", settledValue));
double remainingDebtor = debtor.getValue() + settledValue;
double remainingCreditor = creditor.getValue() - settledValue;
if (remainingDebtor < -0.01) {
debtor.setValue(remainingDebtor);
debtors.add(debtor);
}
if (remainingCreditor > 0.01) {
creditor.setValue(remainingCreditor);
creditors.add(creditor);
}
}
return transactions;
} finally {
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
SplitwiseService service = new SplitwiseService();
User alice = new User("U1", "Alice", "alice@example.com");
User bob = new User("U2", "Bob", "bob@example.com");
User charlie = new User("U3", "Charlie", "charlie@example.com");
User david = new User("U4", "David", "david@example.com");
service.registerUser(alice);
service.registerUser(bob);
service.registerUser(charlie);
service.registerUser(david);
System.out.println("--- Registering expenses ---");
// 1. Alice paid $100 for Equal split among Alice, Bob, Charlie, David
List<Split> splits1 = Arrays.asList(
new EqualSplit(alice),
new EqualSplit(bob),
new EqualSplit(charlie),
new EqualSplit(david)
);
service.addExpense("Dinner", 100.00, "U1", splits1, SplitType.EQUAL);
// 2. Bob paid $50 for Exact split: Charlie owes $10, David owes $40
List<Split> splits2 = Arrays.asList(
new ExactSplit(charlie, 10.00),
new ExactSplit(david, 40.00)
);
service.addExpense("Cab ride", 50.00, "U2", splits2, SplitType.EXACT);
// 3. Charlie paid $120.00 for Percentage split: Alice (20%), Bob (30%), Charlie (50%)
List<Split> splits3 = Arrays.asList(
new PercentSplit(alice, 20.0),
new PercentSplit(bob, 30.0),
new PercentSplit(charlie, 50.0)
);
service.addExpense("Groceries", 120.00, "U3", splits3, SplitType.PERCENT);
System.out.println("\n--- Calculating Debt Simplification (Min Cash Flow) ---");
List<String> plans = service.settleUp();
for (String plan : plans) {
System.out.println(" -> " + plan);
}
System.out.println("\n--- Simulating Concurrent Expense Additions ---");
ExecutorService executor = Executors.newFixedThreadPool(3);
Runnable t1 = () -> service.addExpense("Coffee", 15.00, "U1", Arrays.asList(new EqualSplit(alice), new EqualSplit(bob)), SplitType.EQUAL);
Runnable t2 = () -> service.addExpense("Snacks", 25.00, "U2", Arrays.asList(new EqualSplit(bob), new EqualSplit(charlie)), SplitType.EQUAL);
executor.submit(t1);
executor.submit(t2);
executor.shutdown();
executor.awaitTermination(2, TimeUnit.SECONDS);
System.out.println("\n--- New Debt Plan After Concurrency ---");
plans = service.settleUp();
for (String plan : plans) {
System.out.println(" -> " + plan);
}
}
}Review
Help Us Improve
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Placeholder
Discussion
Share your thoughts, ask questions, or help others.
Loading comments...