Splitwise

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

“Design an expense-sharing app like Splitwise.” The arithmetic is not the hard part. The interview is testing whether several payment and split rules can feed one trustworthy ledger without losing a paise or inventing two answers for the same debt.

Requirements

Fix the money and balance semantics before naming a service. “Split an expense” is still ambiguous until payer totals, participant shares, and settlement rules agree.

KindRequirement for this design
FunctionalUsers can create groups and add other users as members.
FunctionalA group records expenses with a description and a positive total in integer paise.
FunctionalOne or more group members may pay portions of the same expense; payer portions must sum to the expense total.
FunctionalAn expense can use EQUAL, EXACT, or PERCENT splitting among selected group members.
FunctionalComputed shares must sum to the expense total after deterministic rounding.
FunctionalThe system tracks pairwise balances that answer who owes whom and how much.
FunctionalA debtor can settle all or part of an outstanding pairwise balance.
FunctionalA user can see one net balance: money receivable minus money payable across the group.
Non-functionalMoney never uses floating-point rupees; every stored amount is an integer number of paise.
Non-functionalRecording an expense and updating its balances must be one atomic operation in a production store.

Payment or UPI integration, currency conversion, notifications, comments, and receipt images are out of scope. A settlement records that money moved elsewhere; this model does not initiate or verify the transfer.

Clarifying questions to ask

Each answer changes either the split contract or the source of truth for balances.

  • Can several people pay for one expense? Yes. paidBy contains one positive paise amount per payer, and those amounts must add to the total. The ledger first computes each user's net position for that expense, so somebody who both paid and participated is not charged twice.
  • Who may participate in a split? Any non-empty subset of the group. Every payer and participant must already be a member; an expense cannot silently add a user to the group.
  • What happens to ₹100 split equally among three people? ₹100 is 10,000 paise. The base share is 3,333 paise, and the remaining paise goes to the first user ID in lexicographic, or ordinary string, order. With Ada, Ben, and Chen, the shares are ₹33.34, ₹33.33, and ₹33.33. A product could rotate remainder ownership for fairness, but this interview model chooses a stable, testable rule.
  • How are percentage fractions rounded? Percentages arrive as integer basis points, where 10,000 basis points means 100%. Each share is floored to paise, then leftover paise go to the largest fractional remainders; user ID breaks a tie. This is the largest-remainder apportionment rule.
  • What does an exact split contain? One integer-paise amount per participant. The amounts must be non-negative and must already sum to the total, so ExactSplit performs validation rather than rounding.
  • Are balances stored pairwise or only as one net per user? Pairwise balances are the source of truth because the product must say who owes whom and support settleUp(from, to). A user's net is derived as incoming debt minus outgoing debt.
  • Can both Ada owes Ben and Ben owes Ada exist? No. Applying a reverse debt offsets the existing pair first. At most one positive direction remains for any unordered pair.
  • Do we simplify debts across an entire group automatically? No. Expense recording preserves pairwise history after local offsets. A separate simplification view may propose fewer settlement transfers from user nets, but it must not rewrite the audit trail without an explicit product decision.
  • Are group expenses different from one-off expenses? This scope records expenses in a group. A one-off expense can use an ephemeral two-or-more-person group or a later ExpenseContext; adding a second workflow now would duplicate membership and ledger rules.
  • May a settlement exceed the recorded debt? No. A partial settlement reduces the named debtor→creditor edge, and a full settlement removes it. Overpayment is a new transfer or credit, not a negative debt hidden inside settleUp().
  • Can an expense be edited or deleted? Not in the critical flow. A production design would post a compensating reversal and a corrected expense, or rebuild balances from an immutable journal. Mutating old ledger entries in place weakens auditability.
  • What concurrency should we assume? The skeleton is an in-memory, single-process model. Production code must persist the expense and all affected pair balances in one group-scoped transaction, with a version check or serializable write when two members add expenses together.

Entity extraction

Apply the noun-and-verb pass from requirements-to-classes to the clarified prompt:

  1. A user belongs to a group with other members.
  2. An expense has a description, a total, one or more payments, and several splits.
  3. A split type is equal, exact, or percent, and a split policy calculates per-user shares.
  4. A balance sheet stores pairwise debts between a debtor and a creditor.
  5. A settlement reduces one outstanding debt, while a net balance summarizes one user's position.

