Problem 07 · Complete walkthrough•Intermediate•50 min

Splitwise

Design a shared-expense system that records who paid, calculates each person's fair share, shows who owes whom, and helps a group settle with fewer payments.

Ava pays a 120 dollar restaurant bill for Ava, Ben, and Cara, Ben pays a 60 dollar taxi bill for Ben and Cara, and their final net amounts show Ben owing Ava 10 dollars and Cara owing Ava 70 dollars
Two bills create several debts, but the final question is simple: after every person's paid amount and fair share are combined, who should pay whom?

1. Requirements (~5 minutes)

The interview prompt is often only:

“Design Splitwise.”

That sentence does not yet tell us what to build. Does one person pay the complete bill? Are unequal shares allowed? Should 33.33% + 33.33% + 33.34% be supported? Does “simplify” change old expenses? What happens when an invalid split is submitted?

Do not begin by drawing User, Expense, and Group. First make the money rules exact enough that two interviewers would calculate the same answer.

Use these four question groups every time

1
Actions

What must a user be able to do?

2
Rules

When does an action succeed, fail, or change state?

3
Errors

Which invalid actions must we reject?

4
Boundaries

What should we deliberately not build?

Ask these questions

AskWhich actions must the first version support?

Interviewer answer

Pre-created users in one group can record an expense, view pairwise balances, view a simplified payment plan, and settle a direct debt fully.

AskWho pays an expense, and who shares it?

Interviewer answer

One registered user pays the complete expense. One or more registered participants receive shares; the payer may or may not be among them.

AskWhich split rules are required?

Interviewer answer

Support EQUAL, EXACT, and PERCENTAGE. Exact amounts must total the bill. Percentages use basis points and must total 10,000 basis points, which means 100%.

AskHow should fractions of one cent be handled?

Interviewer answer

Store money as integer cents. Allocate leftover cents deterministically: for equal splits use sorted user IDs; for percentage splits use the largest fractional remainder, then user ID.

AskIf Ava owes Ben $30 and a later expense makes Ben owe Ava $50, what should the balance show?

Interviewer answer

Net debt between the same pair immediately. The result is one direction only: Ava is owed $20 by Ben.

AskWhat does simplifying debts mean?

Interviewer answer

Keep the accepted Expense history and pairwise BalanceSheet unchanged. Compute a separate suggested plan that preserves each user's net position and usually uses fewer payments.

AskWhat happens when an amount, user, exact split, or percentage split is invalid?

Interviewer answer

Return a clear rejected result. Do not create an expense ID, append history, or change any balance.

AskWhich currency, group, storage, notification, and concurrency rules are in scope?

Interviewer answer

Use one pre-created group, USD only, and sequential calls in one in-memory process. Leave persistence, exchange rates, notifications, and simultaneous writers for extensions.

Confirm the specification

Confirmed specification

10 requirements · 10 boundaries

The first version must

  1. 1.Users are pre-created with unique IDs; the first version handles one group and USD only.
  2. 2.One registered payer records a positive expense for one or more registered participants.
  3. 3.EQUAL splits divide cents evenly and give leftover cents to sorted user IDs.
  4. 4.EXACT shares must be positive and add to the expense total.
  5. 5.PERCENTAGE shares use positive basis points totaling 10,000 and allocate rounding cents deterministically.
  6. 6.Every participant except the payer owes their calculated share to the payer.
  7. 7.Opposite debts between the same pair cancel, so only one direction remains.
  8. 8.Invalid input returns a clear status and changes neither expense history nor balances.
  9. 9.The system can return pairwise balances and a non-mutating simplified payment plan.
  10. 10.A direct debt may be partially or fully settled, but a user cannot settle more than is owed.
Not building in this version10 boundaries
  • Multiple groups and group membership changes
  • Multiple currencies and exchange rates
  • Receipt images
  • Editing or deleting accepted expenses
  • Recurring expenses
  • Real bank or card payments
  • Persistent storage and APIs
  • Notifications
  • Concurrent or multi-server writes
  • Globally minimal number of settlements

2. Entities and relationships (~3 minutes)

Extract the nouns from the confirmed requirements: user, group, payer, participant, money, expense, split type, split rule, balance, debt, settlement, and receipt. A noun is only a candidate. It deserves a class when it owns meaningful state or an important rule.

Final model

Types we will create

Class
Owns changing state or coordinates rules.
Record
A Java record for validated, immutable data with value equality.
Interface
Defines behavior that another implementation can replace.
ExpenseServiceClass

Coordinates validation, split calculation, accepted history, balances, and settlement.

BalanceSheetClass

Owns current pairwise debt and netting.

