1. Requirements (The Scope)
A group expense sharing platform like Splitwise processes multi-user financial transactions. To model this systematically, we define the requirements under strict functional and engineering headings:
Functional Requirements:
- User & Group Management: Users can register and form groups (e.g., "Flatmates", "Trip to Tokyo").
- Flexible Splitting Modes: Support three expense splitting strategies:
- Equal: Split costs identically among all participants.
- Exact: Assign precise custom amounts to each participant (must sum to total).
- Percentage: Split costs using custom percentages (must sum to 100%).
- Dynamic Balance Sheet: Retrieve individual user balances (e.g., "User A owes User B $20").
- Transaction Simplification: Provide a mechanism to simplify balances within a group to minimize the actual number of transactions required to settle up.
Non-Functional Requirements:
- High Transactional Safety: Ensure all expense additions, balance updates, and splits are mathematically exact (using
BigDecimalinstead ofdoubleto prevent floating-point precision leakage). - Concurrency Safety: Support concurrent expense additions to a group without dirty reads or inconsistent states.
- Open for Extensions: Enable adding new splitting strategies (e.g., custom shares, weight ratios) without modifying the main expense registration pipeline (SOLID Open-Closed Principle).
2. High-Fidelity Diagrams
A. The Core Class Diagram & Strategy Architecture
Below is the structural relationship of our Splitwise domain model, highlighting the Strategy pattern for splits:
classDiagram
class User {
+String userId
+String name
+String email
}
class Split {
+User user
+BigDecimal amount
}
class Expense {
+String id
+String description
+BigDecimal totalAmount
+User paidBy
+List~Split~ splits
+ExpenseType type
}
class ExpenseSplitStrategy {
<<interface>>
+validateSplits(List~Split~ splits, BigDecimal total) boolean
}
class EqualSplitStrategy {
+validateSplits(List~Split~ splits, BigDecimal total) boolean
}
class ExactSplitStrategy {
+validateSplits(List~Split~ splits, BigDecimal total) boolean
}
ExpenseSplitStrategy <|.. EqualSplitStrategy
ExpenseSplitStrategy <|.. ExactSplitStrategy
Expense *-- Split
Expense o-- User
Split o-- User
B. Sequence Diagram: Registering an Expense
The sequence below illustrates the flow of adding a new expense with strategy validation:
sequenceDiagram
autonumber
actor U as User (Payer)
participant GM as GroupManager
participant G as Group
participant S as ExpenseSplitStrategy
participant BS as BalanceSheetController
U->>GM: addExpense(groupId, payerId, amount, description, splits, type)
activate GM
GM->>GM: getStrategy(type)
GM->>S: validateSplits(splits, amount)
alt is invalid
S-->>GM: return false
GM-->>U: throw InvalidExpenseException
else is valid
S-->>GM: return true
GM->>G: createAndAddExpense(payer, amount, splits)
G->>BS: updateBalances(payer, splits)
activate BS
BS-->>G: balance sheet updated
deactivate BS
GM-->>U: return success
end
deactivate GM
3. Production-Grade Java Implementation
Let's build the complete, thread-safe Java codebase. We use standard packaging paradigms and precise currency processing structures:
A. The Splitting Contracts & Models
package com.codesprintpro.splitwise.model;
public class User {
private final String userId;
private final String name;
private final String email;
public User(String userId, String name, String email) {
this.userId = userId;
this.name = name;
this.email = email;
}
public String getUserId() { return userId; }
public String getName() { return name; }
}
package com.codesprintpro.splitwise.model;
import java.math.BigDecimal;
public abstract class Split {
private final User user;
protected BigDecimal amount;
public Split(User user) {
this.user = user;
}
public User getUser() { return user; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
public class EqualSplit extends Split {
public EqualSplit(User user) { super(user); }
}
public class ExactSplit extends Split {
public ExactSplit(User user, BigDecimal amount) {
super(user);
this.amount = amount;
}
}
public class PercentSplit extends Split {
private final double percent;
public PercentSplit(User user, double percent) {
super(user);
this.percent = percent;
}
public double getPercent() { return percent; }
}
B. Splitting Strategy Pipeline
package com.codesprintpro.splitwise.strategy;
import com.codesprintpro.splitwise.model.Split;
import java.math.BigDecimal;
import java.util.List;
public interface ExpenseSplitStrategy {
boolean validateSplits(List<Split> splits, BigDecimal totalAmount);
}
package com.codesprintpro.splitwise.strategy;
import com.codesprintpro.splitwise.model.*;
import java.math.BigDecimal;
import java.util.List;
public class EqualSplitStrategy implements ExpenseSplitStrategy {
@Override
public boolean validateSplits(List<Split> splits, BigDecimal totalAmount) {
// Equal splits automatically generate fair fractional balances
return splits != null && !splits.isEmpty();
}
}
public class ExactSplitStrategy implements ExpenseSplitStrategy {
@Override
public boolean validateSplits(List<Split> splits, BigDecimal totalAmount) {
if (splits == null || splits.isEmpty()) return false;
BigDecimal sum = BigDecimal.ZERO;
for (Split split : splits) {
if (split.getAmount() == null) return false;
sum = sum.add(split.getAmount());
}
return sum.compareTo(totalAmount) == 0;
}
}
public class PercentSplitStrategy implements ExpenseSplitStrategy {
@Override
public boolean validateSplits(List<Split> splits, BigDecimal totalAmount) {
if (splits == null || splits.isEmpty()) return false;
double percentSum = 0;
for (Split split : splits) {
if (!(split instanceof PercentSplit)) return false;
percentSum += ((PercentSplit) split).getPercent();
}
return Math.abs(percentSum - 100.0) < 0.0001;
}
}
C. The Group Ledger & Concurrency Models
package com.codesprintpro.splitwise.model;
import java.math.BigDecimal;
import java.util.List;
public class Expense {
private final String id;
private final String description;
private final BigDecimal totalAmount;
private final User paidBy;
private final List<Split> splits;
public Expense(String id, String description, BigDecimal totalAmount, User paidBy, List<Split> splits) {
this.id = id;
this.description = description;
this.totalAmount = totalAmount;
this.paidBy = paidBy;
this.splits = splits;
}
public String getId() { return id; }
public BigDecimal getTotalAmount() { return totalAmount; }
public User getPaidBy() { return paidBy; }
public List<Split> getSplits() { return splits; }
}
package com.codesprintpro.splitwise.model;
import com.codesprintpro.splitwise.strategy.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class Group {
private final String groupId;
private final String name;
private final List<User> members = new CopyOnWriteArrayList<>();
private final List<Expense> expenses = new CopyOnWriteArrayList<>();
// Nested balance structure: UserIdA -> (UserIdB -> Amount)
// Positive means A owes B; Negative means B owes A
private final Map<String, Map<String, BigDecimal>> balanceSheet = new ConcurrentHashMap<>();
private final Object writeLock = new Object(); // Synchronizes ledger writes
public Group(String groupId, String name) {
this.groupId = groupId;
this.name = name;
}
public void addMember(User user) {
members.add(user);
balanceSheet.putIfAbsent(user.getUserId(), new ConcurrentHashMap<>());
}
public void addExpense(String id, String description, BigDecimal amount, User paidBy, List<Split> splits, ExpenseSplitStrategy strategy) {
if (!strategy.validateSplits(splits, amount)) {
throw new IllegalArgumentException("[Splitwise] Validation FAILED! Splitting values do not reconcile with total: " + amount);
}
// Auto-assign amounts for Equal and Percentage splits to ensure transaction integrity
reconcileSplitAmounts(splits, amount);
synchronized (writeLock) {
Expense expense = new Expense(id, description, amount, paidBy, splits);
expenses.add(expense);
// Mutate Ledger Balance sheets
for (Split split : splits) {
User debtor = split.getUser();
if (debtor.getUserId().equals(paidBy.getUserId())) continue;
BigDecimal splitAmount = split.getAmount();
updateBalances(debtor.getUserId(), paidBy.getUserId(), splitAmount);
}
}
}
private void reconcileSplitAmounts(List<Split> splits, BigDecimal total) {
if (splits.get(0) instanceof EqualSplit) {
BigDecimal size = new BigDecimal(splits.size());
BigDecimal share = total.divide(size, 2, RoundingMode.HALF_UP);
BigDecimal remainder = total.subtract(share.multiply(size));
for (int i = 0; i < splits.size(); i++) {
splits.get(i).setAmount(share);
}
// Distribute fractional pennies safely
if (remainder.compareTo(BigDecimal.ZERO) != 0) {
splits.get(0).setAmount(splits.get(0).getAmount().add(remainder));
}
} else if (splits.get(0) instanceof PercentSplit) {
for (Split split : splits) {
PercentSplit ps = (PercentSplit) split;
BigDecimal share = total.multiply(BigDecimal.valueOf(ps.getPercent() / 100.0)).setScale(2, RoundingMode.HALF_UP);
ps.setAmount(share);
}
}
}
private void updateBalances(String debtorId, String creditorId, BigDecimal amount) {
// Debtor owes Creditor
Map<String, BigDecimal> debtorBalances = balanceSheet.get(debtorId);
BigDecimal currentDebtorBalance = debtorBalances.getOrDefault(creditorId, BigDecimal.ZERO);
debtorBalances.put(creditorId, currentDebtorBalance.add(amount));
// Creditor is owed by Debtor (symmetric negative balance)
Map<String, BigDecimal> creditorBalances = balanceSheet.get(creditorId);
BigDecimal currentCreditorBalance = creditorBalances.getOrDefault(debtorId, BigDecimal.ZERO);
creditorBalances.put(debtorId, currentCreditorBalance.subtract(amount));
}
public Map<String, Map<String, BigDecimal>> getBalanceSheet() { return balanceSheet; }
public List<User> getMembers() { return members; }
}
4. The Transaction Simplification Algorithm
If Alice owes Bob $10, and Bob owes Charlie $10, the optimal operational pathway is Alice paying Charlie $10 directly. This reduces transactions from two to one.
Heap-Based Cash Flow Minimization:
We implement a highly optimized, greedy optimization engine. We calculate the net net balance for each user. Users with negative net balances are creditors (they are owed money); users with positive net balances are debtors (they owe money). We push creditors to a Max-Heap and debtors to a Min-Heap (or Max-Heap based on absolute debit sizes) and resolve settlements recursively:
package com.codesprintpro.splitwise.service;
import com.codesprintpro.splitwise.model.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
public class TransactionSimplifier {
public static class Transaction {
private final String debtorName;
private final String creditorName;
private final BigDecimal amount;
public Transaction(String debtorName, String creditorName, BigDecimal amount) {
this.debtorName = debtorName;
this.creditorName = creditorName;
this.amount = amount;
}
@Override
public String toString() {
return debtorName + " pays " + creditorName + " : $" + amount.setScale(2, RoundingMode.HALF_UP);
}
}
private static class UserBalance implements Comparable<UserBalance> {
String userName;
BigDecimal netBalance; // positive = owed (creditor), negative = owes (debtor)
UserBalance(String userName, BigDecimal netBalance) {
this.userName = userName;
this.netBalance = netBalance;
}
@Override
public int compareTo(UserBalance other) {
return other.netBalance.abs().compareTo(this.netBalance.abs()); // Sorting by descending absolute value
}
}
public static List<Transaction> simplifyBalances(Group group) {
Map<String, BigDecimal> netBalances = new HashMap<>();
// Initialize members
for (User u : group.getMembers()) {
netBalances.put(u.getName(), BigDecimal.ZERO);
}
// Accumulate Net Net balances from balance sheet
Map<String, Map<String, BigDecimal>> sheets = group.getBalanceSheet();
for (User member : group.getMembers()) {
String memberName = member.getName();
Map<String, BigDecimal> relations = sheets.get(member.getUserId());
BigDecimal net = BigDecimal.ZERO;
if (relations != null) {
for (BigDecimal value : relations.values()) {
net = net.subtract(value); // Debts added as negative, credits added as positive
}
}
netBalances.put(memberName, net);
}
// Priority Queues for Greedy settlement matches
PriorityQueue<UserBalance> creditors = new PriorityQueue<>(); // Persons to receive cash (Net Net balance > 0)
PriorityQueue<UserBalance> debtors = new PriorityQueue<>(); // Persons to pay cash (Net Net balance < 0)
for (Map.Entry<String, BigDecimal> entry : netBalances.entrySet()) {
BigDecimal bal = entry.getValue();
if (bal.compareTo(BigDecimal.ZERO) > 0) {
creditors.add(new UserBalance(entry.getKey(), bal));
} else if (bal.compareTo(BigDecimal.ZERO) < 0) {
debtors.add(new UserBalance(entry.getKey(), bal.negate())); // Store as positive absolute for max-heap
}
}
List<Transaction> transactions = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
UserBalance maxCreditor = creditors.poll();
UserBalance maxDebtor = debtors.poll();
BigDecimal settlementAmount = maxCreditor.netBalance.min(maxDebtor.netBalance);
transactions.add(new Transaction(maxDebtor.userName, maxCreditor.userName, settlementAmount));
maxCreditor.netBalance = maxCreditor.netBalance.subtract(settlementAmount);
maxDebtor.netBalance = maxDebtor.netBalance.subtract(settlementAmount);
// Re-queue remaining balances if not fully settled
if (maxCreditor.netBalance.compareTo(BigDecimal.valueOf(0.01)) > 0) {
creditors.add(maxCreditor);
}
if (maxDebtor.netBalance.compareTo(BigDecimal.valueOf(0.01)) > 0) {
debtors.add(maxDebtor);
}
}
return transactions;
}
}
5. SOLID Design Alignment
- SRP (Single Responsibility): The
Groupstores raw ledgers,ExpenseSplitStrategyvalidates arithmetic splits, andTransactionSimplifiersolves algorithmic settlements. - OCP (Open-Closed): To add a new
ShareSplitStrategyor custom tax calculations, we define a new strategy class extendingExpenseSplitStrategywithout editing any structural codebase. - LSP (Liskov Substitution):
EqualSplit,ExactSplit, andPercentSplitare safely substitutable subclasses ofSplit. - DIP (Dependency Inversion): The group coordinator uses abstract strategy abstractions (
ExpenseSplitStrategy) rather than relying on direct class evaluations.
6. Verification Playground (Ledger Simulation)
Run the operational verification class to see the splitting engine and cashflow optimizer calculate exact payments:
import com.codesprintpro.splitwise.model.*;
import com.codesprintpro.splitwise.strategy.*;
import com.codesprintpro.splitwise.service.TransactionSimplifier;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
System.out.println("--- Starting Splitwise Ledger Simulation ---\n");
// 1. Setup users
User alice = new User("U1", "Alice", "alice@pro.com");
User bob = new User("U2", "Bob", "bob@pro.com");
User charlie = new User("U3", "Charlie", "charlie@pro.com");
Group tripGroup = new Group("G1", "Tokyo Trip 2026");
tripGroup.addMember(alice);
tripGroup.addMember(bob);
tripGroup.addMember(charlie);
// 2. Registrations strategies
ExpenseSplitStrategy equalStrategy = new EqualSplitStrategy();
ExpenseSplitStrategy exactStrategy = new ExactSplitStrategy();
// Transaction A: Alice pays $300 for Hotel, split equally
List<Split> hotelSplits = new ArrayList<>();
hotelSplits.add(new EqualSplit(alice));
hotelSplits.add(new EqualSplit(bob));
hotelSplits.add(new EqualSplit(charlie));
tripGroup.addExpense("E1", "Hotel Booking", BigDecimal.valueOf(300.00), alice, hotelSplits, equalStrategy);
System.out.println("[Ledger] Alice paid $300 (split equally among Alice, Bob, Charlie).");
// Transaction B: Bob pays $90 for Bullet Train. Alice owes $40, Charlie owes $50
List<Split> trainSplits = new ArrayList<>();
trainSplits.add(new ExactSplit(alice, BigDecimal.valueOf(40.00)));
trainSplits.add(new ExactSplit(charlie, BigDecimal.valueOf(50.00)));
trainSplits.add(new ExactSplit(bob, BigDecimal.ZERO)); // Bob paid, owes nothing to himself
tripGroup.addExpense("E2", "Shinkansen Tickets", BigDecimal.valueOf(90.00), bob, trainSplits, exactStrategy);
System.out.println("[Ledger] Bob paid $90 (exact split: Alice owes $40, Charlie owes $50).");
// 3. Compute Simplified Settlement
System.out.println("\n--- Calculating Optimized Balance Settlements ---");
List<TransactionSimplifier.Transaction> optimalReceipts = TransactionSimplifier.simplifyBalances(tripGroup);
for (TransactionSimplifier.Transaction tx : optimalReceipts) {
System.out.println(" ✅ " + tx);
}
System.out.println("\n--- Simulation Complete. Cashflows minimized successfully. ---");
}
}