Splitwise System Design Interview: Questions, Architecture and Trade-offs
"Design Splitwise" (or "design a shared-expense app") sounds friendly right up until an interviewer asks what happens when two people edit the same expense at once, or whether 33.33% + 33.33% + 33.34% actually adds up to the original amount. This page treats a fictional group called Weekend Trip — three members, Asha, Ben and Cara, splitting costs in INR — as the running example, so every claim about balances, splits, and repayments can be checked against specific numbers instead of hand-waved. This is an original design for the general "shared expense manager" interview problem, not a description of any real company's internal architecture. You'll get the opening clarifying questions, a realistic 40-45 minute walkthrough, a 35+ question bank with reasoned answers, whiteboard exercises, a strong-vs-weak comparison, and a quick-revision summary.
Clarify the Problem
"Design Splitwise" is underspecified in a way that's easy to miss, because everyone has either used an expense-splitting app or can picture one instantly. A toy calculator that splits one bill evenly, a full ledger that tracks running balances across dozens of groups and years, and a system that also processes real money transfers are three very different systems that all answer the question "who owes whom."
The first few minutes should separate "tracking who owes what" from "moving money." A shared-expense manager's core job is correct bookkeeping: recording expenses, deriving balances, and suggesting settlements. Whether it also initiates real payment transfers (via a payment provider) is a separate, much larger scope decision. Losing this boundary early is a common failure mode — candidates start describing card-network integrations and PCI compliance in minute three when the actual system under design is a ledger and balance-derivation problem.
- Scope: generic shared-expense system vs. literal Splitwise feature parity — state which, since it changes whether the interview covers recurring expenses, currency conversion UI, or just the core expense/balance/settlement loop. This page assumes the generic version with the Weekend Trip group (Asha, Ben, Cara, INR) as the worked example.
- Are real payments in scope? Recording that money changed hands (a settlement record) is very different from actually moving money through a payment provider. Default to: the system tracks settlements as asserted or provider-confirmed events, and payment execution is a pluggable external concern.
- Which split types are required? Equal, exact amounts, percentage, and shares/weights are at minimum four distinct allocation strategies, each with its own validation and rounding rules — not one generic "split" function.
- Can an expense have multiple payers (e.g. two people fronted part of a hotel bill together), or is payer always a single person per expense? This changes the domain model from a single payer_id field to a payer list with its own allocation.
- Are cross-group debts netted? If Asha and Ben are both in "Weekend Trip" and also in "Roommates", do their balances combine into one number, or stay strictly per-group? Splitwise-style products usually keep this explicit and configurable, not silently merged.
- Which currencies are supported, and is cross-currency netting ever implicit? State up front: amounts are tracked in minor units (paise for INR) as integers, never floating point, and different currencies are different ledgers that are never silently combined.
- Can an expense be edited or deleted after creation, and what's the correction semantic — in-place mutation, or an immutable append-only correction that preserves history?
- What read consistency does a user expect after they submit an expense? Must their own balance update immediately on their own device (read-your-writes), even if other members' views can lag slightly behind?
- Scale and latency expectations: how many groups, how many members per group, how many expenses per group over its lifetime — this determines whether balances can be computed on read or must be maintained as a materialized projection.
- Debt simplification: is reducing N pairwise debts to fewer suggested transfers required, and is it understood to be a suggestion a user can ignore, never a forced transfer?
- Offline / intermittent connectivity: can a client create an expense while offline and sync later, and if so, how are conflicting concurrent edits reconciled when it reconnects?
- Idempotency and duplicate handling: what happens if a client retries an expense-creation request after a timeout — is there a documented contract for exactly-once financial effect?
“I'll design a generic shared-expense manager, using a fictional group called Weekend Trip with three members — Asha, Ben, and Cara — settling in INR as the running example, tracking amounts in paise as integers rather than floating-point currency. I'll treat expense recording and payment execution as separate concerns: the system is authoritative about who-owes-whom derived from a ledger of expenses and settlements, but actually moving money is a pluggable, external payment-provider integration, not something I'll design the rails for unless you want me to. I'll support four split types — equal, exact, percentage, and shares — each with a deterministic rounding rule so shares always sum exactly to the total. Balances are per-group by default, never silently netted across groups or currencies, and every correction is an auditable new event, never an in-place edit that erases history. Does that match the scope you want?”
The 40-45 Minute Walkthrough
This is a pacing guide, not a script. A strong interviewer will branch into whatever they want to probe first — follow them there and return to the rest only if time remains.
| Time | Focus | What to cover |
|---|---|---|
| 0-5 min | Scope | Scope the generic shared-expense system using Weekend Trip (Asha/Ben/Cara, INR) as the running example; state payments-out-of-scope-by-default; name the four split types up front since every later example depends on them. |
| 5-10 min | Requirements and invariants | Functional requirements (create/edit/delete expense, view balances, suggest settlements, record a settlement); the handful of invariants you'll defend all interview: integer minor units only, shares sum exactly to the total via a deterministic rounding rule, zero-sum per closed currency ledger, payer and beneficiary are distinct roles. |
| 10-20 min | Domain model, schema, and correct split math | Walk through Group, Member, Expense, ExpenseShare, LedgerEntry, Settlement entities; derive Vector A (Asha pays ₹900 dinner, equal split, Asha +₹600 / Ben −₹300 / Cara −₹300) by hand on the whiteboard to prove the split math and the ledger model agree. |
| 20-30 min | Balances, simplification, API, and concurrency | Derive net balances from raw ledger entries with a SQL-style aggregation; walk the debt-simplification algorithm using Vector B's combined net (Asha +₹500, Ben +₹100, Cara −₹600) collapsing four raw pairwise debts into two suggested transfers; design the create-expense API and its idempotency contract (Vector F); discuss Vector G's concurrent-edit conflict on expense version 4. |
| 30-40 min | Scaling, reliability, and trade-offs | Materialized balance projections vs. on-read aggregation at scale; transactional outbox for keeping the ledger and any downstream notification/projection consistent; multi-currency (Vector H) as strictly separate ledgers; settlement state (Vector J, pending vs confirmed) as a deliberate trade-off. |
| Remaining time | Interviewer follow-ups and recap | Whatever the interviewer digs into — reversal/audit (Vector I), data repair, privacy. Close with a short recap of the architecture and its explicit trade-offs. |
Resist drawing this full diagram in minute two. The value of the walkthrough is watching you arrive at this shape because of problems you named yourself: the "rounding a percentage split loses or gains a paisa" problem motivates a deterministic remainder rule; the "two people edit the same expense at the same time" problem motivates expense versioning; the "a network retry shouldn't double-charge the group" problem motivates idempotency keys. An interviewer who sees the final diagram at minute two can't tell whether you understand it or memorized it.
Interview Question Bank
At least 35 distinct questions, grouped by theme, each with a reasoned answer angle and at least one follow-up. These cover requirements through staff-level trade-off reasoning — pace yourself; a real interview samples from here, it doesn't ask all of them.
Requirements and Modeling
What's the minimum set of entities needed to model this system?
A Group (Weekend Trip), its Members (Asha, Ben, Cara), an Expense (who paid, total amount, currency, split type), one or more ExpenseShare rows (per-beneficiary allocated amount), a LedgerEntry per affected member per expense (the signed balance effect), and a Settlement (a repayment event). Balances are always derived from ledger entries, never stored as the primary fact.
- Why not just store a running balance field per member pair? — A stored running balance is a cache that can drift from reality (double-counted update, missed event, race condition) with no way to recompute or audit it — deriving balance from immutable ledger entries means it's always reconstructable and checkable, which a bare mutable counter is not.
Why track amounts in paise (integers) instead of rupees as a decimal or float?
Floating-point binary representations cannot exactly represent most decimal fractions, so repeated arithmetic on money as floats accumulates silent rounding error — unacceptable when the invariant is that shares must sum exactly to the total. Storing everything as an integer count of the smallest currency unit (paise for INR, cents for USD) makes all arithmetic exact integer arithmetic.
- Does this mean the UI also works in paise? — No — paise is the storage and computation representation; the presentation layer formats it back to rupees-and-paise (e.g. 10,000 paise displayed as ₹100.00) at render time, never doing money math on the formatted string.
What distinguishes an expense from a settlement in the data model?
An expense creates new debt — one payer fronted money that others owe a share of — while a settlement extinguishes existing debt via a direct repayment between two members. Both produce ledger entries, but an expense's entries are derived from a split calculation across potentially many beneficiaries, while a settlement's entries are a simple two-party, equal-and-opposite pair.
- Can a settlement ever have more than two parties? — Not as a single settlement record — a group settle-up that touches three people should be modeled as multiple two-party settlement records, keeping each one independently auditable and reversible.
Walk through Vector A: Asha pays ₹900 for dinner, split equally among Asha, Ben, and Cara.
Each member's equal share is ₹900 / 3 = ₹300 exactly, with no remainder. Asha paid ₹900 and owes ₹300 of it herself, so her net position is +₹600 (others owe her ₹600 total). Ben and Cara each contributed nothing but owe ₹300 each, so their net positions are −₹300 each. The group's net sums to zero: +600 − 300 − 300 = 0. The suggested repayments are Ben → Asha ₹300 and Cara → Asha ₹300.
- Why does the zero-sum property matter here specifically? — Because an equal three-way split of ₹900 divides evenly, Vector A doesn't actually exercise the rounding-remainder rule — that's Vector D's job — but the zero-sum check (net balances always sum to zero within one closed currency ledger) is the invariant that would catch a split-math bug immediately, even in this clean case.
Split Math and Validation
How do you validate an exact-amount split?
Every ExpenseShare amount must be a non-negative integer (in minor units), and the sum of all shares must equal the expense's total amount exactly — not approximately. Vector B's taxi expense (Ben pays ₹600, shares Asha ₹100 / Ben ₹200 / Cara ₹300) validates because 100 + 200 + 300 = 600 exactly; any exact-split submission that doesn't sum to the total should be rejected at the API boundary before it ever reaches the ledger.
- Should this validation happen client-side, server-side, or both? — Both, for different reasons — client-side for fast user feedback while entering amounts, but server-side validation is the only one that's authoritative, since a client can't be trusted to enforce a financial invariant.
How do you validate a percentage split, and what happens with Vector D's 33.33/33.33/33.34 case?
Percentages must sum to exactly 100.00% as stated, but the actual allocation is computed in minor units against the real total, and indivisible remainders need a documented, deterministic rule — never ad hoc rounding and never floating point. Vector D: ₹100.00 (10,000 paise) split 33.33/33.33/33.34 yields shares of 3,333 / 3,333 / 3,334 paise, which sums to exactly 10,000. The extra paisa is assigned to the member whose stated percentage was explicitly highest (33.34%), which is why the percentages themselves were chosen asymmetrically rather than three equal 33.33% shares that wouldn't sum to 100 on their own.
- What if all three percentages were stated as exactly 33.33% each (summing to 99.99%, not 100%)? — That input should be rejected at validation — stated percentages must sum to exactly 100.00%; a 99.99% total is a client input error to surface back to the user, not something the server silently patches by inventing an extra 0.01%.
- What's the general deterministic remainder rule when the leftover isn't pre-assigned by an asymmetric percentage? — A common, auditable rule is largest-remainder allocation: compute each share's exact fractional entitlement, truncate to whole minor units, then distribute the leftover minor units one each to the shares with the largest truncated fractional remainder, in a stable, documented tie-break order (e.g. by member ID) — never by floating-point rounding of the displayed percentage.
How does a shares/weights split work, and what does Vector E produce?
Each beneficiary is assigned an integer weight, and their share is their weight's fraction of the total weight applied to the total amount, using the same deterministic integer-remainder rule as percentage splits. Vector E: Ben pays ₹1,000, weighted Asha:Ben:Cara = 1:2:2 (total weight 5). Asha's share is 1/5 of ₹1,000 = ₹200, Ben's and Cara's are 2/5 each = ₹400 each, summing exactly to ₹1,000 with no remainder to distribute in this particular case.
- What's Vector E's resulting net balance for each member? — Ben paid ₹1,000 and owes ₹400 of it himself, so his net is +₹600. Asha owes ₹200 she didn't pay toward, net −₹200. Cara owes ₹400 she didn't pay toward, net −₹400. These sum to zero: 600 − 200 − 400 = 0.
Why must shares and weights be validated separately from amounts?
An exact split validates against the stated total amount directly; a percentage or shares split instead validates its own internal consistency (percentages summing to 100%, weights being positive integers) and then derives amounts from the total — conflating these two validation paths risks silently accepting a shares split whose weights don't actually produce a sane allocation, or rejecting a valid percentage split by applying amount-sum rules to percentage inputs.
- Can a single expense mix split types for different members? — Not within Splitwise's conventional product model — an expense has one split type applied consistently to all its beneficiaries; mixing would require a more general per-share allocation rule type, which is a larger scope decision to call out if an interviewer pushes on it.
Balances and Simplification
How do you derive a member's net balance from raw ledger entries?
Net balance for a member in a group/currency is the sum of all their signed ledger entries — positive entries from expenses they paid for others, negative entries from their own share of expenses others paid, adjusted by settlements they've sent (reduces what they're owed, i.e. moves their net down... concretely: paid-for-others minus own-share, plus settlements received, minus settlements sent). A straightforward SQL aggregation (SUM(amount) GROUP BY member_id WHERE group_id = ... AND currency = ...) over the ledger-entries table computes this directly, with the ledger as the single source of truth rather than any cached counter.
- Does this aggregation need to scan the entire expense history every time? — At small scale yes, trivially; at scale this is exactly why a materialized balance projection (updated incrementally as new ledger entries arrive) replaces a full re-aggregation on every read, discussed in the scaling section.
Walk through Vector B's combined net balance across both expenses.
From Vector A: Asha +₹600, Ben −₹300, Cara −₹300. Vector B adds the taxi expense (Ben pays ₹600, exact shares Asha ₹100/Ben ₹200/Cara ₹300), whose own net effect is Asha −₹100, Ben +₹400, Cara −₹300. Summing the two expenses' effects: Asha = 600 − 100 = +₹500, Ben = −300 + 400 = +₹100, Cara = −300 − 300 = −₹600. These sum to zero: 500 + 100 − 600 = 0.
- What are the four raw pairwise debts before simplification? — Ben→Asha ₹300 and Cara→Asha ₹300 from the dinner, plus Asha→Ben ₹100 and Cara→Ben ₹300 from the taxi — four separate pairwise obligations that the combined net balance (and simplification) collapses down to far fewer actual suggested transfers.
How does debt simplification turn Vector B's four raw debts into two suggested transfers?
Simplification operates on net balances, not on the raw pairwise history — once you know Asha is net +₹500, Ben is net +₹100, and Cara is net −₹600, a greedy algorithm repeatedly matches the largest debtor against the largest creditor: Cara (owes 600) pays the biggest creditor Asha (owed 500) the full ₹500, zeroing Asha out and leaving Cara owing ₹100; then Cara pays the remaining creditor Ben the remaining ₹100, zeroing both out. Result: Cara → Asha ₹500, Cara → Ben ₹100 — two transfers instead of the four original pairwise debts (Ben→Asha 300, Cara→Asha 300, Asha→Ben 100, Cara→Ben 300), with the same net effect on every member's balance.
- Is this simplified version provably the minimum number of transfers for every possible input? — No — greedy largest-debtor-vs-largest-creditor matching is not provably minimal in transaction count for every input; there are balance configurations where a smarter pairing (exploiting exact-cancellation subsets) achieves fewer transfers than greedy does. Greedy is simple, fast, and good enough for small-to-medium groups, but the claim of global minimality must not be made without proof.
- Does simplification change who is net positive or negative? — No — simplification only changes which specific transfers are suggested to settle the group; every member's net balance before and after simplification is identical by construction, since simplification is a suggestion derived from, not a replacement for, the net positions.
Is a simplified debt suggestion binding, or can a member ignore it?
It's always a suggestion, never a forced transfer — the system can display "Cara owes Asha ₹500 and Ben ₹100" as the simplest way to settle, but Cara is free to instead pay Ben first, or pay less than the full amount, or use the original unsimplified pairwise view if the product exposes one; the ledger and balance derivation don't change based on which suggestion a member follows.
- Does simplification need to be recomputed after every new expense? — Yes, conceptually — simplification is a function of the current net balances, so any new expense or settlement that changes a net balance should be reflected in the next simplification view; whether it's recomputed on every read or incrementally maintained is an implementation/scaling choice, not a correctness one.
Walk through Vector C: Cara repays ₹200 to Asha after Vector B's state.
Starting from Vector B's net (Asha +₹500, Ben +₹100, Cara −₹600), a ₹200 settlement from Cara to Asha reduces what Cara owes and reduces what Asha is owed by the same amount: Asha's net drops to +₹300, Cara's net rises (becomes less negative) to −₹400, Ben is unaffected at +₹100. These still sum to zero: 300 + 100 − 400 = 0. The remaining suggested repayments are Cara → Asha ₹300 and Cara → Ben ₹100.
- Does a settlement produce ledger entries the same way an expense does? — Yes — a settlement of ₹200 from Cara to Asha produces a pair of signed ledger entries (Cara −₹200 effect on what she's owed/owes, Asha +₹200 reciprocal effect) that are summed into net balance exactly like expense-derived entries, keeping the ledger the single source of truth for both event types.
Idempotency and Concurrency
How do you design the create-expense API to be idempotent, per Vector F?
The client generates a scoped idempotency key (e.g. a UUID tied to this specific creation attempt) and sends it with the create-expense request; the server stores that key, atomically, in the same transaction that inserts the expense, its shares, and its ledger entries. If a retry arrives with the same key, the server returns the already-committed result without re-inserting anything — exactly one committed expense, one ledger effect, one activity-feed entry, never zero and never two. A retry with the same key but a materially different payload (e.g. a different amount) returns a documented conflict response rather than silently applying the new payload.
- Where does the idempotency key get checked — before or inside the database transaction? — Inside, as part of the same atomic transaction that writes the expense — checking it outside the transaction (read-then-write) leaves a race window where two concurrent retries could both pass the check before either commits, producing exactly the duplicate the mechanism was meant to prevent.
What's the actual database-level mechanism that enforces idempotency?
A unique constraint on the idempotency key column (scoped to, e.g., the group and the requesting member) at the database level — the insert attempt for a duplicate key fails with a constraint violation, which the application layer catches and translates into "return the existing result" rather than relying purely on an application-level check-then-insert that's vulnerable to a race.
- What should the API return on a genuine conflict (same key, different payload)? — A distinct, documented conflict status (e.g. 409) with enough detail for the client to understand its retry doesn't match what was already committed — silently accepting the new payload under the old key would let a client's bug (or bad retry logic) overwrite a correct, already-settled expense.
Walk through Vector G: two concurrent edits to the same expense at version 4.
The expense row carries a version number; both concurrent edit requests read the expense at version 4 and submit their update conditioned on "only apply if current version is still 4." The database enforces this as an atomic compare-and-set (e.g. an UPDATE ... WHERE id = ? AND version = 4 that also increments version to 5). Exactly one of the two requests' WHERE clause matches and succeeds, taking the row to version 5; the other's UPDATE affects zero rows, and the server returns an explicit version-conflict response to that second caller rather than silently merging or silently discarding either edit.
- What's the losing client's documented retry path? — Re-fetch the expense at its new current version (5), inspect what the winning edit changed, and decide whether to reapply its own intended change on top of the new state or abandon it — never blindly retry the exact same version-4-conditioned request, since that will fail identically every time.
Why is optimistic concurrency (version checks) preferred here over row-level locking?
Expense edits are infrequent relative to reads, and holding a pessimistic lock across a user-facing edit flow (open the edit form, change a field, submit) would hold that lock for an unpredictable, potentially long duration while a human is involved — optimistic concurrency only pays a cost when an actual conflict occurs, which is rare, and keeps the common uncontended case fast and lock-free.
- Is there a scenario where pessimistic locking would be the better choice? — If edits were extremely frequent and contention on the same expense were common (unlikely for this domain), the retry storm from optimistic conflicts could itself become a bigger cost than briefly serializing access with a lock — worth naming as the trade-off's break-even condition rather than treating optimistic concurrency as universally correct.
Event Sourcing, Outbox, and Projections
Should balances be computed live from ledger entries, or maintained as an event-sourced/materialized projection?
At small scale, aggregating ledger entries on every balance read is simple and correct. At real scale, that aggregation becomes expensive to repeat on every page view, so a materialized balance projection — updated incrementally as each new ledger entry commits — serves reads cheaply. The ledger itself remains the append-only source of truth either way; the projection is a derived, rebuildable cache of it, not a replacement for it.
- What happens if the projection and the ledger ever disagree? — The ledger wins, always — a disagreement means the projection has a bug or has fallen behind, and the fix is replaying the ledger to rebuild the projection, never patching the ledger to match whatever the projection currently shows.
What's a transactional outbox, and why would this system use one?
When creating an expense needs to both commit the ledger entries and reliably notify a downstream consumer (a balance-projection updater, a push-notification service) without risking a split where the ledger commits but the notification is silently lost, a transactional outbox writes the ledger change and an outbox event row in the same database transaction. A separate relay process then reads unpublished outbox rows and publishes them to the message bus, retrying until acknowledged — guaranteeing the notification is eventually sent if and only if the ledger write actually committed.
- Why not just publish to the message bus directly inside the request handler? — A direct publish that happens after the database commit risks the process crashing between the two steps, silently losing the downstream notification even though the ledger entry is correctly committed — the outbox pattern removes that window by making the event durable in the same transaction as the fact it describes.
How does the projection updater avoid double-applying the same event if the message bus delivers it twice?
The same idempotency discipline as expense creation itself — each outbox event carries a unique, durable event ID, and the projection updater checks (and records) that ID before applying the event's effect, making redelivery a safe no-op rather than a double-counted balance update.
- Does this mean the message bus needs to guarantee exactly-once delivery? — No — at-least-once delivery from the bus is sufficient as long as the consumer-side idempotency check is correct; building a duplicate-consumer-safe handler is strictly easier and more robust than trying to guarantee exactly-once delivery at the transport layer.
How would you recover a balance projection after it processes events out of order?
Each ledger entry and each projection-applied event should carry a strictly increasing sequence number per affected aggregate (e.g. per group); the projection updater tracks the last-applied sequence number and rejects or buffers an event that arrives out of order relative to it, rather than applying it and silently corrupting the running balance. If out-of-order application is detected after the fact, the clean fix is rebuilding the projection from the ledger from scratch (or from the last known-good checkpoint), not attempting to patch the drifted value in place.
- Is a full rebuild always necessary, or can you resync incrementally? — A full rebuild is the simplest correct answer and a reasonable default for a moderately sized group's history; at larger scale, a snapshot-plus-replay-from-checkpoint approach (replay only the events after the last verified-good snapshot) avoids reprocessing the entire history every time.
How do you guarantee read-your-writes for the member who just created an expense?
The simplest reliable guarantee is routing that member's immediate post-write balance read to the same source that just accepted the write (the primary, or a replica that's confirmed caught up to that write's sequence number) rather than to an arbitrary, possibly-lagging replica — other group members' views can tolerate a small, explicitly bounded staleness window, but the acting member's own immediate view should not show a stale pre-write balance.
- Does this requirement conflict with using read replicas for scale? — Not if the write path returns (or the client can request) a version/sequence token the client then uses to ensure subsequent reads are served from a replica caught up to at least that point — a form of session-level read-your-writes consistency layered on top of generally-asynchronous replica reads.
Where would a cache sit in this system, and what's the risk?
A cache in front of frequently-viewed group balances (e.g. the group summary screen) reduces load on the projection store, but it introduces an explicit staleness window that must be bounded and ideally invalidated proactively on new ledger writes for that group — the risk being a member viewing a cached balance that doesn't yet reflect an expense they or someone else just added, which is acceptable only if it's a known, short, stated window rather than an unbounded or silent one.
- Should the cache ever be the source consulted before a settlement decision? — No — a user deciding how much to actually pay someone should see the freshest available balance, not a cached one; a settlement-flow read should bypass or force-refresh the cache even if the general balance view tolerates a short cache TTL elsewhere.
Sharding, Currency, and Settlement Semantics
How would you shard this system's data at large scale?
Per-group sharding is the natural fit, since almost every read and write (expenses, balances, simplification) is scoped to one group at a time, and groups are naturally independent of each other — a group's entire ledger, membership, and projection can live together on one shard, keyed by group ID, avoiding cross-shard transactions for the overwhelming majority of operations.
- What operation doesn't fit cleanly into per-group sharding? — A member's cross-group activity feed or a global "total you owe across all your groups" summary requires fanning out across every shard that holds a group the member belongs to and aggregating client-side or in a dedicated aggregation service — a deliberate, named exception to the otherwise-clean per-group sharding model.
Vector H: a member has both a ₹1,000 INR balance and a $20 USD balance in the same group. How are these combined?
They are never combined — INR and USD are separate currency positions with separate ledgers, even within the same group, and the system must never net them together into one number without an explicit conversion event and a stated exchange-rate policy. A UI might choose to display both side by side, but internally and in any balance computation they remain two independent zero-sum ledgers.
- If the product eventually wants a single-currency summary view, how would that work correctly? — Via an explicit, timestamped conversion using a stated, auditable exchange rate source applied only for display purposes — the underlying ledgers stay in their original currencies untouched, and the converted summary is clearly presented as a converted estimate, not as if it were a native combined balance.
Why is cross-currency netting a genuinely hard problem rather than just a missing feature?
Exchange rates move continuously, so any netted combined balance is only ever accurate as of the rate snapshot used to compute it, and a later settlement in one currency against a balance derived partly from a converted other currency creates ambiguity about which rate should apply to the actual repayment — this is a real product and correctness question, not an oversight, and the safest default is keeping currencies strictly separate until there's an explicit product decision and a documented rate policy.
- Should a group even be allowed to mix currencies by default? — That's a legitimate product-level question to raise rather than assume — some products disallow mixed-currency groups entirely to sidestep this, while others allow it and explicitly surface per-currency balances; naming this as a decision point is itself a strong interview signal.
Vector J: how do you model a payment that's pending versus confirmed?
A settlement record has an explicit state machine: a pending state (initiated, provider processing) and a confirmed state (provider's authoritative confirmation event received), and only the confirmed state is allowed to reduce the visible, authoritative balance. A pending settlement must not reduce confirmed balances — it can be shown as "pending" in the UI so the user isn't confused, but the ledger's authoritative net balance stays unchanged until confirmation arrives. A failed payment must never be treated as settled; it should transition to a failed state that also does not affect the balance.
- What's the concrete risk of letting a pending payment reduce the balance immediately? — If the payment subsequently fails (insufficient funds, provider timeout, reversal), the balance would have shown a false settled state in the interim — a member could reasonably believe a debt is cleared when it isn't, which is exactly the kind of silent financial-correctness bug this design must prevent by keeping pending and confirmed as genuinely different states with different effects.
- Who or what is authoritative for the confirmation event? — The payment provider's callback/webhook is the authoritative confirmation source when real payments are in scope — a user simply clicking "mark as paid" in the UI is a self-asserted settlement, which the design should treat as a distinct, lower-trust event type from a provider-confirmed one, unless the product explicitly allows self-assertion as sufficient.
What happens if a payment provider's confirmation callback is lost or never arrives?
The settlement should not be stuck in pending forever on faith alone — a reconciliation job should periodically query the provider's status API for any settlement that's been pending longer than an expected window, resolving it to confirmed or failed based on the provider's actual record, rather than relying solely on a webhook that might never arrive.
- Is there a risk of double-confirming a settlement from both the webhook and the reconciliation job? — Only if confirmation isn't itself idempotent — the same "mark this settlement confirmed" operation triggered by either path should be a safe no-op if the settlement is already confirmed, exactly mirroring the idempotency discipline used for expense creation.
Audit, Privacy, and Operations
Vector I: what happens when the ₹900 dinner expense from Vector A is deleted or reversed?
The correction is modeled as a reversal event, not an in-place deletion — the original expense, its original ledger entries, and a new reversal event (that posts the exact opposite ledger effect) all remain permanently in the audit trail. The net balance effect of that expense is now zero (its original +600/−300/−300 effect is exactly cancelled by the reversal's −600/+300/+300), but nothing about the original expense or the fact that it was reversed is ever erased from history.
- Why not just delete the row from the database? — A hard delete destroys the audit trail and makes it impossible to later answer "why did the balance look different last Tuesday" or to investigate a dispute — versioned reversal-plus-replacement preserves full history while still achieving the correct current-state balance effect.
How would you design authorization so one group's members can't see or affect another group's expenses?
Object-level authorization scoped to group membership, checked on every read and write — not just authentication that the caller is a valid logged-in user. Every expense, settlement, and balance query must verify the requesting user is actually a member of the group that resource belongs to, since authentication alone ("you're logged in") says nothing about whether you belong to this particular group.
- What's a concrete exploit if only authentication were checked? — Any logged-in user could query or manipulate any group's expenses by guessing or enumerating group/expense IDs, since there'd be no per-request check that they actually belong to that group — a classic broken-object-level-authorization (BOLA) vulnerability.
How would you support offline expense creation and later sync?
A client creates the expense locally with a client-generated idempotency key and a locally-assigned temporary ID, queues it, and submits it once connectivity returns through the same idempotent create-expense path already designed for Vector F — the server-side idempotency key handling means a sync retry after a dropped connection is still safe. If the same expense was independently edited by someone else while the client was offline, the sync submission is just another concurrent-edit case, handled the same way as Vector G.
- What should the offline client show the user before sync completes? — A clearly marked pending-sync state on that expense and in any balance view that includes it, so the user isn't misled into thinking it's already reflected in the group's authoritative, synced balance.
How would you debug a reported zero-sum invariant violation (a group's net balances don't sum to zero)?
Since every legitimate expense and settlement is individually zero-sum by construction, a true violation means either a partially-applied write (some ledger entries for one event committed, others didn't — a transaction atomicity bug), a bug in a specific split type's rounding logic that silently dropped or added a minor unit, or a projection that's drifted from the ledger. The investigation path: recompute the balance directly from raw ledger entries (bypassing any projection) to confirm whether the ledger itself is unbalanced or just the projection is stale; if the ledger itself is unbalanced, find the specific expense or settlement whose own entries don't net to zero.
- What's the actual repair, once the offending event is found? — Never edit the broken ledger entries in place — post an explicit, audited correcting entry (or a full reversal-and-replacement, per Vector I's pattern) that brings the net back to zero, with the correction itself fully traceable to the investigation that found it.
How would you migrate the balance-computation logic from a live-aggregation query to a materialized projection without a correctness regression?
Build the new projection from the existing ledger as a backfill, then run both the old live-aggregation path and the new projection in parallel for a verification window, comparing their outputs for every group and flagging any discrepancy before cutting reads over to the projection — never a hard cutover based on "it looks right" in a quick spot check, given how much financial correctness is riding on it.
- What's the highest-risk case to specifically verify during that parallel-run window? — Groups that exercise all four split types together and have had at least one correction/reversal — a migration that handles simple equal-split, never-edited groups correctly but subtly mishandles a percentage-split group with a reversal in its history is exactly the kind of regression a casual spot check misses.
Would you send a notification every time a group's balance changes, and how would you avoid notification spam?
Notifications should be driven off the same outbox/event pipeline as projection updates, but coalesced and filtered per-member rather than fired on every single ledger entry — e.g. a batch of near-simultaneous expense edits shouldn't trigger five separate pushes to every group member; a short coalescing window and relevance filtering (only notify members whose own balance actually changed) keep this from becoming noise.
- Should a settlement confirmation always notify immediately, bypassing coalescing? — That's a reasonable product-level exception to name — a settlement moving from pending to confirmed is often higher-signal and lower-frequency than routine expense edits, so treating it as an immediate, uncoalesced notification is a defensible, explicit choice rather than an inconsistency.
Staff-level: given unlimited budget, would you still keep the immutable-ledger-plus-derived-projection architecture?
Yes — this is a correctness-and-auditability-driven choice, not a cost-driven one. Unlimited budget buys more replicas, lower projection lag, and more generous caching, but it doesn't remove the need for an authoritative, replayable, append-only record that corrections, audits, disputes, and recovery all depend on; a mutable "just update the balance field" design would still be the wrong choice even with infinite compute, because the risk it carries is about correctness and trust, not throughput.
- What would unlimited budget actually change, then? — Lower read latency via more aggressive per-group caching and replication, faster projection catch-up after a rebuild, and more generous reconciliation-job frequency against payment providers — all performance and operational levers, not a different correctness model.
Staff-level: how would you reason about when a single-node relational store for the ledger stops being sufficient?
Watch for the actual measured signal — sustained elevated write latency on expense creation, or storage/throughput pressure on the node holding the ledger — rather than picking an arbitrary group-count or expense-count threshold in advance; because the domain is naturally shardable per group, the practical response is moving to per-group sharding (as discussed above) once that signal appears, with the same zero-regression verification discipline used for the projection migration.
- Is there a simpler lever to try before sharding? — Read replicas for the heavy read traffic (balance views, activity feeds) can absorb significant load before the ledger's write path itself becomes the bottleneck, since in this domain reads meaningfully outnumber writes — worth naming as the cheaper first step before committing to a sharding migration.
Whiteboard and Pseudocode Exercises
These are hands-on exercises an interviewer might actually ask you to do at the whiteboard, not just talk about. Practice drawing and narrating each one in under 3-4 minutes.
1. Draw the ER model and name the critical indexes
Draw Group, Member (with a GroupMember join for membership), Expense (group_id, payer_id, total_amount, currency, split_type, version, idempotency_key), ExpenseShare (expense_id, member_id, share_amount), LedgerEntry (group_id, member_id, currency, amount, signed, source_event_id), and Settlement (group_id, from_member_id, to_member_id, amount, currency, state). Name the critical indexes out loud: a unique index on (group_id, idempotency_key) for Vector F's duplicate-creation guard; an index on (group_id, member_id, currency) on LedgerEntry for fast balance aggregation; and a unique index enforcing one active version per expense row for Vector G's optimistic-concurrency check.
2. Calculate Vector A, B, and C entirely by hand
Vector A: ₹900 equal split three ways is ₹300 each; Asha net +₹600, Ben and Cara net −₹300 each. Vector B: add the ₹600 taxi (exact Asha ₹100/Ben ₹200/Cara ₹300, net effect Asha −₹100/Ben +₹400/Cara −₹300) to get combined net Asha +₹500, Ben +₹100, Cara −₹600 — and show the four raw pairwise debts collapsing to two suggested transfers (Cara→Asha ₹500, Cara→Ben ₹100). Vector C: Cara repays ₹200 to Asha, moving the net to Asha +₹300, Ben +₹100, Cara −₹400, with remaining suggested repayments Cara→Asha ₹300 and Cara→Ben ₹100. Verify every intermediate state sums to zero.
3. Write split-allocation pseudocode with deterministic rounding
function allocatePercentageSplit(totalMinorUnits, percentages, memberIds):
assert sum(percentages) == 10000 // 100.00% expressed as basis points, integer
rawShares = []
for i in range(len(memberIds)):
exact = totalMinorUnits * percentages[i] / 10000.0
rawShares[i] = { memberId: memberIds[i], floor: floor(exact), remainder: exact - floor(exact) }
allocated = sum(rawShares[i].floor for i in rawShares)
leftover = totalMinorUnits - allocated // e.g. 1 paisa left over
// Deterministic largest-remainder-first assignment, stable tie-break by memberId
sortedByRemainder = sortDescending(rawShares, key = (remainder, -memberId))
for i in range(leftover):
sortedByRemainder[i].floor += 1
shares = { s.memberId: s.floor for s in sortedByRemainder }
assert sum(shares.values()) == totalMinorUnits // exact, no drift
return shares
// Vector D check: totalMinorUnits=10000, percentages=[3333,3333,3334]
// -> floors [3333,3333,3334], allocated=10000, leftover=0 -> shares exactly [3333,3333,3334]4. Write the greedy debt-simplification algorithm and explain its non-optimality
function simplifyDebts(netBalances): // map of memberId -> signed net amount
creditors = maxHeap(members where net > 0, key = net)
debtors = maxHeap(members where net < 0, key = abs(net))
transfers = []
while creditors is not empty and debtors is not empty:
creditor = creditors.peekMax()
debtor = debtors.peekMax()
amount = min(creditor.amount, debtor.amount)
transfers.append({ from: debtor.id, to: creditor.id, amount: amount })
creditor.amount -= amount
debtor.amount -= amount
if creditor.amount == 0: creditors.popMax()
if debtor.amount == 0: debtors.popMax()
return transfers
// Vector B: creditors=[Asha:500, Ben:100], debtors=[Cara:600]
// -> Cara pays Asha 500 (Asha zeroed), Cara pays Ben 100 (both zeroed) -> 2 transfers5. Design an idempotent create-expense API and its database transaction
POST /groups/{groupId}/expenses
Headers: Idempotency-Key: <client-generated UUID>
Body: { payerId, totalAmount, currency, splitType, shares: [...] }
BEGIN TRANSACTION
IF EXISTS (idempotency_key, group_id) in expense_idempotency_log:
stored = fetch matching committed expense
IF stored.payloadHash == hash(request.body):
ROLLBACK; RETURN 200 with stored result // Test F: safe replay, one committed expense
ELSE:
ROLLBACK; RETURN 409 Conflict // same key, different payload: never silently applied
validate split (sums to totalAmount, deterministic rounding rule applied)
INSERT expense (version = 1)
INSERT expense_shares (derived allocation)
INSERT ledger_entries (signed, zero-sum within this expense)
INSERT expense_idempotency_log (idempotency_key, group_id, payloadHash, expenseId)
INSERT outbox_event (expense.created, expenseId)
COMMIT
RETURN 201 with the committed expense6. Show the edit-version-conflict timeline (Vector G)
Draw two parallel timelines: Client X reads the expense at version 4, Client Y also reads it at version 4. Client X submits UPDATE expenses SET ..., version = 5 WHERE id = ? AND version = 4 first and it matches one row — success, now version 5. Client Y's identical-shaped UPDATE ... WHERE version = 4 now matches zero rows, since the row is already version 5 — the server returns an explicit version-conflict response to Client Y rather than silently applying Y's edit on top of X's, and rather than silently dropping Y's edit with no signal at all.
7. Recover a projection after duplicate/out-of-order events
Narrate: the projection's last-applied sequence number for a group is tracked explicitly; a rebuild loads the last known-good checkpoint, then replays every ledger entry after it in strict sequence order through the same idempotent, per-event-ID-checked logic used for normal incremental updates — never a different, ad hoc rebuild code path that could diverge from live-update behavior. After replay, verify the rebuilt projection's balances sum to zero per currency before swapping it in to serve live reads.
8. Explain precisely why INR and USD must remain separate
Walk through Vector H concretely: a member's ₹1,000 INR position and $20 USD position are two separate zero-sum ledgers, each independently satisfying "net balances across all members in this currency sum to zero." Combining them into one number requires picking a specific exchange rate at a specific instant, and that choice is arbitrary relative to whichever rate actually applies when a real repayment happens later — so the two currencies are never netted together without an explicit, timestamped conversion event and a stated rate-source policy, kept entirely separate from the underlying ledgers themselves.
9. Debug a zero-sum invariant violation step by step
Given a group whose ledger entries sum to a non-zero value for INR, walk the investigation: first bypass any projection and recompute directly from raw ledger_entries to localize whether the ledger itself or just a cache is wrong; then scan for any single expense or settlement whose own set of ledger entries doesn't independently net to zero (the signature of a partially-committed transaction or a split-math bug); once found, post an explicit, audited correcting or reversing entry — never edit the broken rows in place — and re-verify the group's total nets to zero afterward.
10. Draw the settlement state machine (Vector J)
Draw the states: initiated → pending (provider processing; visible balance still reflects the pre-settlement debt) → confirmed (provider's authoritative callback received; visible balance now reduced) as the happy path, with a separate pending → failed transition (provider declines/times out; visible balance remains unchanged, exactly as if the settlement had never been attempted) and a reconciliation-job edge that resolves a stuck pending state by querying the provider directly after an expected window elapses.
Strong vs Weak Answers
The difference between a passing and failing answer at this level is rarely raw knowledge — it's whether the reasoning is anchored to explicit ledger invariants and precise money arithmetic, or floats on assumptions presented as facts.
| Recurring trap | Weak framing | Strong framing |
|---|---|---|
| Core data model | "Just store who owes whom" as a single mutable balance table, no ledger. | Models an immutable ledger of signed entries as the source of truth, with balances always derived and rebuildable, and every correction as a new auditable entry. |
| Money representation | Stores amounts as floating-point decimals (e.g. 33.33) and computes directly on them. | Stores amounts as integer minor units (paise) and applies a documented, deterministic largest-remainder rounding rule, proven to sum exactly to the total every time. |
| Concurrent edits | Last write silently wins — whichever update commits last overwrites the other with no signal. | Uses optimistic version checks (Vector G); exactly one concurrent edit wins per version, the other receives an explicit conflict with a defined re-fetch-and-retry path. |
| Delivery guarantees | Claims the event/notification pipeline is exactly-once end to end. | States at-least-once delivery honestly and shows the actual mechanism (idempotency keys, durable event IDs checked before mutation) that makes retries and duplicates safe regardless. |
| Settlement truth | Treats a pending payment as settled the moment it's initiated. | Keeps pending and confirmed as genuinely different states (Vector J); only a provider-confirmed event reduces the authoritative, visible balance, and a failed payment never counts as settled. |
| Staleness honesty | Claims every group member always sees a perfectly real-time, instantly consistent balance. | States an explicit, bounded staleness contract for non-acting members' views, while guaranteeing read-your-writes for the member who just transacted. |
| Simplification claims | Presents the greedy simplified-debt result as the provably minimum number of transfers. | Names greedy's actual behavior correctly and is explicit that it is not provably minimal in transaction count for every input — a suggestion, not a mathematically optimal guarantee. |
| Multi-currency | Nets a ₹1,000 balance and a $20 balance into one combined number without naming a rate or a policy. | Keeps every currency as a strictly separate zero-sum ledger, converting only for an explicitly labeled display estimate under a stated rate policy. |
Quick Revision
A condensed pass for the night before (or the hour before) the interview — the architecture shape, the invariants, the key numbers, and a 30-second and 2-minute answer you can actually deliver out loud.
The 30-second answer
A shared-expense manager records expenses and settlements as immutable, signed ledger entries in integer minor units, derives every member's net balance by summing those entries, and offers a greedy — not provably optimal — debt-simplification suggestion on top. Expense creation is idempotent on a scoped key, edits are optimistically versioned so concurrent conflicts surface explicitly instead of silently overwriting, corrections are reversal-plus-replacement events rather than in-place mutation, currencies are never netted together implicitly, and a settlement only reduces the authoritative balance once it's provider-confirmed, not merely pending.
The 2-minute answer
I'd design this around Weekend Trip, a group of three — Asha, Ben, Cara — settling in INR, with amounts stored as integer paise, never floats. An expense (like Asha's ₹900 dinner, split equally) produces one ExpenseShare per beneficiary and a set of signed LedgerEntry rows that always sum to zero for that expense; Asha nets +₹600, Ben and Cara net −₹300 each. Adding Ben's ₹600 taxi with an exact 100/200/300 split combines to a net of Asha +₹500, Ben +₹100, Cara −₹600 — four raw pairwise debts that a greedy simplification algorithm reduces to two suggested transfers, Cara→Asha ₹500 and Cara→Ben ₹100, while being explicit that greedy isn't provably minimal for every input. Percentage and shares splits (33.33/33.33/33.34, or 1:2:2 weights) use a deterministic largest-remainder rounding rule so shares always sum exactly to the total, never floating-point approximations. Expense creation is idempotent on a scoped key stored inside the same transaction as the ledger writes, so a network retry produces exactly one committed expense, never zero or two. Edits carry an optimistic version number — two concurrent edits to version 4 can't both silently win; exactly one advances to version 5, the other gets an explicit conflict and a defined re-fetch-and-retry path. Corrections and deletions are reversal events, not in-place mutation, so the full audit trail — including a reversed ₹900 dinner expense — stays permanently visible even though its net balance effect becomes zero. INR and USD balances are strictly separate ledgers, never implicitly netted. And a settlement is only authoritative once provider-confirmed; a merely pending or a failed payment never reduces the visible, confirmed balance. At scale, balances move from live aggregation to a materialized, per-group-sharded projection kept consistent via a transactional outbox, with the ledger always remaining the one source of truth it's rebuilt from.
Architecture-flow recap: a client submits an expense or settlement with an idempotency key → the API validates the split (deterministic rounding, shares sum exactly to total) → the ledger entries and an outbox event commit atomically in one transaction → a relay publishes the outbox event → an idempotent projector updates the per-group materialized balance view → reads (balances, simplified debts, activity) are served from that projection, with the acting member's own read routed to guarantee read-your-writes → a reconciliation job independently verifies any pending settlement against the payment provider's authoritative status.
- Amounts are always integer minor units (paise); money arithmetic never uses floating point.
- Every split's shares must sum exactly to the total via a documented, deterministic remainder rule — never ad hoc rounding (Vector D: 3,333/3,333/3,334 paise summing exactly to 10,000).
- Net balance = paid-for-others minus own-share, adjusted by settlements — always derived from immutable ledger entries, never stored as the primary fact.
- Every closed currency ledger is zero-sum at all times; a non-zero sum signals a bug to investigate, never a state to patch by editing a balance directly.
- Debt simplification (Vector B: four raw debts to two suggested transfers) preserves net positions, not provenance, and is a suggestion a member can ignore — greedy matching is not provably minimal in transfer count for every input.
- Idempotent writes: the same scoped idempotency key always produces exactly one committed financial effect, never zero and never two (Vector F); a conflicting payload under the same key is a documented conflict, never a silent overwrite.
- Concurrent edits are optimistically versioned: exactly one of two simultaneous edits to the same version wins, the other gets an explicit conflict and a defined retry path (Vector G).
- Corrections and deletions are reversal-plus-replacement events; the original record, its ledger effects, and the reversal all stay permanently visible (Vector I).
- Currencies are never implicitly netted — INR and USD (or any pair) remain strictly separate ledgers absent an explicit, rate-policy-backed conversion event (Vector H).
- A settlement only reduces the authoritative balance once confirmed; pending and failed are distinct states that never count as settled (Vector J).
| Trade-off | Choice made here | Alternative |
|---|---|---|
| Balance storage | Derived from an immutable ledger, materialized as a rebuildable projection | A single mutable balance field updated in place per transaction |
| Debt presentation | Greedy-simplified suggested transfers, explicitly non-minimal-guaranteed | Always show every raw pairwise debt with no simplification |
| Concurrency control | Optimistic versioning with explicit conflict responses | Pessimistic row locking held across the edit flow |
| Multi-currency | Strictly separate per-currency ledgers, converted only for display | Implicit single-currency netting using a live exchange rate |
| Settlement truth | Provider-confirmed state is authoritative; pending doesn't affect balance | Treat a self-asserted or pending payment as immediately settled |
| Failure scenario | Key response |
|---|---|
| A create-expense request is retried after a timeout | The scoped idempotency key makes the retry a safe no-op returning the already-committed expense — never a duplicate (Vector F). |
| Two members edit the same expense at the same time | Optimistic version check lets exactly one win; the other gets an explicit conflict and re-fetches before retrying (Vector G). |
| A balance projection drifts or processes an event out of order | Replay the authoritative ledger from the last verified checkpoint through the same idempotent logic to rebuild it, verifying zero-sum before cutover. |
| A payment provider's confirmation callback never arrives | A reconciliation job queries the provider's status directly after an expected window, resolving the stuck pending settlement idempotently. |
| A group's net balances don't sum to zero | Recompute directly from raw ledger entries to localize the broken expense/settlement, then post an explicit correcting or reversing entry — never edit rows in place. |
| Term | What it means here |
|---|---|
| Ledger entry | An immutable, signed record of a balance effect on one member, in one currency, attributable to exactly one expense or settlement event. |
| Net balance | A member's paid-for-others minus their own-share, adjusted by settlements — always derived, never the primary stored fact. |
| Debt simplification | A suggested, non-binding reduction of many pairwise debts into fewer transfers that preserve the same net positions. |
| Idempotency key | A client-scoped token that guarantees a retried write produces exactly one financial effect, not zero or multiple. |
| Optimistic concurrency | A version-number-based conflict check that lets concurrent edits race safely, surfacing an explicit conflict instead of silently overwriting. |
| Transactional outbox | A pattern that commits a domain write and its downstream event in one transaction, so the event is never silently lost after a successful commit. |
| Materialized projection | A derived, rebuildable read-optimized view (like per-group balances) kept in sync with the authoritative ledger, never itself the source of truth. |
| Zero-sum invariant | The rule that every closed currency ledger's net balances across all members always sum to exactly zero. |
| Settlement state machine | The pending/confirmed/failed lifecycle of a repayment, where only confirmation is authoritative enough to change the visible balance. |
| Reversal event | An auditable, opposite-effect correction posted alongside an original event, rather than an in-place edit or deletion of history. |