DebtSimplifierClass

Calculates simplified payment suggestions.

MoneyRecord

Keeps whole cents and validation together.

UserRecord

Provides stable participant identity.

ExpenseRecord

Records one accepted bill and final shares.

DebtRecord

Exposes one directed balance safely.

SplitStrategyInterface

Keeps split calculations replaceable.

Enums

Enums name a small, fixed set of valid choices or outcomes.

SplitType

Names equal, exact, and percentage calculations.

ExpenseStatus

Explains an accepted or rejected expense.

SettlementStatus

Explains the result of recording a payment.

Fields

Fields are simple values stored inside another type; they do not need independent behavior in this version.

userId

Identifies payers, participants, debtors, and creditors.

description

Explains what an expense was for.

cents

Stores money without floating-point rounding.

percentage

Carries one participant's percentage share.

expenseId

Identifies one accepted expense in history.

See one expense move through the objects

Swipe to follow the flow →

A member records an expense through ExpenseService, which asks SplitStrategy for validated shares, creates Expense, applies debts to BalanceSheet, and lets DebtSimplifier read balances and suggest fewer payments
Share calculation happens before state changes; BalanceSheet owns pairwise debt, while DebtSimplifier only reads it to produce another view.

Read the orange arrows first. A Member asks ExpenseService to record a bill. ExpenseService asks SplitStrategy to calculate every share. Only after validated shares return does it create Expense and apply debts to BalanceSheet.

Now read the teal arrows. BalanceSheet returns current pairwise debts. DebtSimplifier reads those values and returns suggested payments. It never writes back. This flow explains the ownership boundary: Strategy owns calculation, BalanceSheet owns current debt, and ExpenseService only orders the work.

3. Class design (10–15 minutes)

Move from requirements to code with the same chain:

requirement → responsible object → state it remembers → method that changes or reads that state

Start with ExpenseService.recordExpense because it is the caller-visible action. Then follow its questions downward: are the users valid, which strategy calculates shares, and how does BalanceSheet apply them?

One class at a time

Design the state owners

Start with the coordinator, then follow the request into the classes that protect changing state. Select a class to see only the fields and behavior needed for its responsibility.

Class 1 of 2

ExpenseService

Coordinates user validation, split calculation, accepted expense history, balances, and settlement.

Requirement behind this class

Record valid expenses without partial state, return balances and simplification, and settle no more than is owed.

State it remembers

Private information required to perform this class's job.

Map<String, User> usersById

Rejects unknown payers and participants.

Map<SplitType, SplitStrategy> strategies

Selects the confirmed split algorithm.

BalanceSheet balanceSheet

Delegates every live debt mutation to its owner.

DebtSimplifier simplifier

Calculates a read-only alternative payment view.

List<Expense> expenses

Stores accepted expense history.

int nextExpenseNumber

Allocates IDs only after complete split validation.

Behavior it owns

Public use cases and internal rules kept beside that state.

recordExpense(...) Public API

Validates users and shares completely before creating history or debt.

balances() / simplifiedDebts()Public API

Returns current and simplified read-only debt views.

settle(...) Public API

Delegates a guarded payment to BalanceSheet.

select strategy / allocate IDInternal

Performs internal workflow only after expected failures are resolved.

Invariant it protects
Expense history, accepted IDs, and pairwise balances change together only after full validation.
Works with
SplitStrategy, BalanceSheet, DebtSimplifier, User, Expense, and result enums.
Does not own
Split arithmetic, signed debt netting, or simplification calculations.
Principle demonstrated
Orchestration stays thin by delegating each financial rule to its information owner.

Supporting types

These types carry stable data or define replaceable behavior. They support the state owners without becoming another place to store live workflow state.

Money, User, Expense, DebtRecord

Carry exact cents, identity, accepted history, and readable balances.

Value equality and immutable components make financial calculations and tests deterministic.

SplitStrategyInterface

Defines complete share calculation for one SplitType.

It returns a validated share map and cannot mutate BalanceSheet or history.

EqualSplitStrategy, ExactSplitStrategy, PercentageSplitStrategyImplementation

Own the three current share algorithms and their input rules.

ExpenseService selects them from an EnumMap without branching through each formula.

DebtSimplifierHelper

Reads current Debt values and suggests a smaller payment route.

It never writes the simplified result back because simplification preserves net position, not original history.

The explorer separates ExpenseService orchestration from BalanceSheet mutation. The following lessons explain the exact-cent arithmetic and netting rules that make the ledger correct.

Money concept: cents, remainders, and exact totals

The practical problem is a $10.00 bill shared by three users. Decimal division says $3.333..., but nobody can pay a fraction of one cent.