Sort those nouns by identity, value, actor, and replaceable behavior:

BucketNounsModel choice
Domain entitiesuser, group, expenseUser, Group, and Expense carry identity or recorded history
Value recordspayment, split, debtPayment, Split, and Debt hold integer amounts and user IDs
Enumsplit typeSplitType contains EQUAL, EXACT, and PERCENT
Replaceable policysplit policySplitStrategy with equal, exact, and percent implementations
Protected resourcebalance sheet, ledgerBalanceSheet owns pairwise debt and net-balance invariants
Actor / boundarymember adding or settling an expenseCalls ExpenseService; no extra actor class is needed in the core
Excluded conceptspayment rail, currency quote, notification, comment, receiptNo speculative classes in this model

The verb pass places rules beside the facts they need:

Verb phraseOwnerReason
add and validate group membersGroupThe group owns its membership boundary
calculate participant sharesselected SplitStrategyEqual, exact, and percent are interchangeable algorithms
record expense factsExpenseThe immutable record joins payments, splits, type, and description
coordinate expense creationExpenseServiceIt selects a strategy and orders membership, total, ledger, and history checks
turn payments and shares into debtsBalanceSheet.applyExpense()The ledger owns pairwise normalization
settle a debtor→creditor balanceBalanceSheet.settleUp()The operation changes one protected ledger edge
calculate a user's net balanceBalanceSheet.netBalance()It derives the answer from every incoming and outgoing pair

SplitStrategy is the Strategy pattern from strategy: the service is the context, and configuration selects one interchangeable share algorithm. BalanceSheet is not another strategy. It owns persistent debt state that every split algorithm feeds.

Core class diagram

Keep expense creation, all three strategies, and the ledger visible. Payment, Split, Debt, and SplitType remain typed values in fields rather than consuming four more boxes.

class diagram

Core Splitwise expense and balance model

Core Splitwise expense and balance model
  1. 1ExpenseService coordinates one expense without owning split arithmetic or debt state.
  2. 2The service selects a SplitStrategy from the requested SplitType.
  3. 3Group supplies the membership boundary and receives the finished expense.
  4. 4Only Users already held by the Group may pay or participate.
  5. 5Group retains immutable Expense records for history and audit.
  6. 6Payments and splits associate the Expense with its payer and participant Users.
  7. 7The group ledger owns directed debts, settlements, and derived net balances.
  8. 8EqualSplit apportions leftover paise with a deterministic user-ID tie-break.
  9. 9ExactSplit accepts only caller-supplied shares whose total already matches.
  10. 10PercentSplit converts basis points to paise with largest-remainder apportionment.

The interface is the Strategy seam. ExpenseService does not branch through three formulas after selection; it asks the chosen object for the same Split[] result. Adding a shares-based or weighted strategy changes the strategy registry and a new implementation, while the ledger remains unchanged.

Expense keeps the calculated splits, not only the original request. That preserves the exact paise allocation used by the ledger, including who absorbed a rounding remainder.

Critical-flow sequence diagram

The flow worth drawing is “add an expense and update balances.” Ada submits one group expense; the same sequence also supports several payers because applyExpense() receives the complete payment and share lists.

sequence diagram

Add an expense and update pairwise balances

Add an expense and update pairwise balances
  1. 1The service first proves that every payer and participant belongs to the group.
  2. 2The selected strategy returns integer-paise shares whose sum equals the total.
  3. 3Expense freezes the payments, calculated splits, and selected split type.
  4. 4BalanceSheet nets each user, then updates every matched debtor→creditor pair.
  5. 5The group records the same expense before its ID returns to the member.

If Ada paid 10,000 paise and her own share is 3,334, her expense position is a 6,666-paise credit. Ben and Chen each have a 3,333-paise debit. BalanceSheet matches those positions and records Ben → Ada and Chen → Ada; it never mistakes the whole payment for what the others owe.

In production, messages 7 through 10 belong in one transaction with the expense insert. Otherwise, a crash can leave balances that reference no expense or an expense that never reached its ledger.

