Low Level Design (LLD) Interview Questions
Design Splitwise
Tier: CommonDifficulty: MediumAsked of: Mid, Senior
Keep balances pairwise, who owes whom, and derive the net position from that. Do not store a single net number per person. Net balances cannot tell you what you owe Ana, and that is the only question anybody actually opens the app to ask.
That modelling decision is the one most candidates get backwards, and it is the reason this problem is nothing like the booking ones. There is no race for a scarce thing here. There is a real algorithm instead, the debt simplification, and a shape to choose before you reach it.
What this really tests
Whether you can pick a data shape that answers the questions users have rather than the one that makes your algorithm easy, and whether you can implement the settlement algorithm and then be honest about what it does and does not guarantee.
What to clarify first
- What split types are needed. Equally, exact amounts, percentages, and shares. Equally is the common case and the others are the reason a seam exists.
- Are groups in scope, or just one to one debts. Groups are the interesting version and they are what the app is known for.
- Are multiple currencies in scope. Say no if you can. A cross currency ledger needs a rate at the time of the expense and it doubles the design.
- Can an expense be edited or deleted after the fact. This matters more than it sounds, because the ledger has already moved.
- Should the app simplify debts automatically, or only when asked. This is the tradeoff question and it is worth surfacing yourself.
- Does a settlement have to be recorded, or is it just marking things paid. It has to be recorded, and saying so early sets up the ledger design.
- Is there any payment integration. Almost always no. The app suggests, people pay each other elsewhere.
Out of scope, said plainly. Payments, receipt scanning, currency conversion, notifications, and friend graphs.
The classes
The model.
- Money is minor units and a currency. Never a double. A bill split three ways in floating point does not add back up to the bill, and this whole problem is about numbers adding up.
- User is an identity.
- Group is membership and the one currency the group settles in. The ledger deliberately does not live on it, because a ledger has to be locked and mutated as a unit and a value object is the wrong home for that. The currency sits here so the bare numbers in the ledger have exactly one meaning.
- Split is one person's share of one expense. It is the output of applying a rule, not the rule itself.
- Expense is who paid, how much, and how it was divided. It is immutable once recorded.
- Settlement is a payment that actually happened, recorded next to the expenses. A movement in the ledger with no document behind it is a balance nobody can explain.
The rules.
- SplitStrategy turns a total and a list of participants into a list of shares. Equal, exact and percentage on day one, and by shares or by adjustment later. Each rule carries its own configuration, which is why this is a small object rather than a lambda.
The ledger.
- BalanceSheet is who owes whom, held pairwise. It nets as it records, so two friends who take turns paying end up with one number rather than a growing pile of entries that cancel out. It derives net balances on demand, which means the derived view can never drift from the truth underneath it.
The settlement.
- Transfer is one suggested payment. A suggestion and nothing more, because the app cannot move anybody's money.
- DebtSimplifier turns net balances into a short list of transfers. This is the algorithm and it gets its own section.
The front door.
- SplitwiseService owns one ledger per group. Add an expense, settle up, ask what somebody owes, ask for a settlement plan, read the history of both kinds of movement.
The shape is one sentence. The service owns a ledger per group, records expenses it can never edit, and knows how a bill was divided only through an interface.
How an expense actually lands
Three friends are on a trip. Ana pays one hundred for dinner and splits it equally.
The service asks the strategy for the shares. Equal split of one hundred among three is not three shares of thirty three, and this is the small detail that catches people out. Somebody has to absorb the odd cent. The rule spreads the remainder over the first few participants, so the shares sum exactly to the bill and the answer is the same on every run.
The service then walks those shares into the ledger. Each participant now owes Ana their share, and Ana's own share is skipped, because nobody owes themselves. The ledger records each debt and immediately nets it against anything owed the other way.
The next day Ben pays thirty for a taxi and splits it equally. Ana now owes Ben ten. The ledger does not add a second entry. It nets, so Ben's thirty three, he was one of the two who did not absorb the odd cent, becomes twenty three and Ana's ten disappears. One number for one pair of friends, which is what the screen shows.
When somebody taps settle up, the service reads the current net balances, runs the simplifier, and returns a short list of who should pay whom. Those are suggestions, computed fresh. Caching them would hand somebody a payment plan that a new expense has already invalidated.
When a payment actually happens, it is recorded as another movement in the ledger in the opposite direction, and the settlement itself is written down next to the expenses. It is not a special record that erases a debt. Treating a settlement as an erasure is how you end up unable to explain a balance to a suspicious friend.
The debt simplification
This is the algorithmic payload and the reason this problem is worth asking.
Start from net balances. For each person, everything they are owed minus everything they owe. Positive means the group owes them. The positives and the negatives always sum to zero, because every expense put the same amount on both sides.
Then repeat one step. Take the person who is owed the most and the person who owes the most, and settle the smaller of the two amounts in a single payment. At least one of those two people is now at zero. Put whatever is left of the other one back and go again.
Two priority queues do this in a few lines. The whole thing finishes in at most one payment fewer than there are people with a non zero balance, which is the number people actually care about, because it is the number of times they have to open their banking app.
Now the honest part, and this is where the marks are. That greedy answer is not provably the minimum number of transactions. Finding the true minimum means searching for subsets of people whose balances already sum to zero and settling each subset on its own. That is subset sum, which is NP hard. Nobody solves it exactly for a real group, and every product ships the greedy version. Say this out loud, because interviewers who ask this problem usually know it and are listening for whether you do.
And then the part almost nobody says. Minimising the number of transactions is not always what users want.
Simplification produces transfers between people who never shared a meal. Ana pays Cara, when Ana's actual expenses were all with Ben. That is arithmetically correct and socially confusing, and the app has to explain it or people stop trusting the numbers. It also destroys the audit trail of who a debt came from, so a disputed expense becomes much harder to unwind.
That is why the real product makes simplification an opt in setting per group rather than something it just does. Say that. A design that knows why a correct algorithm is sometimes the wrong default is a better answer than one that only knows the algorithm.
Patterns actually used
Strategy on the split rule, and it is the only real seam here. Equal, exact and percentage are three rules the product ships immediately and there are more coming. Each one carries its own configuration, which is what makes it an object rather than a lambda.
The Java and the Kotlin differ here on purpose, and the difference is worth explaining rather than glossing over.
Java uses an interface and three implementations. Open, extensible from anywhere, and the shape any Java reviewer expects.
Kotlin uses a sealed interface with three data classes and one exhaustive when. Adding a rule makes the compiler point at exactly the place that has to handle it, which the open interface cannot do. The cost is that the set is closed, so nobody outside the module can add a rule. Here that is right, because the product defines the split rules. It would be wrong for something a plugin should extend. Naming that tradeoff is worth more than picking either one.
What is deliberately not here. No observer for balance change notifications, because it is the same fan out already shown in the food ordering answer. No singleton on the service, which most write ups add and which contributes nothing. No payment gateway, because the app does not move money. No command pattern on expenses even though undo sounds appealing, because a delete plus a new expense is simpler and leaves a better trail.
Java
com.androidinterview.splitwise.ledger.BalanceSheet.java
package com.androidinterview.splitwise.ledger;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
// Who owes whom, held pairwise rather than as one net number per person. That
// choice is deliberate. Pairwise is what lets the app answer the question users
// actually ask, which is what do I owe Ana, and net balances can always be
// derived from it. Going the other way is impossible.
//
// Every debt is netted against anything owed the other way as it is recorded,
// so two friends who take turns paying end up with one number instead of a
// growing pile of entries that cancel out.
public final class BalanceSheet {
private final Map<String, Map<String, Long>> owes = new HashMap<>();
public synchronized void addDebt(String debtorId, String creditorId, long amount) {
if (debtorId.equals(creditorId) || amount == 0) {
return;
}
long net = amount + amountOwed(debtorId, creditorId) - amountOwed(creditorId, debtorId);
clear(debtorId, creditorId);
clear(creditorId, debtorId);
if (net > 0) {
owes.computeIfAbsent(debtorId, key -> new HashMap<>()).put(creditorId, net);
} else if (net < 0) {
owes.computeIfAbsent(creditorId, key -> new HashMap<>()).put(debtorId, -net);
}
}
public synchronized long amountOwed(String debtorId, String creditorId) {
return owes.getOrDefault(debtorId, Map.of()).getOrDefault(creditorId, 0L);
}
// Positive means the group owes this person. Negative means they owe it.
// This is the view the simplifier works from, and deriving it fresh each
// time means it can never drift from the pairwise truth.
public synchronized Map<String, Long> netBalances() {
Map<String, Long> net = new LinkedHashMap<>();
owes.forEach((debtorId, creditors) -> creditors.forEach((creditorId, amount) -> {
net.merge(debtorId, -amount, Long::sum);
net.merge(creditorId, amount, Long::sum);
}));
net.values().removeIf(value -> value == 0);
return net;
}
private void clear(String debtorId, String creditorId) {
Map<String, Long> creditors = owes.get(debtorId);
if (creditors != null) {
creditors.remove(creditorId);
if (creditors.isEmpty()) {
owes.remove(debtorId);
}
}
}
}
com.androidinterview.splitwise.model.Expense.java
package com.androidinterview.splitwise.model;
import java.util.List;
// An expense is immutable once recorded. Editing one is really deleting it and
// adding a new one, because the ledger has already moved on and a silent edit
// would leave balances that do not match any expense anybody can see.
public record Expense(
String id,
String groupId,
String description,
String paidByUserId,
Money total,
List<Split> splits) implements LedgerEntry {
}
com.androidinterview.splitwise.model.Group.java
package com.androidinterview.splitwise.model;
import java.util.List;
// Membership and the one currency the group settles in. The balances do not
// live here, because a group's ledger has to be locked and mutated as a unit
// and a value object is the wrong place for that.
//
// The currency sits on the group so the ledger's bare numbers have exactly one
// meaning. A caller cannot ask for a settlement plan in a currency the group
// never spent in.
public record Group(String id, String name, String currency, List<String> memberIds) {
}
com.androidinterview.splitwise.model.LedgerEntry.java
package com.androidinterview.splitwise.model;
// Everything that ever moved the ledger, in one type. An expense and a
// settlement are both movements, and a balance you cannot explain by listing
// the movements behind it is a balance nobody will trust.
public sealed interface LedgerEntry permits Expense, Settlement {
String id();
String groupId();
}
com.androidinterview.splitwise.model.Money.java
package com.androidinterview.splitwise.model;
// Minor units, always. A bill split three ways in floating point does not add
// back up to the bill, and this whole problem is about numbers adding up.
public record Money(String currency, long amount) {
}
com.androidinterview.splitwise.model.Settlement.java
package com.androidinterview.splitwise.model;
// A payment that actually happened, recorded rather than inferred. The ledger
// movement on its own says the debt is smaller. This says who paid it, so the
// balance can be explained to a suspicious friend line by line.
public record Settlement(
String id,
String groupId,
String fromUserId,
String toUserId,
Money amount) implements LedgerEntry {
}
com.androidinterview.splitwise.model.Split.java
package com.androidinterview.splitwise.model;
// One person's share of one expense. The result of applying a split rule, not
// the rule itself.
public record Split(String userId, Money share) {
}
com.androidinterview.splitwise.model.User.java
package com.androidinterview.splitwise.model;
public record User(String id, String name) {
}
com.androidinterview.splitwise.service.SplitwiseService.java
package com.androidinterview.splitwise.service;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import com.androidinterview.splitwise.ledger.BalanceSheet;
import com.androidinterview.splitwise.model.Expense;
import com.androidinterview.splitwise.model.Group;
import com.androidinterview.splitwise.model.LedgerEntry;
import com.androidinterview.splitwise.model.Money;
import com.androidinterview.splitwise.model.Settlement;
import com.androidinterview.splitwise.model.Split;
import com.androidinterview.splitwise.settlement.DebtSimplifier;
import com.androidinterview.splitwise.settlement.Transfer;
import com.androidinterview.splitwise.split.SplitStrategy;
// The one class the app talks to. One ledger per group, because that is the
// unit people reason about and the unit a settlement covers.
//
// Two levels of safety, and they are separate on purpose. Each ledger has its
// own lock, so two groups never contend. The registries around them are
// concurrent collections, so creating a group cannot race an expense being
// added to another one.
public final class SplitwiseService {
private final Map<String, Group> groups = new ConcurrentHashMap<>();
private final Map<String, BalanceSheet> ledgers = new ConcurrentHashMap<>();
private final List<LedgerEntry> entries = new CopyOnWriteArrayList<>();
public void createGroup(Group group) {
groups.put(group.id(), group);
ledgers.put(group.id(), new BalanceSheet());
}
// Recording an expense is two steps that must not come apart. Work out the
// shares, then move every share into the ledger under the ledger's own
// lock. The payer's own share is skipped, because nobody owes themselves.
public Expense addExpense(String groupId, String description, String paidByUserId,
Money total, List<String> participantIds, SplitStrategy strategy) {
Group group = requireGroup(groupId);
if (!group.currency().equals(total.currency())) {
throw new IllegalArgumentException("the group settles in " + group.currency());
}
List<Split> splits = strategy.split(total, participantIds);
Expense expense = new Expense(UUID.randomUUID().toString(), groupId, description,
paidByUserId, total, splits);
BalanceSheet ledger = ledgers.get(groupId);
synchronized (ledger) {
for (Split split : splits) {
ledger.addDebt(split.userId(), paidByUserId, split.share().amount());
}
}
entries.add(expense);
return expense;
}
// A settlement is just another movement in the ledger, in the opposite
// direction. Treating it as a special kind of record that erases a debt is
// how you end up unable to explain a balance to a suspicious friend.
//
// The movement alone is not enough. The settlement is recorded next to the
// expenses, because a payment nobody can point at is exactly the balance
// that starts the argument.
public Settlement settleUp(String groupId, String fromUserId, String toUserId, Money amount) {
Group group = requireGroup(groupId);
if (!group.currency().equals(amount.currency())) {
throw new IllegalArgumentException("the group settles in " + group.currency());
}
Settlement settlement = new Settlement(UUID.randomUUID().toString(),
groupId, fromUserId, toUserId, amount);
BalanceSheet ledger = ledgers.get(groupId);
synchronized (ledger) {
ledger.addDebt(toUserId, fromUserId, amount.amount());
}
entries.add(settlement);
return settlement;
}
public long amountOwed(String groupId, String debtorId, String creditorId) {
return ledgers.get(groupId).amountOwed(debtorId, creditorId);
}
// Suggestions, computed fresh from the current ledger every time. Caching
// them would hand somebody a payment plan that a new expense has already
// invalidated.
public List<Transfer> suggestSettlement(String groupId) {
Group group = requireGroup(groupId);
BalanceSheet ledger = ledgers.get(groupId);
synchronized (ledger) {
return DebtSimplifier.simplify(ledger.netBalances(), group.currency());
}
}
// The audit trail, expenses and settlements together and in order. This is
// what turns a number on a screen into something a friend can check.
public List<LedgerEntry> historyIn(String groupId) {
return entries.stream().filter(entry -> entry.groupId().equals(groupId)).toList();
}
public List<Expense> expensesIn(String groupId) {
return historyIn(groupId).stream()
.filter(entry -> entry instanceof Expense)
.map(entry -> (Expense) entry)
.toList();
}
private Group requireGroup(String groupId) {
Group group = groups.get(groupId);
if (group == null) {
throw new IllegalArgumentException("no such group");
}
return group;
}
}
com.androidinterview.splitwise.settlement.DebtSimplifier.java
package com.androidinterview.splitwise.settlement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import com.androidinterview.splitwise.model.Money;
// The one piece of real algorithm in this problem.
//
// Take the person who is owed the most and the person who owes the most, settle
// the smaller of the two amounts in a single payment, and repeat. Every round
// takes at least one person to a balance of zero, so it finishes in at most one
// payment fewer than there are people with a non zero balance.
//
// It is not provably the minimum. Finding that means looking for subsets that
// already sum to zero, which is subset sum, which is NP hard. Say so out loud.
// The greedy answer is what every real product ships and the interviewer is
// listening for whether you know the difference.
public final class DebtSimplifier {
private record Balance(String userId, long amount) {}
private DebtSimplifier() {
}
public static List<Transfer> simplify(Map<String, Long> netBalances, String currency) {
// Ties are broken by user id so the same input always gives the same
// suggestions. A settlement screen that reshuffles between two loads
// looks broken even when it is correct.
Comparator<Balance> byUser = Comparator.comparing(Balance::userId);
PriorityQueue<Balance> creditors =
new PriorityQueue<>(Comparator.comparingLong(Balance::amount).reversed().thenComparing(byUser));
PriorityQueue<Balance> debtors =
new PriorityQueue<>(Comparator.comparingLong(Balance::amount).thenComparing(byUser));
netBalances.forEach((userId, amount) -> {
if (amount > 0) {
creditors.add(new Balance(userId, amount));
} else if (amount < 0) {
debtors.add(new Balance(userId, amount));
}
});
List<Transfer> transfers = new ArrayList<>();
while (!creditors.isEmpty() && !debtors.isEmpty()) {
Balance creditor = creditors.poll();
Balance debtor = debtors.poll();
long settled = Math.min(creditor.amount(), -debtor.amount());
transfers.add(new Transfer(debtor.userId(), creditor.userId(), new Money(currency, settled)));
long creditorLeft = creditor.amount() - settled;
long debtorLeft = debtor.amount() + settled;
if (creditorLeft > 0) {
creditors.add(new Balance(creditor.userId(), creditorLeft));
}
if (debtorLeft < 0) {
debtors.add(new Balance(debtor.userId(), debtorLeft));
}
}
return transfers;
}
}
com.androidinterview.splitwise.settlement.Transfer.java
package com.androidinterview.splitwise.settlement;
import com.androidinterview.splitwise.model.Money;
// One suggested payment. A suggestion and nothing more, because the app cannot
// move anybody's money.
public record Transfer(String fromUserId, String toUserId, Money amount) {
}
com.androidinterview.splitwise.split.EqualSplit.java
package com.androidinterview.splitwise.split;
import java.util.ArrayList;
import java.util.List;
import com.androidinterview.splitwise.model.Money;
import com.androidinterview.splitwise.model.Split;
// The common case, and the one with the detail everybody misses. A bill of one
// hundred split three ways is not three shares of thirty three.
public final class EqualSplit implements SplitStrategy {
@Override
public List<Split> split(Money total, List<String> participantIds) {
int people = participantIds.size();
if (people == 0) {
throw new IllegalArgumentException("an expense needs at least one participant");
}
long each = total.amount() / people;
long remainder = total.amount() - each * people;
// Somebody has to absorb the odd cent. Spreading it over the first few
// participants keeps the shares summing exactly to the bill and keeps
// the answer deterministic, which matters more than which friend pays
// the extra penny.
List<Split> splits = new ArrayList<>();
for (int i = 0; i < people; i++) {
long share = each + (i < remainder ? 1 : 0);
splits.add(new Split(participantIds.get(i), new Money(total.currency(), share)));
}
return splits;
}
}
com.androidinterview.splitwise.split.ExactSplit.java
package com.androidinterview.splitwise.split;
import java.util.List;
import java.util.Map;
import com.androidinterview.splitwise.model.Money;
import com.androidinterview.splitwise.model.Split;
// Amounts named per person. The rule carries its own configuration, which is
// why a strategy here is a small object rather than a lambda.
public final class ExactSplit implements SplitStrategy {
private final Map<String, Long> amounts;
public ExactSplit(Map<String, Long> amounts) {
this.amounts = Map.copyOf(amounts);
}
@Override
public List<Split> split(Money total, List<String> participantIds) {
// Every participant has to be named. Without this a missing person
// sails through the sum check whenever the others happen to cover the
// bill, and blows up on the way out with nothing useful to say.
for (String id : participantIds) {
if (!amounts.containsKey(id)) {
throw new IllegalArgumentException("no amount named for " + id);
}
}
long sum = participantIds.stream().mapToLong(id -> amounts.getOrDefault(id, 0L)).sum();
if (sum != total.amount()) {
throw new IllegalArgumentException("the named amounts do not add up to the bill");
}
return participantIds.stream()
.map(id -> new Split(id, new Money(total.currency(), amounts.get(id))))
.toList();
}
}
com.androidinterview.splitwise.split.PercentageSplit.java
package com.androidinterview.splitwise.split;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.androidinterview.splitwise.model.Money;
import com.androidinterview.splitwise.model.Split;
// Percentages as basis points, so nobody hands us a double and nobody has to
// argue about whether the shares add to a hundred.
public final class PercentageSplit implements SplitStrategy {
private static final int FULL = 10000;
private final Map<String, Integer> basisPoints;
public PercentageSplit(Map<String, Integer> basisPoints) {
this.basisPoints = Map.copyOf(basisPoints);
}
@Override
public List<Split> split(Money total, List<String> participantIds) {
int sum = participantIds.stream().mapToInt(id -> basisPoints.getOrDefault(id, 0)).sum();
if (sum != FULL) {
throw new IllegalArgumentException("the percentages do not add up to one hundred");
}
// The last participant takes whatever is left, so rounding can never
// leave the shares a cent short of the bill.
List<Split> splits = new ArrayList<>();
long allocated = 0;
for (int i = 0; i < participantIds.size(); i++) {
String id = participantIds.get(i);
boolean last = i == participantIds.size() - 1;
long nominal = Math.round(total.amount() * basisPoints.getOrDefault(id, 0) / (double) FULL);
long share = last ? total.amount() - allocated : nominal;
// The last share absorbs the rounding, and that is all it should
// absorb. More than a minor unit away from its own percentage means
// the input was inconsistent, a repeated participant for instance,
// in a way the sum check could not see.
if (last && Math.abs(share - nominal) > 1) {
throw new IllegalArgumentException("the percentages do not match the participants");
}
allocated += share;
splits.add(new Split(id, new Money(total.currency(), share)));
}
return splits;
}
}
com.androidinterview.splitwise.split.SplitStrategy.java
package com.androidinterview.splitwise.split;
import java.util.List;
import com.androidinterview.splitwise.model.Money;
import com.androidinterview.splitwise.model.Split;
// How a bill is divided. This is the one genuine seam in the problem, because
// equally, by exact amounts and by percentage are three rules the product ships
// on day one and more arrive later, by shares and by adjustment.
public interface SplitStrategy {
List<Split> split(Money total, List<String> participantIds);
}
Kotlin
com.androidinterview.splitwise.ledger.BalanceSheet.kt
package com.androidinterview.splitwise.ledger
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
// Who owes whom, held pairwise rather than as one net number per person. That
// choice is deliberate. Pairwise answers the question users actually ask, which
// is what do I owe Ana, and net balances can always be derived from it. Going
// the other way is impossible.
//
// Every debt is netted against anything owed the other way as it is recorded, so
// two friends who take turns paying end up with one number instead of a growing
// pile of entries that cancel out.
class BalanceSheet {
private val owes = mutableMapOf<String, MutableMap<String, Long>>()
private val guard = ReentrantLock()
// One expense has to land as one movement, so the caller needs a way to
// hold the same lock across several debts. The lock is reentrant, so
// addDebt inside the block takes it again for free, and it stays private so
// nothing outside can hold it longer than one block.
fun <T> transact(block: BalanceSheet.() -> T): T = guard.withLock { block() }
fun addDebt(debtorId: String, creditorId: String, amount: Long) {
if (debtorId == creditorId || amount == 0L) return
guard.withLock {
val net = amount + owedNoLock(debtorId, creditorId) - owedNoLock(creditorId, debtorId)
clear(debtorId, creditorId)
clear(creditorId, debtorId)
when {
net > 0 -> owes.getOrPut(debtorId) { mutableMapOf() }[creditorId] = net
net < 0 -> owes.getOrPut(creditorId) { mutableMapOf() }[debtorId] = -net
}
}
}
fun amountOwed(debtorId: String, creditorId: String): Long =
guard.withLock { owedNoLock(debtorId, creditorId) }
// Positive means the group owes this person. Derived fresh every time, so it
// can never drift from the pairwise truth underneath it.
fun netBalances(): Map<String, Long> = guard.withLock {
buildMap<String, Long> {
owes.forEach { (debtorId, creditors) ->
creditors.forEach { (creditorId, amount) ->
this[debtorId] = (this[debtorId] ?: 0L) - amount
this[creditorId] = (this[creditorId] ?: 0L) + amount
}
}
}.filterValues { it != 0L }
}
private fun owedNoLock(debtorId: String, creditorId: String) =
owes[debtorId]?.get(creditorId) ?: 0L
private fun clear(debtorId: String, creditorId: String) {
owes[debtorId]?.remove(creditorId)
if (owes[debtorId]?.isEmpty() == true) owes.remove(debtorId)
}
}
com.androidinterview.splitwise.model.Domain.kt
package com.androidinterview.splitwise.model
// Minor units, always. A bill split three ways in floating point does not add
// back up to the bill, and this whole problem is about numbers adding up.
data class Money(val currency: String, val amount: Long)
data class User(val id: String, val name: String)
// Membership and the one currency the group settles in. The ledger does not
// live here, because it has to be locked and mutated as a unit and a value
// object is the wrong place for that.
//
// The currency sits on the group so the ledger's bare numbers have exactly one
// meaning. A caller cannot ask for a settlement plan in a currency the group
// never spent in.
data class Group(val id: String, val name: String, val currency: String, val memberIds: List<String>)
// One person's share of one expense. The result of applying a rule, not the
// rule itself.
data class Split(val userId: String, val share: Money)
// Everything that ever moved the ledger, in one type. An expense and a
// settlement are both movements, and a balance you cannot explain by listing
// the movements behind it is a balance nobody will trust.
sealed interface LedgerEntry {
val id: String
val groupId: String
}
// Immutable once recorded. Editing an expense is really deleting it and adding
// a new one, because the ledger has already moved on.
data class Expense(
override val id: String,
override val groupId: String,
val description: String,
val paidByUserId: String,
val total: Money,
val splits: List<Split>,
) : LedgerEntry
// A payment that actually happened, recorded rather than inferred. The ledger
// movement on its own says the debt is smaller. This says who paid it, so the
// balance can be explained to a suspicious friend line by line.
data class Settlement(
override val id: String,
override val groupId: String,
val fromUserId: String,
val toUserId: String,
val amount: Money,
) : LedgerEntry
com.androidinterview.splitwise.service.SplitwiseService.kt
package com.androidinterview.splitwise.service
import com.androidinterview.splitwise.ledger.BalanceSheet
import com.androidinterview.splitwise.model.Expense
import com.androidinterview.splitwise.model.Group
import com.androidinterview.splitwise.model.LedgerEntry
import com.androidinterview.splitwise.model.Money
import com.androidinterview.splitwise.model.Settlement
import com.androidinterview.splitwise.settlement.Transfer
import com.androidinterview.splitwise.settlement.simplify
import com.androidinterview.splitwise.split.SplitStrategy
import com.androidinterview.splitwise.split.split
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
// The one class the app talks to. One ledger per group, because that is the unit
// people reason about and the unit a settlement covers.
//
// Two levels of safety, and they are separate on purpose. Each ledger has its
// own lock, so two groups never contend. The registries around them are
// concurrent collections, so creating a group cannot race an expense being
// added to another one.
class SplitwiseService {
private val groups = ConcurrentHashMap<String, Group>()
private val ledgers = ConcurrentHashMap<String, BalanceSheet>()
private val entries = CopyOnWriteArrayList<LedgerEntry>()
fun createGroup(group: Group) {
groups[group.id] = group
ledgers[group.id] = BalanceSheet()
}
// Work out the shares, then move every one of them into the ledger inside
// one transaction, so a second expense cannot interleave its debts with
// this one. The payer's own share is skipped inside addDebt, because nobody
// owes themselves.
fun addExpense(
groupId: String,
description: String,
paidByUserId: String,
total: Money,
participantIds: List<String>,
strategy: SplitStrategy,
): Expense {
val group = requireGroup(groupId)
require(group.currency == total.currency) { "the group settles in ${group.currency}" }
val splits = strategy.split(total, participantIds)
val expense = Expense(UUID.randomUUID().toString(), groupId, description, paidByUserId, total, splits)
ledgers.getValue(groupId).transact {
splits.forEach { addDebt(it.userId, paidByUserId, it.share.amount) }
}
entries += expense
return expense
}
// A settlement is just another movement in the ledger, in the opposite
// direction. Treating it as a special record that erases a debt is how you
// end up unable to explain a balance to a suspicious friend.
//
// The movement alone is not enough. The settlement is recorded next to the
// expenses, because a payment nobody can point at is exactly the balance
// that starts the argument.
fun settleUp(groupId: String, fromUserId: String, toUserId: String, amount: Money): Settlement {
val group = requireGroup(groupId)
require(group.currency == amount.currency) { "the group settles in ${group.currency}" }
val settlement = Settlement(UUID.randomUUID().toString(), groupId, fromUserId, toUserId, amount)
ledgers.getValue(groupId).addDebt(toUserId, fromUserId, amount.amount)
entries += settlement
return settlement
}
fun amountOwed(groupId: String, debtorId: String, creditorId: String): Long =
ledgers.getValue(groupId).amountOwed(debtorId, creditorId)
// Suggestions, computed fresh from the current ledger every time. Caching
// them would hand somebody a payment plan a new expense has already
// invalidated.
fun suggestSettlement(groupId: String): List<Transfer> =
simplify(ledgers.getValue(groupId).netBalances(), requireGroup(groupId).currency)
// The audit trail, expenses and settlements together and in order. This is
// what turns a number on a screen into something a friend can check.
fun historyIn(groupId: String): List<LedgerEntry> = entries.filter { it.groupId == groupId }
fun expensesIn(groupId: String): List<Expense> = historyIn(groupId).filterIsInstance<Expense>()
private fun requireGroup(groupId: String): Group =
requireNotNull(groups[groupId]) { "no such group" }
}
com.androidinterview.splitwise.settlement.DebtSimplifier.kt
package com.androidinterview.splitwise.settlement
import com.androidinterview.splitwise.model.Money
import java.util.PriorityQueue
// One suggested payment. A suggestion and nothing more, because the app cannot
// move anybody's money.
data class Transfer(val fromUserId: String, val toUserId: String, val amount: Money)
private data class Balance(val userId: String, val amount: Long)
// The one piece of real algorithm in this problem.
//
// Take the person who is owed the most and the person who owes the most, settle
// the smaller of the two amounts in a single payment, and repeat. Every round
// zeroes at least one person, so it finishes in at most one payment fewer than
// there are people with a non zero balance.
//
// It is not provably the minimum. Finding that means looking for subsets that
// already sum to zero, which is subset sum, which is NP hard. Say so out loud.
// The greedy answer is what every real product ships.
fun simplify(netBalances: Map<String, Long>, currency: String): List<Transfer> {
// Ties broken by user id, so the same ledger always produces the same
// suggestions. A settlement screen that reshuffles between two loads looks
// broken even when it is correct.
val creditors = PriorityQueue<Balance>(compareByDescending<Balance> { it.amount }.thenBy { it.userId })
val debtors = PriorityQueue<Balance>(compareBy<Balance> { it.amount }.thenBy { it.userId })
netBalances.forEach { (userId, amount) ->
when {
amount > 0 -> creditors += Balance(userId, amount)
amount < 0 -> debtors += Balance(userId, amount)
}
}
return buildList {
while (creditors.isNotEmpty() && debtors.isNotEmpty()) {
val creditor = creditors.poll()
val debtor = debtors.poll()
val settled = minOf(creditor.amount, -debtor.amount)
add(Transfer(debtor.userId, creditor.userId, Money(currency, settled)))
(creditor.amount - settled).takeIf { it > 0 }
?.let { creditors += Balance(creditor.userId, it) }
(debtor.amount + settled).takeIf { it < 0 }
?.let { debtors += Balance(debtor.userId, it) }
}
}
}
com.androidinterview.splitwise.split.SplitStrategy.kt
package com.androidinterview.splitwise.split
import com.androidinterview.splitwise.model.Money
import com.androidinterview.splitwise.model.Split
import kotlin.math.abs
import kotlin.math.roundToLong
// A sealed hierarchy rather than an open interface, and this is the one place
// the two languages diverge on purpose. Each rule carries its own configuration
// as a data class, and one exhaustive when covers all of them.
//
// Name the tradeoff, because it is real. A sealed set is closed, so nobody
// outside this module can add a rule. That is right here, since the product
// defines the split rules, and it would be wrong for something a plugin should
// extend.
sealed interface SplitStrategy {
data object Equally : SplitStrategy
data class ExactAmounts(val amounts: Map<String, Long>) : SplitStrategy
data class Percentages(val basisPoints: Map<String, Int>) : SplitStrategy
}
private const val FULL_IN_BASIS_POINTS = 10_000
fun SplitStrategy.split(total: Money, participantIds: List<String>): List<Split> = when (this) {
// The common case, and the one with the detail everybody misses. A bill of
// one hundred split three ways is not three shares of thirty three.
// Somebody absorbs the odd cent, and spreading it over the first few keeps
// the shares summing exactly to the bill and keeps the answer the same on
// every run.
SplitStrategy.Equally -> {
require(participantIds.isNotEmpty()) { "an expense needs at least one participant" }
val each = total.amount / participantIds.size
val remainder = total.amount - each * participantIds.size
participantIds.mapIndexed { index, id ->
Split(id, Money(total.currency, each + if (index < remainder) 1 else 0))
}
}
is SplitStrategy.ExactAmounts -> {
// Every participant has to be named. Without this a missing person
// sails through the sum check whenever the others happen to cover the
// bill, and blows up on the way out with nothing useful to say.
participantIds.firstOrNull { it !in amounts }?.let {
throw IllegalArgumentException("no amount named for $it")
}
val named = participantIds.sumOf { amounts.getValue(it) }
require(named == total.amount) { "the named amounts do not add up to the bill" }
participantIds.map { Split(it, Money(total.currency, amounts.getValue(it))) }
}
// The last participant takes whatever is left, so rounding can never leave
// the shares a cent short of the bill.
is SplitStrategy.Percentages -> {
require(participantIds.sumOf { basisPoints[it] ?: 0 } == FULL_IN_BASIS_POINTS) {
"the percentages do not add up to one hundred"
}
var allocated = 0L
participantIds.mapIndexed { index, id ->
val nominal =
(total.amount * (basisPoints[id] ?: 0) / FULL_IN_BASIS_POINTS.toDouble()).roundToLong()
val last = index == participantIds.lastIndex
val share = if (last) total.amount - allocated else nominal
// The last share absorbs the rounding, and that is all it should
// absorb. More than a minor unit away from its own percentage means
// the input was inconsistent, a repeated participant for instance,
// in a way the sum check could not see.
require(!last || abs(share - nominal) <= 1) {
"the percentages do not match the participants"
}
allocated += share
Split(id, Money(total.currency, share))
}
}
}
Concurrency and edge cases
The contention here is milder than in the booking problems, but it is not absent, and the failure mode is worse. A wrong balance is money.
Two people add an expense to the same group at the same moment. Both read the current balances, both compute new ones, and one write overwrites the other. The fix is that the ledger is only ever mutated through methods that hold its lock, and that recording a debt is a read and a write inside one critical section rather than a read followed by a write from outside. Note that the ledger is per group, so two groups never contend at all. The registries that hold the groups and the ledgers are concurrent collections of their own, because the group lock protects balances and nothing else.
Settling up while somebody adds an expense. A friend opens the settlement screen, sees pay Ana forty, and taps it. In between, somebody added a bill that changed the number. Because a settlement is recorded as a movement rather than as an erasure, this is harmless. Forty moves in the opposite direction and whatever is left is still correct. If settlement had been implemented as set this debt to zero, the new expense would be silently wiped out. That is the argument for movements over erasures, and it is a design decision rather than a locking one.
Editing or deleting an expense that is already in the ledger. The clean answer is that an expense is immutable and an edit is a reversing entry plus a new expense. The ledger stays append only in spirit, the trail explains itself, and nobody has to recompute history. If instead you mutate the expense and rebuild balances, every settlement recorded since then has to be replayed, and that is where the bugs live.
Be honest about the lock. Everything here uses a lock inside one process. A real service runs many, so the ledger has to be a row per pair in a database and the netting has to be a conditional update inside a transaction. The design does not change, only where the critical section lives. Say that rather than implying a synchronized method is the whole story.
The smaller cases, worth naming quickly.
- Rounding. Covered above for equal splits, and the same problem appears in percentages, where the last participant takes whatever is left so the shares always sum to the bill.
- A user leaves a group with a non zero balance. Block it, or force a settlement first. Silently dropping them loses money from the ledger.
- Somebody pays more than they owe. That is legal, and it just flips the direction of the pair. The netting handles it with no special case, which is a small sign the model is right.
- Self expenses. A payer who is also a participant does not owe themselves, and the ledger drops that entry rather than the caller having to remember.
- Mixed currencies. Out of scope here, and the honest answer is that each expense records the currency and the rate at the time, and balances are kept per currency rather than converted on the fly.
Watch