If we round every share to $3.33, the stored shares total $9.99. One cent disappears. If this happens across many bills, the balance sheet no longer matches the expense history.

The core rule is conservation of money: the sum of calculated shares must equal the original total exactly.

For an equal split, integer arithmetic makes the rule visible:

total = 1000 cents, people = 3
base = 1000 / 3 = 333 cents
remainder = 1000 % 3 = 1 cent
shares in sorted ID order = 334, 333, 333 cents

For percentages, each user first receives the whole-cent floor. Leftover cents go to the users with the largest fractional remainders; equal remainders use user ID. This is called the largest remainder method. It is deterministic and keeps the total exact.

Calculating N shares costs O(N log N) because the implementation sorts participant IDs or fractional remainders. A hash-map approach could reduce some work, but deterministic rounding would still need an ordering.

Balance concept: net opposite debts immediately

Suppose Ava owes Ben $30, then a new expense makes Ben owe Ava $50. Storing both arrows is valid history but a poor current balance.

BalanceSheet converts both updates to one signed value on the same sorted pair:

AVA → BEN $30  becomes +3000 on pair (AVA, BEN)
BEN → AVA $50  becomes -5000 on pair (AVA, BEN)
combined value = -2000
meaning: BEN owes AVA $20

This creates an invariant—something that must remain true after every operation: for one user pair, at most one debt direction exists. addDebt owns this rule because it owns the signed pair map.

Adding one debt is expected O(1) with the hash map. Returning balances costs O(P log P) to sort P non-zero pairs for predictable display.

Simplification concept: preserve net position, change the route

After the illustrated dinner and taxi, pairwise history says:

  • Ben owes Ava $40.
  • Cara owes Ava $40.
  • Cara owes Ben $30.

For each person, add money they should receive and subtract money they owe. Ava's net position is +$80, Ben's is -$10, and Cara's is -$70. A positive number means the group owes that user; a negative number means that user owes the group.

DebtSimplifier keeps two ordered groups: debtors and creditors. It repeatedly matches the largest remaining debtor with the largest remaining creditor and transfers the smaller remaining amount. The result is Cara → Ava $70 and Ben → Ava $10.

With U users and P pairwise debts, building net positions costs O(P). Priority queues make matching cost O(U log U), and the result uses at most U - 1 transfers. This greedy result is deterministic, but it is not promised to be the mathematically smallest possible number of payments for every input. That harder optimization is deliberately out of scope.

  1. 1StartAva, Ben, and Cara all have net position $0.
  2. 2DinnerAva pays $120 equally. Ben owes Ava $40; Cara owes Ava $40.
  3. 3TaxiBen pays $60 for Ben and Cara. Cara gains a new $30 debt to Ben.
  4. 4Compute netsAva is +$80, Ben is -$10, and Cara is -$70.
  5. 5MatchCara pays Ava $70, then Ben pays Ava $10.
  6. 6VerifyEvery user's final net amount is unchanged, while three pairwise debts become two suggestions.

Decide whether patterns help

Use: Strategy for split calculation

The rule truly has several versions now. SplitStrategy.calculate(total, participants, inputs) is the boundary. Equal, exact, and percentage strategies validate different inputs but all return Map&lt;String, Money&gt;. ExpenseService can add another split rule without changing history or BalanceSheet.

Do not force: Factory for Expense

There is one immutable Expense shape and one visible construction point after validation. A factory would hide simple fields without solving a creation rule. Introduce one only if several expense types later require different construction workflows.

Do not force: Observer for notifications

The confirmed version sends no notifications. There are no reactions to publish. Add events only when email, push, audit, or analytics consumers become real requirements.

Do not force: Singleton ExpenseService

A global instance would make tests share mutable balances and would not coordinate several application servers. Normal construction keeps users and strategies explicit.

Prove the rules interactively

Record the default dinner, then load and record the taxi. Compare the three pairwise debts with the two simplified suggestions. Finally load the invalid exact split: its shares total $85, not $90, so the simulator must reject it without changing balances.

Follow the money

Shared expense lab

Weekend group · 3 people

Record an expense

Shared by
Ava $40.00 · Ben $40.00 · Cara $40.00

Pairwise BalanceSheet

What each pair owes after opposite debts cancel.

Nobody owes anything yet.

Simplified suggestions

A fresh payment plan with the same net result.

No payment is needed.

Suggestions preserve each person's net total. They do not rewrite the stored expense history.

Accepted expenses

No expense recorded.

What the service did

  1. Ready: record the $120 dinner that Ava paid for all three friends.

4. Java implementation (~10 minutes)