Debt simplification is a separate problem

Once all user nets are known, a common interview follow-up asks for fewer transfers. Partition users into creditors and debtors, repeatedly match the largest remaining creditor with the largest remaining debtor, transfer the smaller absolute amount, and continue until both sides reach zero.

That greedy pass is fast and produces a compact valid settlement; each transfer clears at least one user's remaining position, so it needs at most k - 1 transfers for k non-zero users. It does not guarantee the globally smallest number of transfers. The exact minimum-count problem is NP-hard, meaning it is at least as hard as the hardest problems in NP, so present largest-creditor/largest-debtor matching as a practical heuristic, not as an exact optimizer.

Simplification also changes who pays whom. Keep it as a proposed settlement plan derived from net balances unless the requirements explicitly permit rewriting pairwise obligations and their audit history.

TypeScript skeleton

The skeleton implements all three split strategies, deterministic paise apportionment, multi-payer ledger updates, partial settlement, and derived net balances.

export {};
 
enum SplitType {
  Equal = "EQUAL",
  Exact = "EXACT",
  Percent = "PERCENT",
}
 
interface User {
  readonly id: string;
  readonly name: string;
}
 
interface Payment {
  readonly userId: string;
  readonly amountInPaise: number;
}
 
interface Split {
  readonly userId: string;
  readonly amountInPaise: number;
}
 
type SplitDetails =
  | {
      readonly type: SplitType.Equal;
      readonly participantIds: readonly string[];
    }
  | {
      readonly type: SplitType.Exact;
      readonly amountsByUser: ReadonlyMap<string, number>;
    }
  | {
      readonly type: SplitType.Percent;
      readonly basisPointsByUser: ReadonlyMap<string, number>;
    };
 
interface SplitStrategy {
  computeShares(
    totalInPaise: number,
    details: SplitDetails,
  ): readonly Split[];
}
 
function requirePaise(
  amount: number,
  label: string,
  allowZero = false,
): void {
  const minimum = allowZero ? 0 : 1;
 
  if (!Number.isSafeInteger(amount) || amount < minimum) {
    throw new Error(`${label} must be ${allowZero ? "non-negative" : "positive"} paise`);
  }
}
 
function sortedUnique(ids: readonly string[]): string[] {
  if (ids.length === 0) {
    throw new Error("at least one participant is required");
  }
 
  const sorted = [...ids].sort((a, b) => a.localeCompare(b));
 
  for (let index = 0; index < sorted.length; index += 1) {
    const id = sorted[index];
    if (id === undefined || id.trim().length === 0) {
      throw new Error("user ids are required");
    }
    if (index > 0 && id === sorted[index - 1]) {
      throw new Error(`duplicate user ${id}`);
    }
  }
 
  return sorted;
}
 
interface WeightedUser {
  readonly userId: string;
  readonly weight: number;
}
 
function apportion(
  totalInPaise: number,
  weightedUsers: readonly WeightedUser[],
): readonly Split[] {
  requirePaise(totalInPaise, "expense total");
  const userIds = sortedUnique(
    weightedUsers.map((entry) => entry.userId),
  );
  const weightByUser = new Map(
    weightedUsers.map((entry) => [entry.userId, entry.weight]),
  );
 
  let totalWeight = 0;
  for (const userId of userIds) {
    const weight = weightByUser.get(userId);
    if (weight === undefined || !Number.isSafeInteger(weight) || weight < 0) {
      throw new Error(`invalid weight for ${userId}`);
    }
    totalWeight += weight;
  }
 
  if (!Number.isSafeInteger(totalWeight) || totalWeight <= 0) {
    throw new Error("weights must have a positive safe-integer total");
  }
 
  const allocations = userIds.map((userId) => {
    const weight = weightByUser.get(userId) ?? 0;
    const numerator = totalInPaise * weight;
 
    if (!Number.isSafeInteger(numerator)) {
      throw new Error("expense is too large to apportion safely");
    }
 
    return {
      userId,
      amountInPaise: Math.floor(numerator / totalWeight),
      remainderNumerator: numerator % totalWeight,
    };
  });
 
  const flooredTotal = allocations.reduce(
    (sum, entry) => sum + entry.amountInPaise,
    0,
  );
  const paiseLeft = totalInPaise - flooredTotal;
  const ranked = [...allocations].sort(
    (a, b) =>
      b.remainderNumerator - a.remainderNumerator ||
      a.userId.localeCompare(b.userId),
  );
 
  for (let index = 0; index < paiseLeft; index += 1) {
    const entry = ranked[index];
    if (entry === undefined) {
      throw new Error("remainder allocation failed");
    }
    entry.amountInPaise += 1;
  }
 
  return allocations
    .sort((a, b) => a.userId.localeCompare(b.userId))
    .map(({ userId, amountInPaise }) => ({
      userId,
      amountInPaise,
    }));
}
 
