Splitwise System Design Trade-offs and Architectural Decisions
Complete Design walked through one coherent architecture for Weekend Trip's shared-expense ledger (Asha, Ben, Cara), built against a specific workload: roughly 10M users, 5M expense writes a day, 20M balance reads a day. Nothing about that architecture was the only option — every piece of it was a choice made against those numbers and against the correctness invariants a money-adjacent product cannot compromise on. This page works through 28 of those choices individually: the genuine alternatives, what each one actually costs in correctness, latency, or operational complexity, the baseline recommended for this workload, and — just as important — the specific condition that would flip the recommendation. A handful of these decisions are close to universal for any serious shared-expense system; most are not, and treating them as if they were is itself a design mistake.
Service Topology: Monolith MVP vs Early Microservices vs Modular Monolith
Weekend Trip's app could be built many ways structurally long before 10M users ever shows up. The topology chosen at the start determines how painful every later split is — and whether it's even needed.
| Option | Favors | Costs |
|---|---|---|
| Single monolith (expense capture, ledger, simplification, settlement, and notifications all in one deployable service and one database) | Fastest initial build — one codebase, one deployment, no network calls between what are still just function calls; trivial to keep the ledger write and the balance projection update (Decision 10) inside one local transaction | Every module scales together even though their load profiles differ wildly — ledger writes (5M/day) and balance reads (20M/day) are pinned to the same fleet size as the much lighter settlement-confirmation or notification paths |
| Early microservices (separate expense service, ledger service, simplification service, settlement service, notification service, each with its own datastore and deployment from day one) | Each component can scale and deploy independently from the first release; team ownership boundaries match service boundaries early | Distributed transactions across module boundaries appear immediately — a new expense now has to update ledger entries and a balance projection across a network call instead of one local database transaction, reintroducing the dual-write problem (Decision 17) before the product has validated it needs this much isolation at all |
| Modular monolith (one deployable service, one database, hard internal module boundaries — expense, ledger, simplification, settlement — enforced by code structure, not network calls) | Keeps the simplicity of local transactions for the ledger-write-plus-projection path while making a later extraction (evolved-architecture) a refactor along existing seams rather than a rewrite | Discipline-dependent — module boundaries are only as real as the team's willingness not to reach across them directly, and nothing structurally prevents that the way a network boundary would |
At 5M writes/day and 20M reads/day, none of the three choices is forced by raw throughput alone — a single well-indexed relational database can handle that volume. The real question is organizational and evolutionary, not whether scale is physically possible under a monolith.
Storage Model: Relational Database vs Document Store for Financial Records
Every expense, ledger entry, and balance projection needs a storage engine, and the choice is not neutral once invariants like shares-sum-to-total-exactly and zero-sum-per-closed-ledger are involved.
| Option | Favors | Costs |
|---|---|---|
| Relational database (e.g. Postgres-style, with multi-row transactions) | ACID multi-row transactions enforce invariants directly at the database layer — a ledger write and its paired balance projection update commit together or not at all; mature support for exact integer/numeric types and constraints | Schema migrations carry real cost as the domain model evolves, and horizontal write scaling needs a deliberate partitioning strategy (Decision 20) rather than something that comes for free |
| Document store (schema-flexible, document-shaped records) | Flexible schema is convenient for evolving group and member metadata and attachments; horizontal scaling is often built in from the start | Typical multi-document transaction support is weaker or more awkward than a relational engine's, which is a direct risk to the zero-sum and shares-sum-to-total invariants spanning multiple rows (a split's shares plus its parent expense) in a single write |
| Hybrid (relational for the ledger/expense/balance core, document store for genuinely document-shaped side data) | Relational guarantees exactly where multi-row atomicity is non-negotiable; document flexibility for receipt metadata, activity-feed entries, and group preferences that never participate in a financial invariant | Two storage technologies to operate, back up, and reason about instead of one |
Source of Truth: Immutable Ledger Events vs Mutable Balances
Every other decision on this page depends on what the system treats as authoritative when a dispute, correction, or crash occurs. Vector I makes the requirement concrete: reversing the ₹900 dinner must restore zero net effect while keeping an audit trail — which is only possible if something durable records what happened, not just what the balance currently is.
| Option | Favors | Costs |
|---|---|---|
| Mutable balance table as truth (each user-pair or user has a stored balance directly incremented or decremented in place by each expense or settlement) | Simplest reads — a balance is just a row, no aggregation needed | No record of how a balance arrived at its current value; reversing or correcting a past expense (Vector I) means guessing the inverse operation, and a crash mid-update can leave a balance in a state nothing can explain or rebuild |
| Immutable ledger entries as truth, balances derived (every expense and settlement appends one or more immutable entries; current balance for any user or pair is computed or projected from the entry history) | Replayable and auditable, and matches the reversal-not-mutation invariant exactly — reversing dinner is itself a new entry, not an edit, so the original and its reversal both remain visible forever; balances are rebuildable from scratch at any time | Balance is no longer just a row — it is a projection that must be computed or maintained (Decision 04), and entry volume grows without bound unless something archives it (Decision 27) |
| Mutable balance as the only record, with a separate audit log for compliance only (not used to derive the balance) | Looks similar to the ledger model's auditability at first glance | The audit log and the balance can drift the moment a bug or manual fix touches the balance without also writing an audit entry — unlike a true ledger, nothing forces the two to stay consistent, because the balance was never actually derived from the log |
Balance Reads: Compute on Read vs Materialized Projections
Given ledger entries are truth, does a balance read sum the relevant entries live, or read from an already-maintained projection table?
| Strategy | Favors | Costs |
|---|---|---|
| Compute on read (sum relevant ledger entries, filtered by group/user/pair, on every balance request) | Always reflects current ledger state exactly, with no second structure to keep in sync | At 20M balance reads a day, a live aggregation query against a growing entries table, even well-indexed, puts sustained load directly on the ledger's own writable store, competing with the 5M/day write path for the same resources |
| Materialized balance projection (a maintained table of current balances per user per group, updated as each ledger entry is appended, read directly without aggregation) | O(1) lookups for balance reads; isolates read load from the ledger's write path entirely | The projection is a second thing that can drift from the ledger and needs a defined rebuild path if it's ever found to have drifted |
| Materialized projection updated asynchronously via an outbox/event consumer | Decouples projection maintenance from the ledger write's own latency entirely | Introduces a staleness window on balance reads that has to be explicit — a real cost for a feature users treat as 'how much do I actually owe right now' |
At roughly a 4:1 read-to-write ratio, live aggregation does not stay cheap once a group's ledger history grows past a season or two of activity — Weekend Trip's own history stays small, but a user running many long-lived groups accumulates thousands of entries over time.
Balance Model: Pairwise Debts vs Net-Position Model
Vector B shows the combined picture — Asha +₹500, Ben +₹100, Cara −₹600 — only becomes actionable settlement (Cara→Asha ₹500, Cara→Ben ₹100) once the system knows the underlying pairwise contributions, not just each person's net. What does the ledger actually store as the unit of debt?
| Option | Favors | Costs |
|---|---|---|
| Pure net-position per user (one number per user per group, e.g. Asha +600) | Simplest possible balance view, directly matching 'paid-for-others minus own-share' | Cannot answer 'who specifically owes whom' — net position alone cannot reconstruct the pairwise debts that fed into it, which is exactly the capability Vector B's simplification step and any dispute or audit depend on |
| Pairwise debts per ordered user pair (a running balance for every debtor-creditor pair that has ever transacted, derived from ledger entries that are themselves pairwise) | Preserves exactly who-owes-whom at the finest grain, matching both the un-simplified pairwise debts in Vector B and dispute/audit needs | Pairwise balances grow roughly with the number of distinct pairs that have transacted, up to N(N-1) per group — more rows to update per expense that touches multiple members |
| Hybrid — pairwise ledger entries as the atomic unit of truth, with both a pairwise-balance projection and a net-position-per-user projection maintained from the same entries | Supports both 'who owes whom' (needed for simplification and disputes) and 'what's my overall net' (needed for the group summary view) from one consistent source | Two projections to maintain instead of one, though both stay cheap at the typical 3–20 member group sizes this workload targets |
Simplification Timing: On Demand vs Persisted Suggestions
Once pairwise debts are known, the simplified debt graph — who should pay whom to settle up — can be computed fresh every time it's viewed, or computed once and stored.
| Strategy | Favors | Costs |
|---|---|---|
| On-demand computation (recompute the suggestion every time a user opens the settle-up view, from current pairwise/net balances) | Always reflects the latest balances with no risk of a stale cached suggestion; cheap at this workload's typical group size (3–20 members) | Recomputes the same result repeatedly for a group with no new activity — wasted work at high view-frequency, though cheap per call |
| Persisted suggestion (compute once after each balance-changing event, store the resulting suggested transfers, serve directly on read) | Fast reads, no recomputation on the common 'just show me the view' path | Needs explicit invalidation on every balance change; a missed invalidation path silently serves a suggestion that no longer matches current balances — a correctness risk disguised as a caching detail |
| Versioned persisted suggestion (same as above, but tagged with the balance-projection version it was computed from, invalidated precisely on version change rather than relying on every call site to remember) | Closes the plain-persisted option's gap — reads can detect staleness instead of silently trusting a possibly-stale cache | Needs the version signal to be reliable and checked everywhere the suggestion is served |
Simplification Algorithm: Greedy vs Exact Minimum-Transfer Optimization
Debt simplification preserves net positions, not provenance, and is explicitly a suggestion, never a forced transfer — but even restricted to that, 'fewest possible transactions' is a real optimization problem, and greedy's non-minimality deserves a direct comparison.
| Algorithm | Favors | Costs |
|---|---|---|
| Greedy largest-debtor/largest-creditor matching (repeatedly match whoever owes the most against whoever is owed the most, transfer the smaller of the two amounts, repeat) | Simple, fast — near-linear in the number of distinct net positions — and produces a reasonable reduction in transaction count, exactly Vector B's four raw debts collapsing to two suggested transfers | Not provably minimal — there exist balance configurations where greedy produces more transactions than the true minimum, because it makes locally-best choices with no look-ahead |
| Exact minimum-transaction optimization (search over partitions of net positions into zero-sum subsets, minimizing total transfers) | Guarantees the fewest possible transactions for any given balance configuration | The search space grows combinatorially with the number of distinct non-zero net positions — fine at this workload's 3–20 member group sizes, impractical well before group size reaches even a few dozen |
| Bounded/heuristic optimization (run greedy, then apply a limited local-search or branch-and-bound pass only when the net-position count is small enough to afford it) | Captures most of exact optimization's benefit for genuinely small groups without paying combinatorial cost on larger ones | Two code paths — greedy always available as fallback, exact search only under a size threshold — to maintain and test |
Currency Representation: Integer Minor Units vs Decimal/Floating Point
Integer minor units only is a core invariant of this design, and it's worth seeing exactly why the alternatives fail, not just asserting the rule.
| Representation | Favors | Costs |
|---|---|---|
| Floating point (IEEE 754 double) | Native in nearly every language; arithmetic syntax 'just works' | Binary floating point cannot represent most decimal fractions exactly — splitting ₹900 three ways can silently produce rounding artifacts that don't sum back to ₹900, directly violating shares-sum-to-total-exactly, and the error compounds across millions of expenses |
| Arbitrary-precision decimal type | Exact base-10 arithmetic, no floating-point representation error | Slower than integer arithmetic, and still requires an explicit remainder rule for divisions that don't terminate cleanly — decimal precision solves representation, not the remainder problem |
| Integer minor units (paise; all amounts stored and computed as integers, converted to major units only at the presentation layer) | Exact arithmetic using ordinary integer operations, no representation error ever, and a division's remainder is itself just an integer that a deterministic rule (Decision 09) can assign cleanly | Every layer of the stack must consistently treat amounts as minor-unit integers and convert only at display time — one layer that silently works in rupees-as-floats reintroduces the exact bug this choice exists to prevent |
Remainder Rule: Deterministic Rounding vs User-Selected Remainder
Vector D — ₹100 split 33.33/33.33/33.34%, landing on 3,333/3,333/3,334 paise — requires a specific rule for where the leftover minor units go, because shares must sum to the total exactly, every time.
| Strategy | Favors | Costs |
|---|---|---|
| Deterministic system rule (largest-remainder method: round each share down, then distribute leftover minor units one each to the shares with the largest fractional remainder, in a stable, documented tie-break order) | Fully automatic, consistent, reproducible — the same split inputs always produce the same output, which is essential for replay (recomputing from the same event always yields the same result) | The recipient of the extra paise is a system decision the user didn't make — invisible at one paise, but still a real, slightly arbitrary allocation |
| User-selected remainder (the person recording the expense picks who absorbs the leftover minor units) | Gives the user explicit control over an admittedly tiny amount | Adds a UI step to every split that doesn't divide evenly, for a difference of at most a few paise — friction disproportionate to the stakes, and still needs a deterministic fallback for API/programmatic expense creation where no user is present to choose |
| Random assignment of the remainder | No visible system bias toward any particular member over time, in aggregate | Breaks the deterministic-replay invariant outright — recomputing the same expense from its stored event would not reproduce the same stored shares unless the random seed itself were captured and replayed, defeating the purpose |
Write Path Timing: Synchronous Balance Update vs Asynchronous Projection
When an expense is recorded, does the request wait for the balance projection to update before returning success, or is the ledger append itself the acknowledgment boundary, with the projection update following asynchronously?
| Approach | Favors | Costs |
|---|---|---|
| Fully synchronous (ledger entries and balance projection update commit together, in one database transaction, before the client gets a response) | The submitting user's very next balance read is guaranteed correct and current — no window where 'I just added an expense but my balance hasn't updated' can occur, which matters more here than in a non-financial feed because it's money | Couples expense-write latency and availability directly to projection-write latency; contention on the projection table slows down expense creation itself |
| Fully asynchronous (ledger append is the acknowledgment; the balance projection is updated by an async consumer reading from the transactional outbox) | Ledger append latency and availability depend only on the ledger store; decouples a projection hiccup from expense-submission availability entirely | Introduces a staleness window between 'expense recorded' and 'balance reflects it' — a more visible, trust-affecting gap for a money app than for a less stakes-sensitive feed |
| Synchronous update to the co-located balance projection, plus a transactional outbox entry in the same transaction to drive every other downstream consumer asynchronously | Gets immediate consistency for the primary balance view and decoupling for every other downstream consumer (notifications, activity feed, cross-group aggregates), since those are genuinely different workloads with different consistency needs | Requires the ledger and primary balance projection to share a transactional boundary, which constrains Decision 20's partitioning choice to keep a group's ledger and balance projection co-located |
Read Consistency: Strong Consistency vs Eventual Consistency for Balance Reads
20M balance reads a day against 5M expense writes a day is a read-heavy ratio — the classic argument for serving reads from replicas or caches that lag slightly behind. But slightly stale money is a different kind of cost than slightly stale content elsewhere in a product.
| Model | Favors | Costs |
|---|---|---|
| Strong consistency everywhere (every balance read served from the same transactional store the write committed to, no replica lag tolerated) | Zero ambiguity, no 'why does my balance look wrong' tickets caused by staleness | At 20M reads/day, routing every single read to the primary write-capable store is a real capacity cost and ties read availability directly to write-path infrastructure health |
| Eventual consistency everywhere (all reads served from asynchronously-replicated replicas or caches, no distinction between a user's own just-changed balance and someone else's balance being viewed) | Scales reads independently, cheapest to operate | Directly reintroduces the 'I just added an expense and my own balance still shows the old number' problem Decision 10 specifically solved — treating all reads identically discards the one guarantee users actually notice |
| Read-your-writes strong consistency for the acting user's own just-changed balance, bounded-stale eventual consistency for every other view | Gives the case users are most sensitive to (did my own action take effect) a hard guarantee, while letting the bulk of the 20M/day read volume scale off a cheaper, slightly-lagged path | Requires the read path to know which reads are 'the acting user's own recent write' versus 'a general view,' which is more routing logic than one uniform strategy |
Concurrency Control: Optimistic Locking vs Pessimistic Group-Level Locks
Vector G: Asha and Ben concurrently edit the same version-4 expense. One must win as version 5, and the other must get an explicit, actionable conflict — never a silently lost edit, never silent corruption.
| Approach | Favors | Costs |
|---|---|---|
| Last-write-wins, no version check | Simplest possible implementation, no extra fields or checks | Silently discards one user's edit with no notification to anyone — directly violates the requirement that one edit wins explicitly while the other gets an explicit conflict, not a silent loss |
| Optimistic concurrency control (every expense carries a version number; an edit must supply the version it read, succeeding and bumping the version only if it still matches, otherwise rejected with an explicit conflict) | Matches Vector G exactly — no locks held between read and write, so normal non-conflicting edits never wait on each other, and a genuine conflict is surfaced immediately and explicitly to the loser | The rejected editor has to handle the conflict (re-fetch, merge/retry UI, or resubmission) — real UX complexity that has to actually exist and be tested, not just return a raw error |
| Pessimistic group-level locking (acquire an exclusive lock on the whole group, or on the specific expense, before any edit can begin, held until commit) | Eliminates the conflict entirely rather than detecting it after the fact | A lock held across a network round-trip (user reading, thinking, then submitting) turns one slow or abandoned edit into a group-wide or expense-wide stall for every other concurrent editor — a much worse availability trade than a rare, explicit conflict |
Weekend Trip's own concurrent-edit rate is inherently low — three people rarely edit the same expense in the same second — but the mechanism has to be correct regardless of frequency, because a rare bug that silently drops an edit to a shared expense erodes trust in the product's core promise.
Correction Model: Reversal Events vs In-Place Expense Mutation
Vector I requires that reversing the ₹900 dinner restores zero net effect while keeping an audit trail — the same reasoning any financial ledger needs for correcting a mistake after the fact.
| Strategy | Favors | Costs |
|---|---|---|
| In-place mutation (editing an expense directly updates its stored amount, split, or payer fields) | Looks simple — the 'current' state is just the latest field values | Destroys the prior state the moment it's overwritten; there is no way to answer what this expense said before it was edited, breaking auditability and making the balance projection's history unreconstructable except by trusting the first write was right |
| Reversal plus replacement events (an edit appends a reversal event for the original expense's ledger entries, immediately followed by a new expense event with corrected values; both remain in the immutable log forever) | Matches Vector I exactly — net effect returns to zero from the reversal, the replacement re-establishes corrected balances, and the full history (original, reversal, replacement) is permanently visible for audit | Every edit becomes two or three ledger-affecting events instead of one field update — more entries to store (addressed by Decision 27, not avoided) and the edit path has to translate correctly into this event pair every time |
| Hard delete and recreate (remove the original expense record entirely, create a brand-new one) | End state looks similar to reversal plus replacement | Loses the original record's own existence, not just its values — an auditor or user asking whether this was originally logged as ₹900 rather than ₹850 has nothing to look at, unlike reversal-plus-replacement where the original event still exists, just net-zeroed |
Delete Model: Expense Delete vs Soft-Delete/Reversal
Deleting an expense entirely is a special case of correction, and deserves its own look because 'delete' sounds like it should mean gone — which is in tension with the ledger's immutability.
| Strategy | Favors | Costs |
|---|---|---|
| Hard delete (remove the expense row and its ledger entries entirely) | Matches a user's intuitive mental model of 'delete means gone' | Directly violates the immutable-ledger invariant — replaying balances from the ledger would no longer reproduce the balance effect this expense briefly had, and any dispute or audit involving the deleted expense has nothing to point to |
| Soft-delete flag (expense row stays, marked deleted, excluded from active balance computation) | The record still physically exists, so nothing is destroyed outright | Unless the balance effect is also reversed through a proper compensating entry, a naive soft-delete can leave the balance projection pointing at stale numbers, or require every balance query to filter on the flag correctly everywhere — an easy place for a forgotten filter to reintroduce the deleted expense's effect |
| Reversal event (deletion is treated exactly like Decision 13's correction model: a reversal event zeroes out the expense's net effect; the original event and its reversal both remain, and the expense is marked reversed/inactive for UI purposes) | Deletion becomes the same mechanism as correction — one pattern to build, test, and reason about for 'something about this expense changed,' not two subtly different ones | Same event-volume cost as Decision 13 |
Identifier Generation: Client-Generated IDs vs Server-Generated IDs
Vector F requires that retrying an idempotency key produces exactly one committed expense, which starts with a decision about who generates the identifiers involved in the first place.
| Strategy | Favors | Costs |
|---|---|---|
| Server-generated canonical ID only (the server assigns the expense's permanent ID only after the write commits) | Simplest server-side model — IDs assigned exactly once, by exactly one authority | If the client's request times out before the response arrives, the client has no ID to check and no way to know whether the expense was created — it can only guess, exactly the ambiguity idempotency keys exist to resolve |
| Client-generated canonical ID (the client mints a UUID for the new expense itself and sends it as the record's own ID) | The client knows the expense's eventual ID immediately, useful for optimistic UI | Trusting a client-generated value as the actual primary identifier of a financial record is a bigger surface for a misbehaving or buggy client to cause ID collisions or spoof an existing record's ID than necessary |
| Client-generated idempotency key, separate from a server-generated canonical ID (the client attaches a unique idempotency key to the create request; the server owns its own ID space but uses the key to recognize and deduplicate a retried request) | Matches Vector F precisely — retrying the same request with the same idempotency key is always safe and produces exactly one committed expense, while the server still owns its own ID space | Requires a durable, indexed idempotency-key store checked before every write, exactly as Decision 16 details |
Deduplication: Idempotency Keys vs Request Dedup Heuristics
The mechanism promised by Decision 15 still needs to actually work, which means weighing it against the easier-looking but unreliable alternative.
| Strategy | Favors | Costs |
|---|---|---|
| No dedup at all | Nothing to build | Any client retry on an ambiguous network failure (timeout, dropped response) double-creates the expense — directly the opposite of Vector F's requirement |
| Heuristic dedup (treat two requests as probably the same if they share user, group, amount, and arrive within a short time window) | No new storage needed; catches the common accidental-double-tap case | Both false positives (two genuinely separate identical ₹300 coffee expenses minutes apart, the second wrongly suppressed) and false negatives (a legitimate retry landing just outside the window, wrongly treated as new) are real, user-visible correctness bugs, and 'short time window' is a guess that's always wrong for someone |
| Idempotency keys (a unique key accompanies the create request; the server checks a durable, indexed store for that exact key before applying the write, returning the original result unchanged if seen before) | Exact, not probabilistic — a retry with the same key is always recognized regardless of timing, amount, or any other heuristic signal | Requires the client to generate and persist the key across its own retry attempts, and the server's dedup store needs its own retention policy for how long a key must be remembered |
Event Publication: Transactional Outbox vs Direct Event Publication
Once Decision 10 writes a ledger entry and its primary balance projection synchronously, every other downstream consumer — notifications, activity feed, cross-group aggregation, analytics — still needs to find out an expense happened. How that event leaves the database is its own decision.
| Strategy | Favors | Costs |
|---|---|---|
| Direct publish after commit (the application commits the database transaction, then separately calls a message broker to publish an event) | Looks straightforward, no new database table | The classic dual-write problem — the database commit and the broker publish are two separate operations with no shared transaction; a crash between them leaves the ledger and the event stream silently disagreeing about what happened |
| Transactional outbox (the event to publish is written as a row in an outbox table in the exact same transaction as the ledger entry and balance projection update; a separate relay reads the outbox and publishes, marking rows sent) | The event's existence is guaranteed by the same atomic commit as the ledger write — no window where the ledger says one thing and the event stream eventually says another | Requires a relay process (polling or change-data-capture) as a new operational component, and outbox rows need their own cleanup/retention once published |
| Change-data-capture directly off the database's write-ahead log, with no explicit outbox table | Avoids maintaining an application-level outbox table — the guarantee comes from the database's own commit log | Ties event shape directly to the physical table schema unless an additional transformation layer is added, and requires CDC infrastructure as an operational dependency instead of a comparatively simpler outbox-relay pattern |
Delivery Guarantee: At-Least-Once Events vs Attempted Exactly-Once Messaging
The same reasoning that makes idempotent ingestion non-negotiable (Decision 16) applies again at the outbox relay's own publish step.
| Guarantee | Favors | Costs |
|---|---|---|
| At-most-once (the relay publishes once and doesn't retry on an uncertain outcome) | No duplicate events ever | Any transient broker or network issue silently drops a downstream notification, activity-feed update, or cross-group aggregate update with no record it was ever supposed to happen — for a trust-sensitive feature like 'did my friend get notified,' silent loss is worse than a duplicate |
| Attempted exactly-once (coordinate the broker publish and the outbox row's sent-marker update as a single atomic operation, e.g. via a distributed transaction) | Would eliminate both duplicates and drops if it worked perfectly | Genuinely exactly-once delivery across a database and an external broker is not achievable without a distributed transaction protocol most brokers don't support natively, and bolting one on is substantial complexity for a guarantee that's effectively unreachable anyway |
| At-least-once with idempotent consumers (the relay retries on any uncertain outcome, occasionally producing a duplicate event; every downstream consumer treats a duplicate event ID as a no-op) | Retrying is always safe, and consumers already need idempotent handling anyway — the pattern extends naturally downstream from the primary ingestion path | Every downstream consumer has to actually implement the dedup check, not just the primary ledger write — a consumer added later that forgets this is a real, recurring risk |
Read Caching: Cache-Aside vs Versioned Materialized Read Model
With 20M balance reads a day, something in front of the primary balance projection is probably going to end up caching results — the question is whether that cache is a blunt TTL, or a model that actually knows when it's stale.
| Strategy | Favors | Costs |
|---|---|---|
| No cache (every balance read goes straight to the materialized projection from Decision 04) | Simplest — the projection is already fast and purpose-built for this | At sustained high read volume for a popular/active group, this still adds load the projection store has to absorb directly, competing with the Decision 10 write path for the same resources |
| Cache-aside with TTL (cache a balance view for a short fixed duration, refetch after expiry) | Simple to add on top of an existing read path; bounds staleness to a known window | The TTL window is a blunt guess — either short enough to barely help, or long enough to risk showing a balance that's noticeably behind a recent expense or settlement, a worse trust cost here than for a less stakes-sensitive feed |
| Versioned materialized read model (the cached balance view carries the projection's own version/sequence number; a read checks whether the cached version still matches current, invalidating precisely on change) | Never serves a balance more stale than the true last update, and can cache aggressively exactly when nothing has actually changed | Requires the write path to expose a cheap, reliable version signal and the read path to check it consistently |
Partitioning Strategy: Per-Group vs Per-User vs Per-Expense Hash
At 10M users and 5M expense writes a day, 'everything on one database' eventually stops being viable for at least the busiest slice of data, and the unit of partitioning matters because it has to match the access pattern already established — ledger entries and balance projections are group-scoped.
| Strategy | Favors | Costs |
|---|---|---|
| Partition by group (each group's expenses, ledger entries, and balance projections co-located, distributed across nodes by consistent hashing of the group ID) | Matches the natural transactional unit from Decision 10 — a single expense's ledger write and balance projection update stay within one partition's local transaction, with no cross-partition coordination needed for the common case | A single extremely large or extremely active group still lives on one partition, capping that specific group's write throughput — unusual for this product's typical 3–20 member shape, but possible for a large event group |
| Partition by user (shard data by user ID, so each user's involvement across all their groups lives on one node) | Could simplify a 'show me all my balances across every group' aggregate query to a single-node read | Breaks the group-scoped transactional locality this workload needs — a single expense touching three group members now potentially touches three different user-partitions for one atomic write, turning a local transaction into cross-partition coordination on every expense creation, not just an occasional aggregate query |
| Partition by expense ID / hash (distribute expense records by a hash of the expense's own ID, independent of group) | Very even write distribution across partitions in isolation | Destroys the ability to compute a group's balance — now scattered across every partition — without a scatter-gather across the entire partition set for every balance read, the opposite of what Decision 04's materialized projection is trying to achieve |
Cross-Group Visibility: Group-Local Balances vs Global Cross-Group Netting
A user in several groups — a trip group, a roommates group, a book club — will want to see roughly how much they owe or are owed across everything. Does the system actually net balances across groups, or only ever aggregate group-local truths for display?
| Option | Favors | Costs |
|---|---|---|
| Global cross-group netting (balances from different groups combined into a single net figure per counterparty — e.g. Asha owes Ben ₹200 in one group and Ben owes Asha ₹150 in another, netted to Asha owes Ben ₹50) | Presents the smallest possible number of apparent debts between any two people | Silently merges two contexts that may have entirely different purposes and expectations — a user who wants to settle up a trip separately from household bills now sees an opaque merged number; this is a forced transformation, not a suggestion the user can decline, in direct tension with the no-implicit-netting posture this system takes elsewhere |
| Strictly group-local balances only, no cross-group aggregation of any kind | Zero risk of conflating two groups' debts; every group's ledger stays exactly zero-sum and nothing more | A user with many groups has no single place to see roughly where they stand overall without manually checking each group |
| Group-local truth with an optional, clearly-labeled display-only aggregate (every balance stays computed and settled strictly within its own group; a summary view can sum each group's own net position per counterparty into one display total, explicitly presented as an aggregate, not as a mergeable or settleable debt) | Gives users the overall-visibility option the first option was reaching for, without actually collapsing distinct group-scoped debts into one forced transfer | The aggregate view has to be labeled carefully enough that users don't mistake it for an actual combined, settleable balance — a UX discipline requirement, not just a backend one |
Currency Isolation: Multi-Currency Separation vs FX-Converted Unified View
Vector H is explicit: ₹1,000 and $20 are separate ledgers, never netted implicitly. The product still reasonably wants to show something coherent to a user holding balances in more than one currency.
| Option | Favors | Costs |
|---|---|---|
| FX-converted unified ledger (every amount converted to one reference currency at ledger-write time, stored and settled in that single unit) | Every balance, everywhere, is directly comparable and summable, with no cross-currency display logic needed | Bakes a specific exchange rate into the permanent record at write time, wrong the instant rates move; reversing or auditing an old expense now also has to account for which rate was used and why, and directly violates no-implicit-netting by silently fusing currencies that were never meant to combine |
| Strictly separate currency ledgers, no converted view anywhere | Matches Vector H precisely; every ledger stays exactly zero-sum in its own unit, with zero conversion-rate risk anywhere in the system of record | A user juggling multiple currencies gets no at-a-glance combined picture at all |
| Separate ledgers as the system of record, with an optional converted display-only view (balances remain fully separate and zero-sum per currency; a summary screen may additionally show an approximate converted total, explicitly labeled as an estimate, never actionable) | Gives users the at-a-glance orientation the strict-separation option lacks, without ever letting a conversion rate leak into the actual ledger, a settlement amount, or a correction | The display-only conversion has to be visibly and durably labeled as an estimate, and the rate source is one more external dependency to manage even for a non-authoritative feature |
Settlement Recording: Manual Repayment Recording vs Payment-Provider Integration
Vector C — Cara repays ₹200 to Asha — has to get entered into the ledger somehow, and 'somehow' ranges from a pure self-report to a fully provider-confirmed transaction.
| Option | Favors | Costs |
|---|---|---|
| Manual recording only (a user records that a payment was made or received, with no connection to any actual money movement) | Works for any payment method two people might actually use — cash, a bank transfer the app has no visibility into, a different app entirely — no integration dependency | The record is purely an assertion; the system has no independent way to know the money actually moved, exactly why settled is explicitly a user assertion unless provider-confirmed, not a verified fact |
| Payment-provider integration only (settlements must go through an integrated payment rail, and only a provider-confirmed transaction can mark a debt settled) | A provider-confirmed settlement is an independently verified fact, not just an assertion — a meaningfully stronger correctness guarantee | Forces every settlement through one specific payment mechanism, which fails the moment two users settle by any other means — cash at the table, a bank transfer outside the app — a real, common case, not an edge case |
| Both, explicitly distinguished (manual recording remains the universal fallback; payment-provider integration is an optional, stronger path; the resulting ledger entry is tagged with its confirmation source) | Supports every real settlement method users actually use, while preserving a clear, queryable distinction between someone asserting a payment happened and a provider confirming it, matching Vector J's distinction directly | The data model and UI need to carry and surface that confirmation-source distinction consistently, rather than treating all settled markers as equivalent |
Settlement State: Pending Payment Reservation vs Confirmation-Only Settlement
Vector J: a pending payment never reduces the confirmed balance, and a failed payment is never treated as settled. The settlement workflow needs explicit states, not just a boolean settled-or-not.
| Model | Favors | Costs |
|---|---|---|
| Optimistic reservation (a payment is immediately marked reduced/settled in the balance view the moment it's initiated, on the assumption it will almost always go through) | The UI feels instantly responsive — initiate a payment, see the balance update right away | Directly violates Vector J — if the payment later fails or reverses, the balance was wrong for the entire window in between, needing a second, user-visible correction to undo an update that should never have been shown as confirmed |
| Confirmation-only, no intermediate state at all (a payment either hasn't happened yet or is confirmed and settled, with no visible pending state in between) | The confirmed balance is always trustworthy, with no risk of showing an un-landed payment as settled | A user who just initiated a payment sees nothing change until confirmation arrives, which can look like the action didn't register, especially if confirmation takes more than an instant |
| Explicit pending state, separate from confirmed settlement (initiating a payment creates a visibly distinct pending record; the confirmed balance is untouched by it; only a provider-confirmed or explicitly-recorded success event transitions it to settled; a failed or expired pending record transitions to failed and stays excluded from settled balances) | Matches Vector J exactly in both directions — pending never reduces confirmed balance, and failed is never settled — while still giving the user visible feedback that their action was received and is in flight | The settlement workflow needs a real state machine instead of a single boolean, with every transition needing a defined trigger and no silent auto-transitions |
Update Delivery: Real-Time WebSocket Updates vs Push Notifications/Polling
When Ben records a payment, how quickly and by what channel do Asha and Cara find out their balance changed?
| Channel | Favors | Costs |
|---|---|---|
| Polling (client periodically re-fetches the group's balance) | Simplest implementation, works everywhere with no persistent connection needed | Either wastes requests when nothing changed or has a real lag before a member notices a balance update; a naive short interval multiplies request volume directly on top of the 20M/day read baseline |
| Push notifications plus on-demand refresh (a push notification announces the change; opening the app or group view then fetches the current, correct balance on demand, with no persistent live connection maintained just for balances) | Matches this product's actual usage pattern — people check a shared-expense app in bursts (after an event, before a trip, when settling up), not as a continuously-open live dashboard | A member who already has the app open in the foreground doesn't see the update until they trigger a refresh or the notification arrives — a small, acceptable gap for this workload |
| Persistent WebSocket connection (every active client session maintains a live, full-duplex connection; changes are pushed instantly to every connected member) | Lowest possible latency for in-app updates while a session is open | Maintaining persistent connections for 10M users' worth of sessions is real infrastructure (heartbeats, reconnect/backoff, server-side connection state) for a feature that is not continuously watched the way a live ticker or leaderboard is |
Receipt Storage: Object Storage Reference vs Database Blobs
An expense can carry a receipt photo — where does that file actually live?
| Option | Favors | Costs |
|---|---|---|
| Database blob (binary column on the expense record) | One system to back up, one place to look, transactionally consistent with the expense row itself | Receipt photos are large, infrequently-queried binary data that bloats a relational database's storage and backup size disproportionately to the transactional data (ledger entries, balances) it's optimized for, and most relational engines serve large binary reads poorly at scale |
| Object storage with a reference (the file uploaded to and served from dedicated object storage; the expense record stores only a reference/URL) | Keeps the transactional database focused on small, structured, frequently-updated rows, while the file lives in storage purpose-built for large binary objects at low cost per byte | Introduces a second storage system and a reference-integrity question — what happens to the database row if the referenced object is missing, or vice versa — handled via a deliberate upload-then-reference write order |
| Hybrid (small thumbnail stored inline or cached for quick preview, full-resolution original in object storage) | Fast preview rendering without a round trip to object storage for the common thumbnail-in-list case | Two representations of the same receipt to keep consistent, more moving parts for a feature that's genuinely secondary to the product's core balance-correctness job |
Audit Retention vs Deletion/Privacy Obligations
The immutable ledger that correctness depends on is in direct tension with a user's reasonable expectation — and in some jurisdictions, legal right — to have their personal data deleted.
| Strategy | Favors | Costs |
|---|---|---|
| Retain everything forever, no deletion path at all | Maximal auditability and dispute-resolution capability, with nothing ever at risk of being lost | Ignores legitimate privacy obligations entirely; in a jurisdiction with a legal right-to-erasure requirement, this posture is a compliance risk, not just a product-polish gap |
| Full deletion on request (a user's deletion request removes their ledger entries, expenses, and all associated records outright) | Satisfies the deletion request literally and completely | Directly breaks the zero-sum and immutability invariants for every group that user was ever part of — deleting their ledger entries leaves every other member's historical balance projection unexplainable and unreproducible from a now-incomplete log, a correctness failure imposed on people who requested nothing |
| Anonymize personal identifiers while preserving the ledger's financial shape (replace personally-identifying fields with an anonymized placeholder; the underlying ledger entries, amounts, and group-balance effects remain intact and reproducible, exactly as they were) | Honors the privacy request's actual substance — removing personally-identifying data about the person — without breaking the shared financial history belonging collectively to the group's other members | Requires a clear, deliberate line between personal data (eligible for anonymization) and financial ledger data (preserved for integrity), a real design and legal-review exercise, not a simple toggle |
Build vs Adopt: Custom Expense Engine vs Financial-Ledger Infrastructure
Should Weekend Trip's expense-splitting core be built from scratch, or built on top of existing double-entry ledger infrastructure?
| Option | Favors | Costs |
|---|---|---|
| Build a fully custom expense-and-ledger engine in-house (expense model, split allocation, pairwise ledger entries, simplification, settlement state machine, all implemented specifically for this domain) | The domain semantics here — group-scoped splits, debt simplification as a suggestion, multi-currency isolation, pending-vs-confirmed settlement — are specific enough that every invariant maps directly onto first-class concepts, not a workaround bolted onto someone else's model | The team owns correctness for every invariant itself, with no vendor or open-source project's track record to lean on, and has to get double-entry-style correctness right from scratch |
| Adopt general-purpose financial-ledger infrastructure (an existing double-entry ledger platform providing accounts, transactions, and balance invariants as its core abstraction, with product-specific logic layered on top) | Inherits a mature, battle-tested implementation of the hardest, most universal part — atomic, balanced double-entry transactions | The platform's native abstractions (accounts, postings) don't map one-to-one onto this product's domain concepts (groups, members, splits, simplification suggestions); a real translation layer is still required, and the platform's own release cadence and failure modes become a new dependency |
| Hybrid — borrow double-entry ledger principles (balanced postings, immutability, append-only history) without adopting an external platform, implementing them directly inside this product's own schema and services | Gets the proven correctness pattern — double-entry bookkeeping is a decades-old, well-understood solution to exactly the 'money has to balance and be auditable' problem this module solves — without taking on a platform dependency whose abstractions don't fit | Still requires the team to correctly implement and test those principles itself — borrowing the idea removes the risk of reinventing the idea badly, not the implementation responsibility |
This module's whole design — ledger entries, pairwise balances, reversal-not-mutation corrections, zero-sum invariants — is itself built on long-established double-entry bookkeeping principles. The real choice was never inventing accounting from nothing versus using someone else's accounting platform; it's how directly to borrow those principles versus adopting a platform's specific abstractions wholesale.
Architecture Decision Record: The Load-Bearing Choices
Most of the 28 decisions above are genuinely workload-dependent — a different scale, a different product shape, or a different team's constraints could reasonably flip them. A smaller set is load-bearing: get these wrong and the rest of the design doesn't hold together, regardless of scale. This ADR table covers that smaller set.
| Decision | Choice | Why | Reconsider When |
|---|---|---|---|
| Source of truth | Immutable ledger entries, with balances and simplification suggestions as derived, rebuildable projections | Every other guarantee — reversal-based correction, auditability, recovery, dispute resolution — depends on there being one durable, replayable history that projections are derived from, not an independent record of their own | Only for a small, single-group, low-stakes toy with no disputes, no correction history, and no recovery requirement |
| Concurrency model | Optimistic concurrency with a version field on every expense; one concurrent edit wins explicitly, the other gets an explicit conflict | Matches a low-contention, user-facing edit pattern without holding locks across a network round-trip, and never silently discards a user's edit | A genuinely multi-step, long-running joint-edit workflow (not part of this product's model today) where blocking is more honest than a late conflict |
| Event publication | Transactional outbox, written in the same transaction as the ledger and balance projection update, relayed to downstream consumers at-least-once | Eliminates the dual-write problem between the database commit and the event stream, and matches the at-least-once-with-idempotent-consumers guarantee required everywhere else in this design | Outbox-table write volume itself becomes the ledger database's bottleneck, or infrastructure-level change-data-capture becomes available across many services uniformly |
| Debt simplification model | Greedy simplification over pairwise ledger-derived net positions, explicitly presented as a suggestion, never a provably minimal or forced result | Matches this workload's typical 3–20 member group sizes, where greedy's output is already close to minimal and exact optimization's combinatorial cost isn't justified | A specific feature promises fewest-possible-transfers as a guarantee, or an atypically large settlement group makes the transaction-count gap materially annoying |
| Multi-currency isolation | Strictly separate ledgers per currency as the system of record; any converted total is an optional, clearly-labeled, non-authoritative display estimate | Prevents an exchange rate from ever leaking into the permanent record, and keeps every ledger exactly zero-sum in its own unit, matching the no-implicit-netting posture directly | Never for the underlying ledger; a regulated-finance context may drop even the display-only estimate to avoid appearing to offer unlicensed currency conversion |
| Settlement confirmation model | Explicit pending state, separate from and never substituting for confirmed settlement; only a provider-confirmed or explicitly-recorded success event updates the confirmed balance | A pending payment must never reduce the confirmed balance, and a failed payment must never be treated as settled — relaxing this trades away the one guarantee users rely on most | Never relax the state model itself; what legitimately varies is how quickly pending resolves depending on the settlement path |
- Immutable ledger entries as source of truth, with balances and simplification as derived, rebuildable projections, is the one decision genuinely upstream of almost everything else on this page.
- Pairwise ledger entries, not net-position-only, are what make debt simplification and dispute resolution possible at all — a net balance alone cannot reconstruct who owes whom.
- Idempotent writes, reversal-based corrections, optimistic concurrency, and pending-never-settled state are close to universal requirements for this product class; almost everything else here is explicitly workload-dependent and should move if the workload does.
- For the architecture these decisions assume, see Complete Design and Architecture Deep Dive; for what happens when these baselines are tested by real incidents, see Failure & Scale Scenarios; for how to present these trade-offs concisely under interview time pressure, see Interview Perspective.