What the code does

This Java 17 program records equal, exact, and percentage expenses; preserves every cent; nets pairwise debt; settles direct balances; and calculates a deterministic simplified payment plan.

Why it is structured this way

Money protects cents, each Strategy owns one split algorithm, BalanceSheet protects current debt, and DebtSimplifier stays a read-only calculation. ExpenseService coordinates them without copying their rules.

Follow one request through the code

  1. 1ExpenseService validates the payer and every participant.
  2. 2The selected SplitStrategy calculates all shares before any mutation.
  3. 3ExpenseService creates the accepted Expense and its ID.
  4. 4For every participant except the payer, BalanceSheet applies one debt.
  5. 5BalanceSheet combines the signed amount with any opposite debt.
  6. 6DebtSimplifier may later read balances and produce suggestions without rewriting them.

Code concepts to understand

Money as integer centsWhole cents avoid floating-point surprises and make exact totals testable.

Money stores a non-negative long. EqualSplitStrategy uses quotient and remainder. PercentageSplitStrategy uses basis points and integer numerators. No code stores double amounts, so $0.10 + $0.20 cannot become an imprecise binary fraction.

Strategy call flowThe pattern isolates only the share calculation that actually varies.

ExpenseService selects a strategy from an EnumMap, then calls calculate. The returned share map has one common shape. The strategies never edit balances; BalanceSheet does not know which split type produced an amount.

Signed canonical pairOne sorted key stores either direction of debt between two users.

PairKey.of always puts the lower user ID first. addDebt chooses a positive or negative delta from the actual debtor direction. Adding the delta automatically cancels opposite debt, and a zero result removes the map entry.

Validation before mutationAn invalid split cannot create an ID, history entry, or partial debt.

ExpenseService checks identities and asks Strategy for the complete share map first. It catches strategy input errors and returns INVALID_SPLIT. Only then does it increment the ID, append Expense, and call BalanceSheet.

Priority queues in simplificationThe largest remaining debtor and creditor can be selected repeatedly without re-sorting every time.

DebtSimplifier turns pairwise debts into net positions, adds non-zero users to two priority queues, and matches the queue heads. Each match clears at least one user, so there are at most U - 1 suggestions.

Why simplified debts are not storedA suggestion is a view of current obligations, not the source of truth.

Expense history explains why balances exist. Pairwise BalanceSheet explains which real user pairs owe money. Replacing those with suggestions would discard that relationship. The simplifier therefore returns new Debt values and never mutates its input.

Start with values shared by every layer

Read Money first because every algorithm depends on its whole-cent rule. Then read the identity, split type, Expense, and Debt records. They are immutable snapshots, so callers cannot edit an accepted share map later.

Money.java
Represents a non-negative amount using exact integer cents.
import java.util.Locale;

public record Money(long cents) {
  public Money {
      if (cents < 0) {
          throw new IllegalArgumentException("Money cannot be negative");
      }
  }

  public static Money ofCents(long cents) {
      return new Money(cents);
  }

  public String format() {
      return String.format(Locale.ROOT, "$%.2f", cents / 100.0);
  }
}
User.java
Stores the stable identity and display name of one registered user.
public record User(String id, String name) {
  public User {
      if (id == null || id.isBlank() || name == null || name.isBlank()) {
          throw new IllegalArgumentException("User id and name are required");
      }
  }
}
SplitType.java
Names the three supported share-calculation rules.
public enum SplitType {
  EQUAL,
  EXACT,
  PERCENTAGE
}
Expense.java
Stores the immutable facts and final shares of one accepted expense.
import java.util.Map;

public record Expense(
      String id,
      String description,
      String payerId,
      Money total,
      SplitType splitType,
      Map<String, Money> shares
) {
  public Expense {
      if (id == null || id.isBlank()
              || description == null || description.isBlank()
              || payerId == null || payerId.isBlank()
              || total == null || total.cents() == 0
              || splitType == null || shares == null || shares.isEmpty()) {
          throw new IllegalArgumentException("Valid expense fields are required");
      }
      shares = Map.copyOf(shares);
  }
}
Debt.java
Presents one immutable amount from a debtor to a creditor.
public record Debt(
      String debtorId,
      String creditorId,
      Money amount
) {
  public Debt {
      if (debtorId == null || debtorId.isBlank()
              || creditorId == null || creditorId.isBlank()
              || debtorId.equals(creditorId)
              || amount == null || amount.cents() == 0) {
          throw new IllegalArgumentException("Valid debt fields are required");
      }
  }
}

Implement the three split rules