class EqualSplit implements SplitStrategy {
  public computeShares(
    totalInPaise: number,
    details: SplitDetails,
  ): readonly Split[] {
    if (details.type !== SplitType.Equal) {
      throw new Error("EqualSplit received incompatible details");
    }
 
    return apportion(
      totalInPaise,
      sortedUnique(details.participantIds).map((userId) => ({
        userId,
        weight: 1,
      })),
    );
  }
}
 
class ExactSplit implements SplitStrategy {
  public computeShares(
    totalInPaise: number,
    details: SplitDetails,
  ): readonly Split[] {
    if (details.type !== SplitType.Exact) {
      throw new Error("ExactSplit received incompatible details");
    }
 
    requirePaise(totalInPaise, "expense total");
    const userIds = sortedUnique([...details.amountsByUser.keys()]);
    const splits = userIds.map((userId) => {
      const amountInPaise = details.amountsByUser.get(userId);
      if (amountInPaise === undefined) {
        throw new Error(`missing exact amount for ${userId}`);
      }
      requirePaise(amountInPaise, `share for ${userId}`, true);
      return { userId, amountInPaise };
    });
 
    const splitTotal = splits.reduce(
      (sum, split) => sum + split.amountInPaise,
      0,
    );
    if (splitTotal !== totalInPaise) {
      throw new Error("exact shares must sum to the expense total");
    }
 
    return splits;
  }
}
 
class PercentSplit implements SplitStrategy {
  public computeShares(
    totalInPaise: number,
    details: SplitDetails,
  ): readonly Split[] {
    if (details.type !== SplitType.Percent) {
      throw new Error("PercentSplit received incompatible details");
    }
 
    const userIds = sortedUnique([
      ...details.basisPointsByUser.keys(),
    ]);
    const weightedUsers = userIds.map((userId) => {
      const basisPoints = details.basisPointsByUser.get(userId);
      if (
        basisPoints === undefined ||
        !Number.isInteger(basisPoints) ||
        basisPoints < 0
      ) {
        throw new Error(`invalid percentage for ${userId}`);
      }
      return { userId, weight: basisPoints };
    });
 
    const basisPointTotal = weightedUsers.reduce(
      (sum, entry) => sum + entry.weight,
      0,
    );
    if (basisPointTotal !== 10_000) {
      throw new Error("percent shares must total 10000 basis points");
    }
 
    return apportion(totalInPaise, weightedUsers);
  }
}
 
interface Debt {
  readonly debtorId: string;
  readonly creditorId: string;
  readonly amountInPaise: number;
}
 
class BalanceSheet {
  private readonly debts = new Map<
    string,
    Map<string, number>
  >();
 
