LLD Series: Splitwise / Expense Sharing (Low-Level Design)
Overview
- 1. Problem statement and how it is asked
- 2. Requirements clarification
- 3. Entities, relationships, class diagram
- 4. Design patterns and why
- 5. Complete Java code
- 6. Walkthrough of key flows
- 7. Extensibility
- 8. Concurrency and edge cases
- 9. Follow-up questions and model answers
- 10. Common mistakes, red flags, rubric
Part of the Interview Prep series. Dotted-underline words are glossary terms: hover for the meaning or open the Glossary. Track your progress on the Interview Prep tracker. Series index: Start here.
1. Problem statement and how it is asked
Typical phrasing: "Design Splitwise. Users add expenses and split them equally, by exact amounts, or by percentage. Show who owes whom. Add a 'simplify debts' feature that minimises the number of transactions."
- Asked at: PayU, Amex, Visa, Mastercard (money-splitting is a natural payments LLD), and a staple at Flipkart/Uber/Walmart machine-coding rounds.
- Interviewer is checking: Strategy for split types with correct validation, exact money arithmetic (no
double, remainder paise handling), a balance sheet that stays consistent, the simplify-debts algorithm (net balances + greedy with heaps), and thread safety of the aggregate.
2. Requirements clarification
Functional
- Users and groups. A group has members; expenses belong to a group.
- Add an expense: payer, total, participants, split type = EQUAL / EXACT / PERCENT.
- Validation: exact amounts must sum to total; percentages must sum to 100; payer/participants must be members.
- Show pairwise balances ("B owes A 150") and per-user net balance.
- Simplify debts: produce a small list of transfers that settles everyone.
- Settle up (record a payment between two members).
Non-functional
- Money is exact: shares always sum to the total to the paisa.
- Thread-safe adds; a failed expense must leave NO trace (all-or-nothing).
- Strategy extensible (shares, by-item, ...).
Assumptions: single currency, 2 decimals; the payer may or may not be a participant; one payer per expense; in-memory.
Out of scope: multi-currency, receipts/images, comments, notifications, recurring expenses, authentication, persistence (discussed in follow-ups).
3. Entities, relationships, class diagram
+--------------------+ 1 * +------------+ 1 * +----------------+
| Group |---------->| Expense |------->| Split |
|--------------------| | id,paidBy | | userId, amount |
| members | | total,type | +----------------+
| addExpense() | +------------+
| settle() |
| simplifiedDebts() |--has--> BalanceSheet (owes[debtor][creditor], net[user])
+----+----------+----+
|uses |uses
v v
+-----------------------+ +--------------------+
|SplitStrategyFactory | |<<iface>> |
| EnumMap<Type,Strategy>| | DebtSimplifier |<|-- GreedyDebtSimplifier
+-----------+-----------+ +--------------------+
| creates/returns
v
<<iface>> SplitStrategy <|-- EqualSplitStrategy
<|-- ExactSplitStrategy
<|-- PercentSplitStrategy
Money (static utils: minor units <-> string) Transfer(from,to,amount)
4. Design patterns and why
| Pattern | Where | Why / alternative rejected |
|---|---|---|
| Strategy | SplitStrategy (3 impls) | Each split type has its own validation and math. Rejected: switch(type) in the service, or the classic EqualExpense/ExactExpense/PercentExpense subclass hierarchy - the expense is the same, only the way shares are computed varies, so subclassing the entity is the wrong axis. |
| Factory / registry | SplitStrategyFactory | Maps enum -> strategy, new types registered without touching Group. |
| Strategy (2nd) | DebtSimplifier | Greedy now; can swap in exact backtracking for small groups. |
| Aggregate root | Group | Owns balance sheet + expenses; the lock protects the invariant "sum(net) == 0". |
| Immutable value objects | Expense, Split, Transfer | Safe publication, audit trail. |
| (Mention) Observer | notify members on new expense | Add ExpenseListener list in Group, fire after unlock. |
Why minor units (long paise)? double 0.1+0.2 drift breaks "sum of shares equals total". BigDecimal works but long minor units are faster and trivially exact; convert only at the edges (Money).
Remainder rule: 100.00 split 3 ways = 33.34 + 33.33 + 33.33 (leftover paise to first participants). Percent: floor each share, then hand out leftover paise.
5. Complete Java code
Default package; javac *.java && java Main.
5.1 Money and basic models
// SplitwiseException.java
public class SplitwiseException extends RuntimeException {
public SplitwiseException(String m) { super(m); }
}
// Money.java
import java.math.BigDecimal;
import java.math.RoundingMode;
/** All internal arithmetic is in minor units (paise) as long: no floating point drift, sums always exact. */
public final class Money {
private Money() {}
public static long toMinor(String major) { return toMinor(new BigDecimal(major)); }
public static long toMinor(BigDecimal major) {
return major.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).longValueExact();
}
public static String format(long minor) {
return BigDecimal.valueOf(minor, 2).toPlainString();
}
}
// Split.java
/** One participant's share of an expense, in minor units. */
public final class Split {
private final String userId;
private final long amount;
public Split(String userId, long amount) { this.userId = userId; this.amount = amount; }
public String userId() { return userId; }
public long amount() { return amount; }
}
// ExpenseType.java
public enum ExpenseType { EQUAL, EXACT, PERCENT }
// Expense.java
import java.time.Instant;
import java.util.List;
public final class Expense {
private final String id, description, paidBy;
private final long total;
private final ExpenseType type;
private final List<Split> splits;
private final Instant createdAt;
public Expense(String id, String description, String paidBy, long total, ExpenseType type,
List<Split> splits, Instant createdAt) {
this.id = id; this.description = description; this.paidBy = paidBy; this.total = total;
this.type = type; this.splits = List.copyOf(splits); this.createdAt = createdAt;
}
public String id() { return id; }
public String description() { return description; }
public String paidBy() { return paidBy; }
public long total() { return total; }
public ExpenseType type() { return type; }
public List<Split> splits() { return splits; }
}
// Transfer.java
/** "from pays to amount" - the output of settlement / simplification. */
public final class Transfer {
public final String from, to; public final long amount;
public Transfer(String from, String to, long amount) { this.from = from; this.to = to; this.amount = amount; }
@Override public String toString() { return from + " pays " + to + " " + Money.format(amount); }
}
5.2 Split strategies
// SplitStrategy.java
import java.math.BigDecimal;
import java.util.List;
/**
* Strategy: turn (total, participants, params) into per-user shares that sum EXACTLY to total.
* params meaning: EQUAL -> ignored, EXACT -> amounts (major units), PERCENT -> percentages.
*/
public interface SplitStrategy {
List<Split> split(long totalMinor, List<String> participants, List<BigDecimal> params);
}
// EqualSplitStrategy.java
import java.math.BigDecimal;
import java.util.*;
public final class EqualSplitStrategy implements SplitStrategy {
@Override
public List<Split> split(long total, List<String> users, List<BigDecimal> ignored) {
int n = users.size();
long base = total / n;
long remainder = total % n; // distribute leftover paise one each to first users
List<Split> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) out.add(new Split(users.get(i), base + (i < remainder ? 1 : 0)));
return out;
}
}
// ExactSplitStrategy.java
import java.math.BigDecimal;
import java.util.*;
public final class ExactSplitStrategy implements SplitStrategy {
@Override
public List<Split> split(long total, List<String> users, List<BigDecimal> amounts) {
if (amounts == null || amounts.size() != users.size())
throw new SplitwiseException("EXACT needs one amount per participant");
List<Split> out = new ArrayList<>();
long sum = 0;
for (int i = 0; i < users.size(); i++) {
long a = Money.toMinor(amounts.get(i));
if (a < 0) throw new SplitwiseException("Negative share");
sum += a;
out.add(new Split(users.get(i), a));
}
if (sum != total)
throw new SplitwiseException("Exact amounts sum to " + Money.format(sum) + " but total is " + Money.format(total));
return out;
}
}
// PercentSplitStrategy.java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
public final class PercentSplitStrategy implements SplitStrategy {
@Override
public List<Split> split(long total, List<String> users, List<BigDecimal> percents) {
if (percents == null || percents.size() != users.size())
throw new SplitwiseException("PERCENT needs one percentage per participant");
BigDecimal sum = percents.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (sum.compareTo(BigDecimal.valueOf(100)) != 0)
throw new SplitwiseException("Percentages must add up to 100 but were " + sum.stripTrailingZeros().toPlainString());
long[] shares = new long[users.size()];
long assigned = 0;
for (int i = 0; i < shares.length; i++) {
shares[i] = BigDecimal.valueOf(total).multiply(percents.get(i))
.divide(BigDecimal.valueOf(100), 0, RoundingMode.FLOOR).longValueExact();
assigned += shares[i];
}
for (int i = 0; assigned < total; i = (i + 1) % shares.length) { shares[i]++; assigned++; } // leftover paise
List<Split> out = new ArrayList<>();
for (int i = 0; i < shares.length; i++) out.add(new Split(users.get(i), shares[i]));
return out;
}
}
// SplitStrategyFactory.java
import java.util.EnumMap;
import java.util.Map;
/** Registry; adding a type = new enum constant + new strategy + one register() call. */
public final class SplitStrategyFactory {
private final Map<ExpenseType, SplitStrategy> strategies = new EnumMap<>(ExpenseType.class);
public SplitStrategyFactory() {
register(ExpenseType.EQUAL, new EqualSplitStrategy());
register(ExpenseType.EXACT, new ExactSplitStrategy());
register(ExpenseType.PERCENT, new PercentSplitStrategy());
}
public void register(ExpenseType t, SplitStrategy s) { strategies.put(t, s); }
public SplitStrategy get(ExpenseType t) {
SplitStrategy s = strategies.get(t);
if (s == null) throw new SplitwiseException("Unsupported expense type " + t);
return s;
}
}
5.3 Balance sheet and debt simplification
// BalanceSheet.java
import java.util.*;
/**
* Not thread-safe by itself; the owning Group guards it with its lock.
* owes[debtor][creditor] > 0 always holds one direction only (opposite debts are netted).
* net[user] > 0 : user is owed money overall; < 0 : user owes money overall.
*/
public final class BalanceSheet {
private final Map<String, Map<String, Long>> owes = new TreeMap<>();
private final Map<String, Long> net = new TreeMap<>();
public void addDebt(String debtor, String creditor, long amount) {
if (amount == 0 || debtor.equals(creditor)) return;
net.merge(creditor, amount, Long::sum);
net.merge(debtor, -amount, Long::sum);
long reverse = get(creditor, debtor);
if (reverse >= amount) { // net against opposite direction
set(creditor, debtor, reverse - amount);
} else {
set(creditor, debtor, 0);
set(debtor, creditor, get(debtor, creditor) + amount - reverse);
}
}
private long get(String debtor, String creditor) { return owes.getOrDefault(debtor, Map.of()).getOrDefault(creditor, 0L); }
private void set(String debtor, String creditor, long v) {
if (v == 0) { Map<String, Long> m = owes.get(debtor); if (m != null) m.remove(creditor); }
else owes.computeIfAbsent(debtor, k -> new TreeMap<>()).put(creditor, v);
}
public List<Transfer> pairwise() {
List<Transfer> out = new ArrayList<>();
owes.forEach((d, m) -> m.forEach((c, v) -> out.add(new Transfer(d, c, v))));
return out;
}
public Map<String, Long> netBalances() { return new TreeMap<>(net); }
}
// DebtSimplifier.java
import java.util.List;
import java.util.Map;
public interface DebtSimplifier {
List<Transfer> simplify(Map<String, Long> netBalances);
}
// GreedyDebtSimplifier.java
import java.util.*;
/**
* Greedy: repeatedly settle the biggest debtor against the biggest creditor.
* O(n log n); at most n-1 transfers (each step zeroes at least one person). Not guaranteed minimal -
* minimising transfers is NP-hard (reduces to partitioning into zero-sum subsets).
*/
public final class GreedyDebtSimplifier implements DebtSimplifier {
private static final class Entry {
final String user; long amount;
Entry(String user, long amount) { this.user = user; this.amount = amount; }
}
@Override
public List<Transfer> simplify(Map<String, Long> net) {
Comparator<Entry> byAmountDesc = Comparator.<Entry>comparingLong(e -> e.amount).reversed().thenComparing(e -> e.user);
PriorityQueue<Entry> creditors = new PriorityQueue<>(byAmountDesc);
PriorityQueue<Entry> debtors = new PriorityQueue<>(byAmountDesc);
net.forEach((u, v) -> {
if (v > 0) creditors.add(new Entry(u, v));
else if (v < 0) debtors.add(new Entry(u, -v));
});
List<Transfer> result = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
Entry c = creditors.poll(), d = debtors.poll();
long pay = Math.min(c.amount, d.amount);
result.add(new Transfer(d.user, c.user, pay));
c.amount -= pay; d.amount -= pay;
if (c.amount > 0) creditors.add(c);
if (d.amount > 0) debtors.add(d);
}
return result;
}
}
5.4 Aggregate / service
// Group.java
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Aggregate root: members + expenses + balance sheet. One RW lock PER GROUP:
* different groups never contend, and within a group an expense touches many pairs so a
* per-pair lock would risk deadlock/partial updates. Reads (show balances) share the read lock.
*/
public final class Group {
private final String id, name;
private final Set<String> members;
private final SplitStrategyFactory strategies;
private final DebtSimplifier simplifier;
private final Clock clock;
private final BalanceSheet sheet = new BalanceSheet();
private final List<Expense> expenses = new ArrayList<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int seq;
public Group(String id, String name, Collection<String> members, SplitStrategyFactory strategies,
DebtSimplifier simplifier, Clock clock) {
this.id = id; this.name = name; this.members = new LinkedHashSet<>(members);
this.strategies = strategies; this.simplifier = simplifier; this.clock = clock;
}
public Expense addExpense(String description, String paidBy, String total, ExpenseType type,
List<String> participants, List<BigDecimal> params) {
long totalMinor = Money.toMinor(total);
if (totalMinor <= 0) throw new SplitwiseException("Amount must be positive");
if (participants.isEmpty() || new HashSet<>(participants).size() != participants.size())
throw new SplitwiseException("Participants must be non-empty and unique");
// compute outside the lock: pure function, so validation failures never touch shared state
List<Split> splits = strategies.get(type).split(totalMinor, participants, params);
lock.writeLock().lock();
try {
requireMember(paidBy);
participants.forEach(this::requireMember);
Expense e = new Expense(id + "-E" + (++seq), description, paidBy, totalMinor, type, splits, Instant.now(clock));
for (Split s : splits) sheet.addDebt(s.userId(), paidBy, s.amount()); // all-or-nothing under write lock
expenses.add(e);
return e;
} finally { lock.writeLock().unlock(); }
}
/** "from" hands cash to "to": reduces what from owes to. */
public void settle(String from, String to, String amount) {
long a = Money.toMinor(amount);
lock.writeLock().lock();
try {
requireMember(from); requireMember(to);
sheet.addDebt(to, from, a);
} finally { lock.writeLock().unlock(); }
}
public List<Transfer> pairwiseBalances() {
lock.readLock().lock();
try { return sheet.pairwise(); } finally { lock.readLock().unlock(); }
}
public Map<String, Long> netBalances() {
lock.readLock().lock();
try { return sheet.netBalances(); } finally { lock.readLock().unlock(); }
}
public List<Transfer> simplifiedDebts() {
return simplifier.simplify(netBalances());
}
public int expenseCount() {
lock.readLock().lock();
try { return expenses.size(); } finally { lock.readLock().unlock(); }
}
private void requireMember(String u) {
if (!members.contains(u)) throw new SplitwiseException("Not a member of " + name + ": " + u);
}
}
5.5 Demo driver
// Main.java
import java.math.BigDecimal;
import java.time.Clock;
import java.util.*;
import java.util.concurrent.*;
public class Main {
static List<BigDecimal> nums(String... v) { return Arrays.stream(v).map(BigDecimal::new).collect(java.util.stream.Collectors.toList()); }
static void show(Group g) {
System.out.println(" pairwise:");
g.pairwiseBalances().forEach(t -> System.out.println(" " + t.from + " owes " + t.to + " " + Money.format(t.amount)));
System.out.println(" net: " + fmt(g.netBalances()));
}
static String fmt(Map<String, Long> m) {
StringBuilder sb = new StringBuilder();
m.forEach((k, v) -> sb.append(k).append('=').append(Money.format(v)).append(' '));
return sb.toString().trim();
}
public static void main(String[] args) throws Exception {
Group trip = new Group("G1", "Goa Trip", List.of("A", "B", "C", "D"),
new SplitStrategyFactory(), new GreedyDebtSimplifier(), Clock.systemUTC());
trip.addExpense("Hotel", "A", "1000", ExpenseType.EQUAL, List.of("A", "B", "C", "D"), null);
trip.addExpense("Dinner", "B", "600", ExpenseType.EXACT, List.of("A", "B", "C"), nums("100", "200", "300"));
trip.addExpense("Cab", "C", "1000", ExpenseType.PERCENT, List.of("A", "B", "C"), nums("40", "30", "30"));
trip.addExpense("Snacks", "D", "100", ExpenseType.EQUAL, List.of("A", "B", "C"), null); // 33.34 / 33.33 / 33.33
System.out.println("== After 4 expenses ==");
show(trip);
System.out.println("== Simplified (minimum-ish transfers) ==");
trip.simplifiedDebts().forEach(t -> System.out.println(" " + t));
System.out.println("== Validation errors ==");
try { trip.addExpense("Bad", "A", "500", ExpenseType.EXACT, List.of("A", "B"), nums("100", "100")); }
catch (SplitwiseException e) { System.out.println(" " + e.getMessage()); }
try { trip.addExpense("Bad", "A", "500", ExpenseType.PERCENT, List.of("A", "B"), nums("60", "60")); }
catch (SplitwiseException e) { System.out.println(" " + e.getMessage()); }
try { trip.addExpense("Bad", "Z", "500", ExpenseType.EQUAL, List.of("A", "B"), null); }
catch (SplitwiseException e) { System.out.println(" " + e.getMessage()); }
System.out.println(" expenses recorded (failed ones left no trace): " + trip.expenseCount());
System.out.println("== Settle: first simplified transfer is paid ==");
Transfer first = trip.simplifiedDebts().get(0);
trip.settle(first.from, first.to, Money.format(first.amount));
trip.simplifiedDebts().forEach(t -> System.out.println(" " + t));
System.out.println("== Concurrency: 8 threads x 500 expenses ==");
Group g2 = new Group("G2", "Flat", List.of("X", "Y", "Z"), new SplitStrategyFactory(), new GreedyDebtSimplifier(), Clock.systemUTC());
ExecutorService pool = Executors.newFixedThreadPool(8);
List<Future<?>> fs = new ArrayList<>();
for (int t = 0; t < 8; t++) {
final String payer = List.of("X", "Y", "Z").get(t % 3);
fs.add(pool.submit(() -> {
for (int i = 0; i < 500; i++)
g2.addExpense("e", payer, "100.01", ExpenseType.EQUAL, List.of("X", "Y", "Z"), null);
}));
}
for (Future<?> f : fs) f.get();
pool.shutdown();
long sum = g2.netBalances().values().stream().mapToLong(Long::longValue).sum();
System.out.println(" expenses=" + g2.expenseCount() + " net=" + fmt(g2.netBalances()) + " sum(net)=" + sum + " (must be 0)");
}
}
Real output (from java Main)
== After 4 expenses ==
pairwise:
A owes C 150.00
B owes A 150.00
B owes D 33.33
C owes D 33.33
D owes A 216.66
net: A=216.66 B=-183.33 C=116.67 D=-150.00
== Simplified (minimum-ish transfers) ==
B pays A 183.33
D pays C 116.67
D pays A 33.33
== Validation errors ==
Exact amounts sum to 200.00 but total is 500.00
Percentages must add up to 100 but were 120
Not a member of Goa Trip: Z
expenses recorded (failed ones left no trace): 4
== Settle: first simplified transfer is paid ==
D pays C 116.67
D pays A 33.33
== Concurrency: 8 threads x 500 expenses ==
expenses=4000 net=X=16655.00 Y=16655.00 Z=-33310.00 sum(net)=0 (must be 0)
Check by hand for A: paid 1000, owes 250 + 100 + 400 + 33.34 = 783.34, net +216.66. The simplified list has 3 transfers for 4 people (worst case n-1).
6. Walkthrough of key flows
addExpense(Dinner, paidBy=B, 600, EXACT, [A,B,C], [100,200,300])
Client Group Factory/Strategy BalanceSheet
|--add---->| | |
| |-validate total>0, participants unique |
| |--get(EXACT)----------->| |
| |--split(60000, users, params)-> validates sum, returns [A:10000,B:20000,C:30000]
| | (exception here => nothing changed) |
| |-writeLock() |
| |-requireMember(payer, participants) |
| |-for each split: addDebt(user -> payer, amt)----->| net[B]+=, net[A]-=, owes netted
| |-unlock |
Simplify debts algorithm:
net: A=+216.66 B=-183.33 C=+116.67 D=-150.00
creditors max-heap: A 216.66, C 116.67 debtors max-heap: B 183.33, D 150.00
step1: max creditor A(216.66) vs max debtor B(183.33) -> B pays A 183.33 ; A left 33.33
step2: max creditor C(116.67) vs max debtor D(150) -> D pays C 116.67 ; D left 33.33
step3: A(33.33) vs D(33.33) -> D pays A 33.33
Each step zeroes at least one participant => at most n-1 transfers, O(n log n).
7. Extensibility
- New split type (BY_SHARES 2:1:1, BY_ITEM): add enum constant, implement
SplitStrategy,factory.register(...). No change toGroup,BalanceSheet. - Different simplification: implement
DebtSimplifier(e.g. exact search for <=10 members). - Notifications:
ExpenseListenerinjected into Group, invoked after releasing the lock. - Multi-currency:
MoneybecomesMoney(amount, currency), aBalanceSheetper currency, FX at settlement only. - Multiple payers:
paidBybecomesList<Split> payments; the balance update becomesnet[u] += paid[u] - owed[u]- same math, generalised. - Audit/undo: expenses are immutable; deletion = reverse entry (add inverse debts), not mutation.
8. Concurrency and edge cases
- Lock granularity: one
ReentrantReadWriteLockperGroup. Rationale: an expense updates several pairs and the net map; per-pair locks would need ordered acquisition and risk partial updates. Different groups never contend (typical load shape). Reads (balances, simplify) take the read lock and run concurrently. - Strategy computation happens outside the lock (pure function) so validation errors cost nothing and the critical section is only the in-memory updates.
- Invariant:
sum(net) == 0always (demo's concurrency run proves it with 4000 expenses over 8 threads). - All-or-nothing: membership checks occur before any mutation inside the write lock; the sequence number is only consumed after checks pass.
- Edge cases: payer not in participants (payer is owed the whole total); duplicate participants; zero/negative amounts; 3-way split remainder; percent like 33.33/33.33/33.34; payer paying for himself only (no-op); settle more than owed (flips the direction - allowed, or reject by policy); rounding in cross-currency.
- Pairwise map netting: A->B and B->A are collapsed so there is never a two-way debt.
9. Follow-up questions and model answers
- Is greedy optimal for minimum transfers? No. Minimising the number of transactions is NP-hard (it is equivalent to partitioning the non-zero balances into the maximum number of zero-sum subsets; a subset of size k needs k-1 transfers). Greedy guarantees <= n-1. For small n you can do DFS/bitmask DP over subsets:
dp[mask]= max number of zero-sum groups; answer = (non-zero count) - groups. - Why net balances before simplifying? Because only each person's net position matters for settling; the history of who paid what is irrelevant.
- Equal split of 100 among 3 - who pays the extra paisa? Deterministic rule (first participants / or random seeded by expense id); the key requirement is that shares sum to the total.
- How to persist? Tables:
users,groups,group_members,expenses(id, group_id, paid_by, total_minor, type, created_at),expense_splits(expense_id, user_id, amount_minor). Balances are a derived projection: either recompute (SUMper user) or maintain abalances(group_id, debtor, creditor, amount)table updated in the same transaction as the expense insert. Use integer minor units. Optimistic version column on the group to serialise updates. - Distributed? Shard by
group_id(all writes of a group land on one shard => single-node ACID transaction, no 2PC). Cache balances in Redis, invalidate on write. For the global "you owe in total" across groups, aggregate asynchronously from events (Kafka) into a per-user read model. - Idempotency for retries from the mobile app? Client-generated expense UUID as a unique key; duplicate insert = no-op.
- Concurrent edits (two members edit the same expense)? Expense has a
version; update only if version matches, else conflict response. - Real money? Splitwise does not move money; if integrated with UPI/wallet (PayU, NPCI), settlement creates a payment with an idempotency key and marks the debt settled on success callback.
- Read performance with 1000s of expenses? Maintain the balance sheet incrementally (as done) rather than replaying expenses.
10. Common mistakes, red flags, rubric
Red flags
double/floatfor money; shares not summing to total.- Subclass-per-split-type of
Expensewith copy-pasted logic. - Storing only expenses and recomputing on every read with O(n^2) loops, or not netting opposite debts.
- Not validating percent sum / exact sum.
- Mutating balances before validation (partial updates on failure).
- Global
synchronizedon everything, or no locking at all. - Describing simplify as "optimal" without acknowledging NP-hardness.
Mini rubric (20 pts)
| Area | Points |
|---|---|
| Requirements/scope, exact-money assumption | 3 |
| Strategy + factory for splits, validations | 5 |
| Balance sheet model + net/pairwise correctness | 4 |
| Simplify-debts algorithm and complexity | 4 |
| Thread-safety and atomicity of an expense | 2 |
| Runnable demo + follow-ups (persistence) | 2 |