The interface accepts one generic values map: exact strategies read cents; percentage strategies read basis points; equal strategy needs no values. ExpenseService hides this input detail from BalanceSheet.

SplitStrategy.java
Defines the common boundary for every share calculation.
import java.util.List;
import java.util.Map;

public interface SplitStrategy {
  Map<String, Money> calculate(
          Money total,
          List<String> participantIds,
          Map<String, Long> values
  );
}
EqualSplitStrategy.java
Divides cents equally and assigns leftover cents by sorted user ID.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class EqualSplitStrategy implements SplitStrategy {
  @Override
  public Map<String, Money> calculate(
          Money total,
          List<String> participantIds,
          Map<String, Long> ignored
  ) {
      List<String> ordered = participantIds.stream().sorted().toList();
      if (ordered.isEmpty()) {
          throw new IllegalArgumentException("Participants are required");
      }

      long base = total.cents() / ordered.size();
      long remainder = total.cents() % ordered.size();
      Map<String, Money> shares = new LinkedHashMap<>();
      for (int index = 0; index < ordered.size(); index++) {
          long cents = base + (index < remainder ? 1 : 0);
          if (cents == 0) {
              throw new IllegalArgumentException("Every share must be positive");
          }
          shares.put(ordered.get(index), Money.ofCents(cents));
      }
      return Map.copyOf(shares);
  }
}
ExactSplitStrategy.java
Accepts positive cent amounts only when they exactly total the bill.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

public final class ExactSplitStrategy implements SplitStrategy {
  @Override
  public Map<String, Money> calculate(
          Money total,
          List<String> participantIds,
          Map<String, Long> values
  ) {
      Set<String> expected = Set.copyOf(participantIds);
      if (expected.isEmpty() || expected.size() != participantIds.size()
              || values == null || !values.keySet().equals(expected)) {
          throw new IllegalArgumentException("One exact value per participant is required");
      }

      long sum = 0;
      Map<String, Money> shares = new LinkedHashMap<>();
      for (String id : expected.stream().sorted().toList()) {
          long cents = values.get(id);
          if (cents <= 0) {
              throw new IllegalArgumentException("Exact shares must be positive");
          }
          sum = Math.addExact(sum, cents);
          shares.put(id, Money.ofCents(cents));
      }
      if (sum != total.cents()) {
          throw new IllegalArgumentException("Exact shares must equal total");
      }
      return Map.copyOf(shares);
  }
}
PercentageSplitStrategy.java
Uses basis points and the largest remainders to preserve every cent.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

public final class PercentageSplitStrategy implements SplitStrategy {
  private record Fraction(String userId, long remainder) {}

  @Override
  public Map<String, Money> calculate(
          Money total,
          List<String> participantIds,
          Map<String, Long> basisPointsByUser
  ) {
      Set<String> expected = Set.copyOf(participantIds);
      if (expected.isEmpty() || expected.size() != participantIds.size()
              || basisPointsByUser == null
              || !basisPointsByUser.keySet().equals(expected)) {
          throw new IllegalArgumentException("One percentage per participant is required");
      }

      long basisPointSum = 0;
      long allocated = 0;
      Map<String, Long> centsByUser = new LinkedHashMap<>();
      List<Fraction> fractions = new ArrayList<>();

      for (String id : expected.stream().sorted().toList()) {
          long basisPoints = basisPointsByUser.get(id);
          if (basisPoints <= 0) {
              throw new IllegalArgumentException("Percentages must be positive");
          }
          basisPointSum = Math.addExact(basisPointSum, basisPoints);
          long numerator = Math.multiplyExact(total.cents(), basisPoints);
          long cents = numerator / 10_000;
          centsByUser.put(id, cents);
          allocated += cents;
          fractions.add(new Fraction(id, numerator % 10_000));
      }
      if (basisPointSum != 10_000) {
          throw new IllegalArgumentException("Percentages must total 100%");
      }

      fractions.sort(Comparator.comparingLong(Fraction::remainder)
              .reversed().thenComparing(Fraction::userId));
      long leftover = total.cents() - allocated;
      for (int index = 0; index < leftover; index++) {
          String id = fractions.get(index).userId();
          centsByUser.put(id, centsByUser.get(id) + 1);
      }

      Map<String, Money> shares = new LinkedHashMap<>();
      for (String id : expected.stream().sorted().toList()) {
          long cents = centsByUser.get(id);
          if (cents == 0) {
              throw new IllegalArgumentException("Every share must be positive");
          }
          shares.put(id, Money.ofCents(cents));
      }
      return Map.copyOf(shares);
  }
}

Add clear action results