  public applyExpense(
    payments: readonly Payment[],
    splits: readonly Split[],
  ): void {
    if (payments.length === 0 || splits.length === 0) {
      throw new Error("payments and splits are required");
    }
 
    const positions = new Map<string, number>();
    let paidTotal = 0;
    let splitTotal = 0;
 
    for (const payment of payments) {
      requirePaise(payment.amountInPaise, "payment");
      paidTotal += payment.amountInPaise;
      positions.set(
        payment.userId,
        (positions.get(payment.userId) ?? 0) + payment.amountInPaise,
      );
    }
 
    for (const split of splits) {
      requirePaise(split.amountInPaise, "split", true);
      splitTotal += split.amountInPaise;
      positions.set(
        split.userId,
        (positions.get(split.userId) ?? 0) - split.amountInPaise,
      );
    }
 
    if (paidTotal !== splitTotal) {
      throw new Error("payments and splits must have the same total");
    }
 
    const creditors: Array<{ userId: string; amount: number }> = [];
    const debtors: Array<{ userId: string; amount: number }> = [];
 
    for (const [userId, position] of positions) {
      if (position > 0) creditors.push({ userId, amount: position });
      if (position < 0) debtors.push({ userId, amount: -position });
    }
 
    creditors.sort((a, b) => a.userId.localeCompare(b.userId));
    debtors.sort((a, b) => a.userId.localeCompare(b.userId));
 
    let creditorIndex = 0;
    let debtorIndex = 0;
 
    while (
      creditorIndex < creditors.length &&
      debtorIndex < debtors.length
    ) {
      const creditor = creditors[creditorIndex];
      const debtor = debtors[debtorIndex];
      if (creditor === undefined || debtor === undefined) {
        throw new Error("expense positions are inconsistent");
      }
 
      const amount = Math.min(creditor.amount, debtor.amount);
      this.addDebt(debtor.userId, creditor.userId, amount);
      creditor.amount -= amount;
      debtor.amount -= amount;
 
      if (creditor.amount === 0) creditorIndex += 1;
      if (debtor.amount === 0) debtorIndex += 1;
    }
 
    if (
      creditorIndex !== creditors.length ||
      debtorIndex !== debtors.length
    ) {
      throw new Error("expense does not balance to zero");
    }
  }
 
  public settleUp(
    debtorId: string,
    creditorId: string,
    amountInPaise: number,
  ): void {
    requirePaise(amountInPaise, "settlement");
    const outstanding = this.amountOwed(debtorId, creditorId);
 
    if (amountInPaise > outstanding) {
      throw new Error("settlement exceeds the outstanding debt");
    }
 
    this.setDebt(
      debtorId,
      creditorId,
      outstanding - amountInPaise,
    );
  }
 
  public amountOwed(debtorId: string, creditorId: string): number {
    return this.debts.get(debtorId)?.get(creditorId) ?? 0;
  }
 
  public netBalance(userId: string): number {
    let netInPaise = 0;
 
    for (const [debtorId, creditors] of this.debts) {
      for (const [creditorId, amount] of creditors) {
        if (creditorId === userId) netInPaise += amount;
        if (debtorId === userId) netInPaise -= amount;
      }
    }
 
    return netInPaise;
  }
 
  public balances(): readonly Debt[] {
    const result: Debt[] = [];
 
    for (const [debtorId, creditors] of this.debts) {
      for (const [creditorId, amountInPaise] of creditors) {
        result.push({ debtorId, creditorId, amountInPaise });
      }
    }
 
    return result.sort(
      (a, b) =>
        a.debtorId.localeCompare(b.debtorId) ||
        a.creditorId.localeCompare(b.creditorId),
    );
  }
 
  private addDebt(
    debtorId: string,
    creditorId: string,
    amountInPaise: number,
  ): void {
    if (debtorId === creditorId || amountInPaise === 0) return;
 
    const reverse = this.amountOwed(creditorId, debtorId);
    const offset = Math.min(reverse, amountInPaise);
 
    this.setDebt(creditorId, debtorId, reverse - offset);
    this.setDebt(
      debtorId,
      creditorId,
      this.amountOwed(debtorId, creditorId) + amountInPaise - offset,
    );
  }
 
  private setDebt(
    debtorId: string,
    creditorId: string,
    amountInPaise: number,
  ): void {
    const creditors = this.debts.get(debtorId);
 
    if (amountInPaise === 0) {
      creditors?.delete(creditorId);
      if (creditors?.size === 0) this.debts.delete(debtorId);
      return;
    }
 
    const target = creditors ?? new Map<string, number>();
    target.set(creditorId, amountInPaise);
    this.debts.set(debtorId, target);
  }
}
 
class Expense {
  public readonly payments: readonly Payment[];
  public readonly splits: readonly Split[];
 
