Model users, expenses, and splits
An expense is paid by someone and split among participants. The split can be equal, exact amounts, or percentages. Model money carefully — always integer cents, never floating dollars.
class User { final String id; User(String id) { this.id = id; } }
class Split {
final User user;
final long owedCents; // how much this user owes for the expense
Split(User user, long owedCents) { this.user = user; this.owedCents = owedCents; }
}
class Expense {
final User paidBy;
final long amountCents;
final List<Split> splits;
Expense(User paidBy, long amountCents, List<Split> splits) {
this.paidBy = paidBy; this.amountCents = amountCents; this.splits = splits;
}
}
Using long cents avoids the classic 0.1 + 0.2 != 0.3 rounding bug that silently corrupts balances over time.
Splits are a strategy
Equal / exact / percentage are three algorithms for turning an amount into per-user Splits. Hide them behind an interface so a new split type never edits the expense code.
interface SplitStrategy {
List<Split> split(long amountCents, List<User> members, long[] args);
}
class EqualSplit implements SplitStrategy {
public List<Split> split(long amount, List<User> members, long[] args) {
long base = amount / members.size();
long rem = amount % members.size(); // distribute the leftover cents
List<Split> out = new ArrayList<>();
for (int i = 0; i < members.size(); i++)
out.add(new Split(members.get(i), base + (i < rem ? 1 : 0)));
return out;
}
}
Note the remainder handling: split 1000 cents three ways and someone must owe the extra penny, or the parts won't sum to the whole. Adding PercentSplit later is a new class — Open/Closed again.
Validate before you record
A split is only legal if its parts reconcile with the amount. Reject bad input at the door — a corrupted expense poisons every downstream balance.
static void validate(long amount, List<Split> splits) {
long sum = splits.stream().mapToLong(s -> s.owedCents).sum();
if (sum != amount)
throw new IllegalArgumentException("splits sum " + sum + " != amount " + amount);
}
For a percentage split you'd first assert the percentages total 100; for exact splits, that the amounts total the bill. The invariant is one line but essential: the parts must equal the whole, checked at creation, not discovered later in a wrong balance.
<!--STEP-->Track net balances, not raw transactions
Don't store a log and recompute — maintain a running who-owes-whom map. For each expense, everyone except the payer owes their share to the payer.
// balances.get(a).get(b) > 0 means a owes b
private final Map<String, Map<String, Long>> balances = new HashMap<>();
void record(Expense e) {
for (Split s : e.splits) {
if (s.user.id.equals(e.paidBy.id)) continue;
adjust(s.user.id, e.paidBy.id, s.owedCents); // debtor owes payer
adjust(e.paidBy.id, s.user.id, -s.owedCents); // mirror entry
}
}
void adjust(String from, String to, long delta) {
balances.computeIfAbsent(from, k -> new HashMap<>()).merge(to, delta, Long::sum);
}
Keeping a net balance per pair makes "how much does Alice owe Bob?" an O(1) lookup, and offsetting debts (Alice owes Bob, Bob owes Alice) cancel automatically.
<!--STEP-->Settle up with fewer transfers
Everyone's balances net to zero. To minimise transfers, compute each person's net position (paid minus owed), then repeatedly settle the biggest creditor against the biggest debtor.
class ExpenseManager {
private final BalanceSheet sheet = new BalanceSheet();
void addExpense(User paidBy, long amount, SplitStrategy strat,
List<User> members, long[] args) {
List<Split> splits = strat.split(amount, members, args);
BalanceSheet.validate(amount, splits);
sheet.record(new Expense(paidBy, amount, splits));
}
// simplifyDebts(): greedily match max-creditor with max-debtor until all net to zero
}
The greedy max-creditor/max-debtor match won't always hit the theoretical minimum number of transfers (that problem is NP-hard), but it's simple, correct, and yields dramatically fewer payments than settling each expense individually — exactly the trade-off to state aloud.
Practice it for real: the Splitwise problem in the ladder tests split types, balances, and settlement against hidden cases.