Invalid user actions are expected outcomes, not crashes. ExpenseService converts validation failures into ExpenseStatus. BalanceSheet returns a settlement status when no matching debt exists or the amount is too large.

ExpenseStatus.java
Names every accepted or rejected record-expense outcome.
public enum ExpenseStatus {
  RECORDED,
  INVALID_INPUT,
  UNKNOWN_USER,
  INVALID_SPLIT
}
RecordExpenseResult.java
Carries the accepted Expense only when recording succeeds.
import java.util.Optional;

public record RecordExpenseResult(
      ExpenseStatus status,
      Optional<Expense> expense
) {
  public RecordExpenseResult {
      if (status == null || expense == null) {
          throw new IllegalArgumentException("Result fields are required");
      }
  }

  public static RecordExpenseResult accepted(Expense expense) {
      return new RecordExpenseResult(
              ExpenseStatus.RECORDED,
              Optional.of(expense)
      );
  }

  public static RecordExpenseResult rejected(ExpenseStatus status) {
      return new RecordExpenseResult(status, Optional.empty());
  }
}
SettlementStatus.java
Names every expected direct-settlement outcome.
public enum SettlementStatus {
  SETTLED,
  INVALID_AMOUNT,
  NO_MATCHING_DEBT,
  AMOUNT_EXCEEDS_DEBT
}

Let BalanceSheet protect pairwise debt

Read addDebt first. It turns a real direction into a signed delta on the canonical pair. Then read balances, which translates the private representation back into readable Debt values.

BalanceSheet.java
Owns canonical pairwise debt, opposite-direction netting, and direct settlement.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class BalanceSheet {
  private record PairKey(String first, String second) {
      static PairKey of(String left, String right) {
          return left.compareTo(right) < 0
                  ? new PairKey(left, right)
                  : new PairKey(right, left);
      }
  }

  private final Map<PairKey, Long> signedByPair = new HashMap<>();

  public void addDebt(String debtorId, String creditorId, Money amount) {
      if (debtorId == null || creditorId == null || amount == null
              || debtorId.equals(creditorId) || amount.cents() == 0) {
          return;
      }
      PairKey key = PairKey.of(debtorId, creditorId);
      long delta = debtorId.equals(key.first())
              ? amount.cents()
              : -amount.cents();
      long combined = Math.addExact(signedByPair.getOrDefault(key, 0L), delta);
      if (combined == 0) {
          signedByPair.remove(key);
      } else {
          signedByPair.put(key, combined);
      }
  }

  public SettlementStatus settle(
          String debtorId,
          String creditorId,
          Money amount
  ) {
      if (amount == null || amount.cents() == 0
              || debtorId == null || creditorId == null
              || debtorId.equals(creditorId)) {
          return SettlementStatus.INVALID_AMOUNT;
      }
      Debt current = balances().stream()
              .filter(debt -> debt.debtorId().equals(debtorId)
                      && debt.creditorId().equals(creditorId))
              .findFirst()
              .orElse(null);
      if (current == null) {
          return SettlementStatus.NO_MATCHING_DEBT;
      }
      if (amount.cents() > current.amount().cents()) {
          return SettlementStatus.AMOUNT_EXCEEDS_DEBT;
      }
      addDebt(creditorId, debtorId, amount);
      return SettlementStatus.SETTLED;
  }

  public List<Debt> balances() {
      List<Debt> result = new ArrayList<>();
      for (Map.Entry<PairKey, Long> entry : signedByPair.entrySet()) {
          PairKey key = entry.getKey();
          long signed = entry.getValue();
          if (signed > 0) {
              result.add(new Debt(key.first(), key.second(), Money.ofCents(signed)));
          } else {
              result.add(new Debt(key.second(), key.first(), Money.ofCents(-signed)));
          }
      }
      result.sort(Comparator.comparing(Debt::debtorId)
              .thenComparing(Debt::creditorId));
      return List.copyOf(result);
  }
}

Calculate a non-mutating simplified view

The simplifier receives only Debt snapshots. Its priority-queue entries are immutable; after a match, it inserts a new entry for any remaining amount.

DebtSimplifier.java
Preserves every user's net position while suggesting at most U - 1 payments.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;

public final class DebtSimplifier {
  private record Account(String userId, long cents) {}