  public constructor(
    public readonly id: string,
    public readonly description: string,
    public readonly totalInPaise: number,
    public readonly splitType: SplitType,
    payments: readonly Payment[],
    splits: readonly Split[],
  ) {
    this.payments = [...payments];
    this.splits = [...splits];
  }
}
 
class Group {
  private readonly members = new Map<string, User>();
  private readonly expenses: Expense[] = [];
  public readonly balances = new BalanceSheet();
 
  public constructor(
    public readonly id: string,
    public readonly name: string,
    members: readonly User[],
  ) {
    for (const member of members) {
      if (this.members.has(member.id)) {
        throw new Error(`duplicate group member ${member.id}`);
      }
      this.members.set(member.id, member);
    }
  }
 
  public requireMembers(userIds: readonly string[]): void {
    for (const userId of userIds) {
      if (!this.members.has(userId)) {
        throw new Error(`${userId} is not a member of ${this.name}`);
      }
    }
  }
 
  public record(expense: Expense): void {
    this.expenses.push(expense);
  }
}
 
class ExpenseService {
  private expenseSequence = 0;
 
  public constructor(
    private readonly strategies: ReadonlyMap<
      SplitType,
      SplitStrategy
    >,
  ) {}
 
  public addExpense(
    group: Group,
    description: string,
    totalInPaise: number,
    payments: readonly Payment[],
    details: SplitDetails,
  ): Expense {
    if (description.trim().length === 0) {
      throw new Error("expense description is required");
    }
    requirePaise(totalInPaise, "expense total");
    sortedUnique(payments.map((payment) => payment.userId));
 
    const paidTotal = payments.reduce(
      (sum, payment) => sum + payment.amountInPaise,
      0,
    );
    if (paidTotal !== totalInPaise) {
      throw new Error("payer amounts must sum to the expense total");
    }
 
    const strategy = this.strategies.get(details.type);
    if (strategy === undefined) {
      throw new Error(`no strategy registered for ${details.type}`);
    }
 
    const splits = strategy.computeShares(totalInPaise, details);
    group.requireMembers([
      ...payments.map((payment) => payment.userId),
      ...splits.map((split) => split.userId),
    ]);
 
    const expense = new Expense(
      `E-${++this.expenseSequence}`,
      description,
      totalInPaise,
      details.type,
      payments,
      splits,
    );
 
    group.balances.applyExpense(payments, splits);
    group.record(expense);
    return expense;
  }
 
  public settleUp(
    group: Group,
    debtorId: string,
    creditorId: string,
    amountInPaise: number,
  ): void {
    group.requireMembers([debtorId, creditorId]);
    group.balances.settleUp(
      debtorId,
      creditorId,
      amountInPaise,
    );
  }
}
 
function formatPaise(amountInPaise: number): string {
  return `${(amountInPaise / 100).toFixed(2)}`;
}
 
function formatSignedPaise(amountInPaise: number): string {
  if (amountInPaise === 0) return "₹0.00";
  const sign = amountInPaise > 0 ? "+" : "-";
  return `${sign}${formatPaise(Math.abs(amountInPaise))}`;
}
 
const ada: User = { id: "ada", name: "Ada" };
const ben: User = { id: "ben", name: "Ben" };
const chen: User = { id: "chen", name: "Chen" };
const names = new Map([
  [ada.id, ada.name],
  [ben.id, ben.name],
  [chen.id, chen.name],
]);
 
const group = new Group(
  "g-trip",
  "Weekend trip",
  [ada, ben, chen],
);
const service = new ExpenseService(
  new Map<SplitType, SplitStrategy>([
    [SplitType.Equal, new EqualSplit()],
    [SplitType.Exact, new ExactSplit()],
    [SplitType.Percent, new PercentSplit()],
  ]),
);
 
service.addExpense(
  group,
  "Dinner",
  10_000,
  [{ userId: ada.id, amountInPaise: 10_000 }],
  {
    type: SplitType.Equal,
    participantIds: [ada.id, ben.id, chen.id],
  },
);
 
service.addExpense(
  group,
  "Taxi",
  6_000,
  [{ userId: ben.id, amountInPaise: 6_000 }],
  {
    type: SplitType.Exact,
    amountsByUser: new Map([
      [ada.id, 2_000],
      [ben.id, 1_000],
      [chen.id, 3_000],
    ]),
  },
);
 
