LLD-007 · DESIGN PRACTICE
Splitwise / Expense Sharing
Preserve exact totals, immutable history and retry identity while computing shared obligations.
Local Java 21 reference verified: 320 checks. Learner drafts are not executed. Author self-review; independent review pending.
Existing introductory lesson · Version 1
Understand the problem
Design an expense-sharing group that records expenses, exact allocations, weighted/equal splits, settlements and reversals. Explain why this is an obligation ledger rather than a real payment system, and how deterministic rounding and append-only corrections protect auditability.
Before you start
- Integer money and rounding
- Value equality and immutable records
- Atomic compound updates
- Ledger/projection distinction
Scope and explicit decisions
- One in-memory group, one implicit currency and registered member IDs. It records obligations only; it does not execute bank transfers, exchange currencies, authenticate members or persist data.
- Positive balance means the member should receive money; negative means they owe money. Every entry delta sums to zero. Expenses may allocate zero shares but must sum exactly to the positive total.
- split accepts positive integer weights. Equal split uses weight 1; percentage-like ratios use integer weights such as 50:30:20. No floating-point percentages or fractional currency units are stored.
- Weighted shares are floored exactly with BigInteger intermediates. Remaining minor units go in lexicographic member-ID order. This is a deterministic rule, not a claim that it is the only fair allocation.
- A settlement must go from a currently negative member to a currently positive member and cannot exceed either net obligation. It is a global-netting model, not a stored pairwise debt graph.
- Corrections append inverse entries, including when a prior settlement already occurred. Reversing an expense does not erase its settlement; the remaining balances may reverse who owes whom.
- A command key is scoped to this ledger. Typed immutable fingerprints compare the actual operation arguments; reused keys with different requests fail. Matching repeats return the original Entry.
- Every updated long balance is checked before publishing state. Overflow fails without partial ledger mutation. History and dedupe records grow for the process lifetime; durable audit storage is a Staff extension.
Worked examples
Input: A pays 100 shared equally among A, B and C.
Outcome: Shares A34/B33/C33; net A+66/B-33/C-33.
The payer’s own consumption offsets part of the amount they paid.
Input: B settles 33 to A, then the original expense is reversed.
Outcome: After reversal A-33/B+33/C0; original expense, settlement and inverse all remain.
A correction changes obligations; it cannot erase a completed external exchange.
Your design attempt
Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.
Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.
Run program · Java 21
No unsaved changes · 208/20000
Self-review
Checks apply to this visit. Self-review is not a passed submission or verified mastery.
Try before revealing
Hint 1
Write the exact sum of shares and the sign convention first.
Hint 2
How does a payer’s own share change their credit?
Hint 3
Keep every committed fact; use a new inverse entry for correction.
Requirements and invariants
R1 — Weighted allocations conserve the original integer amount. split protects I1.
R2 — Each atomic expense or settlement preserves zero total obligations. expense protects I2.
R3 — Corrections preserve history through one inverse entry. reverse protects I3.
R4 — A retry cannot post the same logical expense twice. expense protects I3.
I1: Every accepted share is nonnegative and the exact sum equals the expense amount.
I2: Each entry delta and all net balances sum to zero; balances equal ordered replay of retained entries.
I3: An operation key resolves one fingerprint/result, and an original entry has at most one accepted reversal.
Derive the design
Calculate balances from history before maintaining a projection
The simplest auditable model stores exact expense/settlement entries and recomputes balances by scanning history. It is correct but each query grows with history. The reference retains append-only entries plus a materialized balance map for quick reads, with rebuild as a verification/recovery calculation. Correctness depends on committing the entry and updated projection together.
Alternative: Pairwise debts preserve who originally owes whom but complicate global netting and simplification. This chapter explicitly chooses group net obligations. Suggested settlement edges are a separate view; a greedy matching algorithm must not be advertised as globally minimizing transaction count without proof.
Define balance sign and settlement eligibility
Without a sign convention, tests can accidentally “prove” the reverse result. State that positive receives and negative owes, and define whether global netting or pairwise debt is the business model.
Separate split calculation from posting
A pure split function can be checked against conservation independently of ledger mutation. Exact shares are then validated again at the command boundary.
Prepare all checked arithmetic before publishing
Compute updated balances into a temporary map. An arithmetic error must not leave a new Entry without its matching projection or command result.
Treat corrections as new facts
Original history explains what happened. Reversal adds another fact; it does not pretend related later settlements disappeared.
Responsibilities and ownership
split
Owns: Pure weighted allocation and remainder rule
Methods: split(amount, weights)
Returns immutable exact shares without posting ledger state.
Ledger
Owns: Members, entries, balances, command identities and reversal index
Methods: expense, settle, reverse, rebuild
One monitor validates and commits a balanced posting.
Entry
Owns: Stable ID, immutable member deltas and optional original reference
Methods: Record accessors
Corrections never mutate this prior entry.
Typed fingerprints
Owns: Normalized operation arguments
Methods: Record equality
Structured identity avoids ambiguity from delimiter-concatenated strings.
One local monitor protects the posting. This is an obligation ledger, not a bank payment API.
- Exact shares + command key
↓ Check registered members and total
Validate balanced delta↓ Prepare all checked balance adds
Atomic entry + projection↓ Store typed retry fingerprint
Immutable result
A reversal may change who owes whom because prior settlements remain part of history.
- Original expense entry
↓ Retain original fact
Later settlement entry↓ Append correction, do not erase settlement
Inverse expense entry↓ Replay all entries in order
Rebuilt net balances
Data model, schema and relationships
An expense has a payer and several shares. A settlement is a separate money fact; net balances are a projection of both, scoped to one currency.
Implementation boundary: Expense/share/balance records map to the reference arithmetic. Durable accounts, membership permissions and settlement rails remain adapters.
These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.
Entity definitions and field contracts
member
Identity used in group arithmetic.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Member identity |
name | text · VALUE | Display label |
Code mapping: Member identity.
- Identity must not depend on mutable display name.
expense_group
Currency and membership scope.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Group identity |
currency | text · VALUE | Single declared currency |
Code mapping: Group scope.
- Do not net mixed currencies without explicit conversion ledger.
expense
One payer-funded expense.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Expense identity |
group_id | uuid · FK | Accounting scope · expense_group.id |
payer_id | uuid · FK | Payer · member.id |
amount | bigint · VALUE | Exact minor units |
created_at | timestamp · VALUE | Immutable acceptance instant for history queries |
Code mapping: Expense record.
- Shares sum exactly to amount; retry identity binds full split request.
settlement
Accepted transfer between members.
| Field | Type / key | Meaning and reference |
|---|---|---|
id | uuid · PK | Settlement identity |
group_id | uuid · FK | Currency scope · expense_group.id |
from_id | uuid · FK | Sender · member.id |
to_id | uuid · FK | Recipient · member.id |
amount | bigint · VALUE | Transfer amount |
Code mapping: Settlement input/projection.
- Sender differs from recipient; deduplicate settlement identity.
Relationship cardinalities and their reasons
| Parent → child | Multiplicity | FK / why |
|---|---|---|
| expense_group → expense contains expenses | 1 parent per child; 0..* children per parent | expense.group_id → expense_group.idGroup determines the single-currency accounting boundary. |
| member → expense pays | 1 parent per child; 0..* children per parent | expense.payer_id → member.idPayer can also owe a share; those are distinct roles. |
| expense → share splits into | 1 parent per child; 0..* children per parent | share.expense_id → expense.idJoin rows resolve expense/member many-to-many liability. |
| member → share owes shares | 1 parent per child; 0..* children per parent | share.member_id → member.idRepeated participation across expenses creates net liability. |
| expense_group → settlement records settlements | 1 parent per child; 0..* children per parent | settlement.group_id → expense_group.idSettlements change balances without rewriting old expenses. |
| member → settlement sends | 1 parent per child; 0..* children per parent | settlement.from_id → member.idDirectional foreign key identifies sender. |
| member → settlement receives | 1 parent per child; 0..* children per parent | settlement.to_id → member.idA second role-specific foreign key identifies recipient. |
Transaction boundaries
- Expense insertion, validated shares and balance projection update commit together.
- Settlement identity and both balance deltas commit together; simplification suggestions are not actual payment evidence.
Indexes and uniqueness
- Index expense(group_id,created_at), share(member_id,expense_id), settlement(group_id).
- Unique operation identity with immutable payer/amount/split fingerprint.
Schema traps and failure cases
- Rounding each share independently can make shares sum to more than the expense.
- A simplified debt graph is not proof of an executed settlement or permission to rewrite history.
Trace the records through one operation
- A pays 100; shares A34,B33,C33 conserve the amount.
- B pays A 33 under settlement S.
- Repeated S changes nothing; projection still sums to zero.
Method contracts
split(amount, positiveWeights) → immutable exactShares
Use exact integer arithmetic and deterministic remainder allocation; reject empty/zero/negative weights.
expense(payer, amount, exactShares, key) → Entry
Validate member IDs and exact sum. Payer gains amount and each member loses their share; a payer share offsets their credit. Compute all balance changes before commit.
settle(debtor, creditor, amount, key) → Entry
Resolve duplicates before checking current eligibility, since the successful original call changed balances. Reject over-settlement and same-member transfers.
reverse(entryId,key) → Entry
Append exact inverse of an original, once. A reversal cannot itself be reversed through this API; original records stay unchanged.
balances(), entries(), rebuild()
Return immutable observations. rebuild replays entries in order using checked arithmetic and should equal the materialized balance projection.
What, when, why and how
Each concept is attached to the requirement it protects, its implementation, an alternative and a test.
Exact allocation and deterministic remainder
- what
- Represent shares in integer minor units and assign indivisible leftovers explicitly.
- when
- Amounts do not divide evenly or weights are large.
- why
- Binary floating point or independent rounding can create or lose a currency unit.
- how
- Compute floor(amount×weight/sumWeights) using BigInteger; distribute leftover units in sorted member order.
Code: split · Invariants: I1
Alternatives, cost and failure example
Alternative: Largest-remainder allocation is another policy, but requires tie rules and independent tests.
Cost: Sorting P members is O(P log P); exact multiplication/division has bit-length cost. Input amounts/weights are long-sized.
Counterexample: 100 divided three ways rounded to 33 each loses one unit.
Test: Assert 34/33/33, huge weights and exact conservation for seeded varied amounts.
Balanced ledger and materialized projection
- what
- Retain immutable zero-sum postings while deriving a fast net-balance view.
- when
- Users need auditability and repeated balance queries.
- why
- Mutable totals alone cannot explain how a correction or settlement changed obligations.
- how
- apply verifies zero-sum deltas, computes a temporary next map with checked adds, then commits projection, Entry and command result under one monitor.
Code: apply, rebuild · Invariants: I2
Alternatives, cost and failure example
Alternative: Recompute on each read for smaller histories; add a projection only when its cost and repair contract are justified.
Cost: This implementation copies all M member balances per posting, so the update is O(M+P), not O(P) alone.
Counterexample: Entry is stored, then balance update overflows halfway, leaving history and projection inconsistent.
Test: Force long overflow and verify neither entries nor balances change; compare rebuild after every seeded expense.
Idempotent operation identity
- what
- Match retries by structured business arguments and retain the original result.
- when
- A caller loses the response after a commit.
- why
- A retry can otherwise double the obligation, while reusing one key for another payer can hide data errors.
- how
- ExpenseFingerprint, SettlementFingerprint and ReversalFingerprint are immutable values. Matching keys return the original entry; mismatches fail. Settlement checks retries before new balance constraints.
Code: Command, ExpenseFingerprint, settle · Invariants: I3
Alternatives, cost and failure example
Alternative: A durable unique key/result row in the same transaction is the persisted equivalent.
Cost: Dedupe retention grows; expiration must preserve the business retry horizon.
Counterexample: A successful settlement is retried, but current debt is now zero and naïve eligibility validation rejects the original result lookup.
Test: Race two identical expense keys; assert one stored entry and identical returned results.
Reversal instead of history rewriting
- what
- Correct an earlier posting by appending its inverse.
- when
- An expense was wrong after other events already occurred.
- why
- Deleting history can silently delete evidence and produce unexplained balances after settlement.
- how
- reverse references the original, negates each delta with checked arithmetic and records one reversal. All subsequent entries remain.
Code: reverse, Entry · Invariants: I2, I3
Alternatives, cost and failure example
Alternative: Editing an uncommitted draft is simpler; after posting, corrections need an explicit audit model.
Cost: Reversal may produce newly owed balances because actual prior settlements remain recorded.
Counterexample: Erase an expense and its related settlement as if a bank transfer never happened.
Test: Reverse the original after B settled A; rebuild shows A now owes B while preserving the settlement entry.
Read and run the Java reference
Reveal complete Solution.java
Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.
split preserves every minor unit
Floored shares cannot over-allocate. Each participant loses less than one unit to flooring, so total remainder is less than participant count and one sorted pass can assign it.
apply validates then publishes
Balanced sum uses BigInteger so even validation does not overflow. Checked long per-member adds occur in a temporary map before sequence/entry/projection mutation.
settle resolves prior result before eligibility
The first successful settlement changed current balances. Repeat detection must happen first or the retry can appear to over-settle.
reverse preserves causal history
One inverse entry links to its original. It is safe to rebuild because every accepted posting follows the same checked balance transition.
Complexity and limits
- Weighted splitting sorts P participant IDs: O(P log P), plus bounded-size BigInteger arithmetic. Shares and weights use O(P) space.
- Posting copies M balances, validates P deltas and stores an entry: O(M+P) expected work plus typed fingerprint maps. Immutable balance snapshots also copy O(M).
- rebuild scans all retained postings E with total delta count D: O(M+D). History/dedupe/reversal retention grows O(D+E).
- The monitor serializes all postings. Real large-group storage would use a transactional ledger and carefully versioned projections; a concurrent map does not make a multi-member posting atomic.
Walk through the actual reference
These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.
Exact split and settlement
Step 1 of 3: Split 100 equally among A, B, C
Shares 34,33,33; A paid
Invariant: Shares sum exactly to 100
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Split 100 equally among A, B, C | Shares 34,33,33; A paid | Shares sum exactly to 100 |
| Post expense | Balances A +66, B -33, C -33 | Net obligations sum to zero |
| B settles 33 to A | A +33, B 0, C -33 | Settlement records obligation change, not a real bank transfer |
Correction after settlement
Step 1 of 2: Reverse original expense
Append inverse entry; original remains
Invariant: Audit history is append-only
Read the complete trace table
| Action | State after action | Invariant |
|---|---|---|
| Reverse original expense | Append inverse entry; original remains | Audit history is append-only |
| Rebuild balances | A -33, B +33, C 0 | Prior settlement is not erased by expense correction |
Mistakes and counterexamples
Round every share independently
Failing case: 33+33+33 for amount100.
Repair: Use exact units and an explicit remainder policy.
Maintain totals without entries
Failing case: A disputed correction has no reproducible explanation.
Repair: Retain immutable postings and a rebuildable projection.
Deduplicate by unescaped concatenated strings
Failing case: Member identifiers contain separators used by the fingerprint format.
Repair: Use typed structured equality or canonical length-safe serialization.
Claim greedy settlement minimizes all transfers
Failing case: A valid greedy matching need not solve a global combinatorial minimum.
Repair: State whether the objective is validity or optimality and prove/test accordingly.
Engineer → Senior → Staff
Implement exact splits and clear balances.
Executed: weighted/equal allocation, exact expense validation and settlements.
Change exercise
Add a 50:30:20 split of 101 and explain which member receives the leftover unit.
Worked answer guidance
Calculate floor shares 50/30/20 and allocate the remaining one by the declared sorted-ID rule. The answer depends on identity order, so keep that contract visible.
Interview follow-ups
Why are amounts integers?
A currency minor unit is indivisible in this model. Integer/decimal policies with explicit rounding preserve totals; floating point is unnecessary.
Does a positive balance mean money is in a wallet?
No. It means a net group obligation to receive; this ledger performs no actual payment.
Provenance and scope of evidence
Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.
Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.