  public List<Debt> simplify(List<Debt> balances) {
      Map<String, Long> net = new HashMap<>();
      for (Debt debt : balances) {
          net.merge(debt.debtorId(), -debt.amount().cents(), Long::sum);
          net.merge(debt.creditorId(), debt.amount().cents(), Long::sum);
      }

      Comparator<Account> largestFirst = Comparator
              .comparingLong(Account::cents).reversed()
              .thenComparing(Account::userId);
      PriorityQueue<Account> debtors = new PriorityQueue<>(largestFirst);
      PriorityQueue<Account> creditors = new PriorityQueue<>(largestFirst);
      for (Map.Entry<String, Long> entry : net.entrySet()) {
          if (entry.getValue() < 0) {
              debtors.add(new Account(entry.getKey(), -entry.getValue()));
          } else if (entry.getValue() > 0) {
              creditors.add(new Account(entry.getKey(), entry.getValue()));
          }
      }

      List<Debt> suggestions = new ArrayList<>();
      while (!debtors.isEmpty() && !creditors.isEmpty()) {
          Account debtor = debtors.remove();
          Account creditor = creditors.remove();
          long transfer = Math.min(debtor.cents(), creditor.cents());
          suggestions.add(new Debt(
                  debtor.userId(),
                  creditor.userId(),
                  Money.ofCents(transfer)
          ));
          if (debtor.cents() > transfer) {
              debtors.add(new Account(debtor.userId(), debtor.cents() - transfer));
          }
          if (creditor.cents() > transfer) {
              creditors.add(new Account(creditor.userId(), creditor.cents() - transfer));
          }
      }
      return List.copyOf(suggestions);
  }
}

Finish with the coordinator and executable demo

recordExpense shows the validation boundary. Notice that nextExpenseNumber++, history append, and debt updates all occur after Strategy succeeds.

ExpenseService.java
Coordinates registered users, split strategies, accepted history, balances, settlement, and simplification.
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

public final class ExpenseService {
  private final Map<String, User> usersById = new LinkedHashMap<>();
  private final Map<SplitType, SplitStrategy> strategies = new EnumMap<>(SplitType.class);
  private final List<Expense> expenses = new ArrayList<>();
  private final BalanceSheet balanceSheet = new BalanceSheet();
  private final DebtSimplifier simplifier = new DebtSimplifier();
  private int nextExpenseNumber = 1;

  public ExpenseService(List<User> users) {
      if (users == null || users.isEmpty()) {
          throw new IllegalArgumentException("Users are required");
      }
      for (User user : users) {
          if (usersById.putIfAbsent(user.id(), user) != null) {
              throw new IllegalArgumentException("Duplicate user: " + user.id());
          }
      }
      strategies.put(SplitType.EQUAL, new EqualSplitStrategy());
      strategies.put(SplitType.EXACT, new ExactSplitStrategy());
      strategies.put(SplitType.PERCENTAGE, new PercentageSplitStrategy());
  }

  public RecordExpenseResult recordExpense(
          String description,
          String payerId,
          Money total,
          List<String> participantIds,
          SplitType splitType,
          Map<String, Long> values
  ) {
      if (description == null || description.isBlank()
              || total == null || total.cents() == 0
              || participantIds == null || participantIds.isEmpty()
              || splitType == null || values == null
              || Set.copyOf(participantIds).size() != participantIds.size()) {
          return RecordExpenseResult.rejected(ExpenseStatus.INVALID_INPUT);
      }
      if (!usersById.containsKey(payerId)
              || participantIds.stream().anyMatch(id -> !usersById.containsKey(id))) {
          return RecordExpenseResult.rejected(ExpenseStatus.UNKNOWN_USER);
      }

      Map<String, Money> shares;
      try {
          shares = strategies.get(splitType)
                  .calculate(total, List.copyOf(participantIds), Map.copyOf(values));
      } catch (IllegalArgumentException | ArithmeticException error) {
          return RecordExpenseResult.rejected(ExpenseStatus.INVALID_SPLIT);
      }

      Expense expense = new Expense(
              "E" + nextExpenseNumber,
              description,
              payerId,
              total,
              splitType,
              shares
      );
      nextExpenseNumber++;
      expenses.add(expense);
      for (Map.Entry<String, Money> share : shares.entrySet()) {
          if (!share.getKey().equals(payerId)) {
              balanceSheet.addDebt(share.getKey(), payerId, share.getValue());
          }
      }
      return RecordExpenseResult.accepted(expense);
  }

  public SettlementStatus settle(
          String debtorId,
          String creditorId,
          Money amount
  ) {
      return balanceSheet.settle(debtorId, creditorId, amount);
  }

  public List<Expense> expenses() {
      return List.copyOf(expenses);
  }

  public List<Debt> balances() {
      return balanceSheet.balances();
  }

  public List<Debt> simplifiedDebts() {
      return simplifier.simplify(balanceSheet.balances());
  }
}
SplitwiseDemo.java
Runs the illustrated dinner, taxi, invalid split, simplification, and settlement cases.
import java.util.List;
import java.util.Map;