for (const debt of group.balances.balances()) {
  console.log(
    `${names.get(debt.debtorId)} owes ${names.get(debt.creditorId)}: ${formatPaise(debt.amountInPaise)}`,
  );
}
 
for (const user of [ada, ben, chen]) {
  console.log(
    `${user.name} net: ${formatSignedPaise(group.balances.netBalance(user.id))}`,
  );
}

Ben owes Ada: ₹13.33 Chen owes Ada: ₹33.33 Chen owes Ben: ₹30.00 Ada net: +₹46.66 Ben net: +₹16.67 Chen net: -₹63.33

The equal dinner gives Ada the one-paise remainder in her own share. The exact taxi then creates Ada → Ben for ₹20, which offsets part of the existing Ben → Ada debt and leaves only ₹13.33 in that direction. Every pair has one positive direction, and the three net balances sum to zero.

The in-memory ordering makes the responsibility map executable, not transaction-safe. A durable version should append the expense and update versioned pair rows together. Retries also need an idempotency key, a request identifier that makes repeated submissions return the first result instead of posting the expense twice.

Design-review checklist

This is where the interviewer tests the invariants behind the arithmetic.

  • Rounding and remainder ownership: Equal and percent splits allocate integer paise, never floating-point rupees. State the tie-break before coding. A deterministic rule makes retries and tests agree; a rotating rule would need persisted rotation state.
  • Split totals: Every strategy must return exactly one share per participant and a sum equal to the expense total. Exact shares validate the caller's numbers; percentages validate 10,000 basis points before apportionment.
  • Multiple payers: Convert paid - consumed share into one net position per user before creating edges. Pairing every participant with every payer directly can overstate debt when a payer also owes a share.
  • Pairwise versus net storage: Pairwise rows answer who should settle with whom and preserve local history, but they require more writes. Per-user nets are compact and make simplification easy, yet cannot reconstruct the original counterparty. This design stores pairs and derives nets.
  • Symmetric balance invariant: Never retain positive A → B and B → A rows together. Offset the reverse direction atomically, delete zero rows, and verify that all group nets still sum to zero.
  • Why Strategy earns its seam: EQUAL, EXACT, and PERCENT vary only in share calculation and return the same value shape. ExpenseService selects once and delegates, matching the Strategy pattern instead of carrying a growing formula switch.
  • Debt simplification limit: Largest-creditor/largest-debtor matching produces a valid plan with at most k - 1 transfers for k non-zero users. It is a heuristic for transaction count; the exact minimum-count problem is NP-hard in general.
  • Settlement semantics: A settlement reduces a named pair and cannot exceed it. Record a settlement event with actor, timestamp, and idempotency key in production; do not erase the expense that created the debt.
  • Concurrent expenses: Two requests can both read the same pair before either writes its offset. Use a group version, serializable transaction, or locked pair rows, and retry the whole expense command rather than individual balance increments.
  • Failure ordering: Strategy validation has no side effects and runs first. Expense persistence, all pair changes, and the group journal append then commit together; partial success would break reconciliation.
  • Editing and deletion: Prefer immutable expenses plus reversals when audit matters. If product requirements permit edits, recompute the old contribution and the new contribution in one transaction instead of patching visible totals.
  • Testing split strategies: Cover ₹100 among three users, zero exact shares, invalid exact totals, percentage fractions, percentage totals other than 100%, duplicate participants, and deterministic ID ties. Property tests should assert non-negative integer shares and an exact total.
  • Testing the ledger: Cover one payer, several payers, payer-participants, reverse-edge offsets, partial and excessive settlements, and net totals. After every operation, no opposite pair may coexist and the sum of all user nets must be zero.
  • With more time: Add an immutable expense journal, settlement records, idempotent commands, repository ports, authorization, per-currency group boundaries, and a derived simplification view only after each consistency promise is explicit.

Checkpoint

Answer all three to mark this lesson complete

Splitwise separates allocation from accounting: strategies decide each share, while one ledger protects debt and settlement invariants. Next, the elevator system uses Strategy for car assignment and State for a car that changes its own behavior while it moves.

+50 XP on completion