public final class SplitwiseDemo {
  public static void main(String[] args) {
      ExpenseService service = new ExpenseService(List.of(
              new User("AVA", "Ava"),
              new User("BEN", "Ben"),
              new User("CARA", "Cara")
      ));

      RecordExpenseResult dinner = service.recordExpense(
              "Dinner", "AVA", Money.ofCents(12_000),
              List.of("AVA", "BEN", "CARA"),
              SplitType.EQUAL, Map.of()
      );
      System.out.println("dinner: " + dinner.status());

      RecordExpenseResult taxi = service.recordExpense(
              "Taxi", "BEN", Money.ofCents(6_000),
              List.of("BEN", "CARA"),
              SplitType.EQUAL, Map.of()
      );
      System.out.println("taxi: " + taxi.status());

      System.out.println("pairwise:");
      service.balances().forEach(debt -> System.out.println(
              debt.debtorId() + " owes " + debt.creditorId()
                      + " " + debt.amount().format()
      ));

      System.out.println("simplified:");
      service.simplifiedDebts().forEach(debt -> System.out.println(
              debt.debtorId() + " pays " + debt.creditorId()
                      + " " + debt.amount().format()
      ));

      int debtCountBefore = service.balances().size();
      RecordExpenseResult invalid = service.recordExpense(
              "Groceries", "CARA", Money.ofCents(9_000),
              List.of("AVA", "BEN", "CARA"),
              SplitType.EXACT,
              Map.of("AVA", 5_000L, "BEN", 3_000L, "CARA", 500L)
      );
      System.out.println("invalid exact: " + invalid.status());
      System.out.println("unchanged debts: "
              + (debtCountBefore == service.balances().size()));

      System.out.println("settlement: " + service.settle(
              "CARA", "BEN", Money.ofCents(3_000)
      ));
      System.out.println("remaining direct debts: "
              + service.balances().size());
  }
}
  1. 1Initial stateExpense history and BalanceSheet are empty; next accepted ID is E1.
  2. 2Dinner callEqualSplitStrategy returns $40 for Ava, Ben, and Cara. E1 is created; Ben and Cara each gain a $40 debt to Ava.
  3. 3Taxi callEqualSplitStrategy returns $30 for Ben and Cara. E2 is created; Cara gains a $30 debt to Ben.
  4. 4SimplifyDebtSimplifier reads three debts and returns Cara → Ava $70 plus Ben → Ava $10 without changing the three stored debts.
  5. 5Invalid callThe $90 exact shares total only $85. ExpenseService returns INVALID_SPLIT before E3 or any debt is created.
  6. 6SettlementCara settles the direct $30 debt to Ben. BalanceSheet removes that pair; expense history remains unchanged.

5. Extensions (~5 minutes)

“Now support several groups”

The changed requirement adds group identity and membership. Today ExpenseService owns one user registry and one BalanceSheet. Introduce Group with members, expenses, and its own BalanceSheet, then let a thin service find a Group by ID. Do not put group IDs into every method while still sharing one global ledger.

“Allow users to edit or delete an expense”

The affected rule is how accepted shares change balances. ExpenseService currently only applies debt forward. Add a reversible ledger operation: remove the old Expense contribution, validate and calculate the replacement completely, then apply the new contribution as one unit. An audit entry is useful because financial history should explain the change.

“Record expenses from simultaneous requests”

The current scope assumes sequential calls. With two threads, expense IDs, history, and pair balances can race. In one Java process, protect the complete mutation boundary—ID allocation, history append, and all BalanceSheet updates—with one lock first. A database deployment needs a transaction and constraints; an in-process lock cannot protect several servers.

“Support several currencies”

Money must now include a Currency, and expenses must state whether balances stay separated per currency or use an exchange rate. Keep rate lookup and conversion outside SplitStrategy. Never add raw USD cents to EUR cents.

“Find the minimum possible number of payments”

DebtSimplifier currently uses a deterministic greedy rule and promises preserved net positions, not a global minimum. The new requirement changes its algorithm only, so a DebtSimplificationStrategy interface becomes justified. Keep the greedy version for large groups and add an exact search only for small groups, with the higher time cost stated clearly.

A strong spoken summary

“I first clarified payer versus participants, the three split types, cent rounding, pairwise netting, and rejection behavior. Money stores integer cents. Split strategies calculate a complete, exact share map before any state changes. BalanceSheet owns one signed value per user pair, while DebtSimplifier is a read-only view that preserves each user's net position. The current version is one in-memory group with sequential calls; groups, currencies, editing, and concurrent persistence are extensions.”