See It Flow
System Design · Trade-offs · ~30 min read

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.

Mangesh Mandlik·Updated September 2026
Decision 01

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.

OptionFavorsCosts
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 transactionEvery 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 earlyDistributed 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 rewriteDiscipline-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
Service topology options

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.

Decision 02

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.

OptionFavorsCosts
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 constraintsSchema 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 startTypical 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 invariantTwo storage technologies to operate, back up, and reason about instead of one
Storage engine options for financial records
Decision 03

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.

LEDGER EVENTS — expense evt-dinner-01p-asha+60000 paisep-ben-30000 paisep-cara-30000 paiseImmutable, append-only — a correction adds a reversal + new event, never edits these rows
Immutable ledger entries as the source of truth; balances are projections derived from the entry history
OptionFavorsCosts
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 neededNo 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 timeBalance 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 glanceThe 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
Source-of-truth models
Decision 04

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?

StrategyFavorsCosts
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 syncAt 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 entirelyThe 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 consumerDecouples projection maintenance from the ledger write's own latency entirelyIntroduces 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'
Balance computation strategies

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.

Decision 05

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?

OptionFavorsCosts
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 needsPairwise 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 entriesSupports both 'who owes whom' (needed for simplification and disputes) and 'what's my overall net' (needed for the group summary view) from one consistent sourceTwo projections to maintain instead of one, though both stay cheap at the typical 3–20 member group sizes this workload targets
Balance representation options
Decision 06

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.

StrategyFavorsCosts
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' pathNeeds 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 cacheNeeds the version signal to be reliable and checked everywhere the suggestion is served
Simplification timing strategies
Decision 07

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.

RAW PAIRWISE (4 debts)AshaBenCara100300300SIMPLIFIED (2 debts)AshaBenCara500100
Vector B simplified: four raw pairwise debts collapse to two suggested transfers, Cara→Asha ₹500 and Cara→Ben ₹100
AlgorithmFavorsCosts
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 transfersNot 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 configurationThe 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 onesTwo code paths — greedy always available as fallback, exact search only under a size threshold — to maintain and test
Simplification algorithm options
Decision 08

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.

RepresentationFavorsCosts
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 typeExact base-10 arithmetic, no floating-point representation errorSlower 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 cleanlyEvery 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
Monetary representation options
Decision 09

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.

Expense ₹900paid by AshaAshashare ₹300Benshare ₹300Carashare ₹300300 + 300 + 300 = 900 — shares always conserve the total exactly
Largest-remainder allocation: each share's exact entitlement rounds down, the leftover paise distributed by documented tie-break order
StrategyFavorsCosts
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 amountAdds 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 remainderNo visible system bias toward any particular member over time, in aggregateBreaks 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
Remainder allocation strategies
Decision 10

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?

ApproachFavorsCosts
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 moneyCouples 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 entirelyIntroduces 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 asynchronouslyGets 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 needsRequires 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
Write-path timing options
Decision 11

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.

ModelFavorsCosts
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 stalenessAt 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 operateDirectly 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 viewGives 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 pathRequires 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
Read consistency options
Decision 12

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.

EXPENSE ROW — starts at version 4Asha PATCH v4→v5Ben PATCH v4→v5DB: now v5wins — commits409 conflict
Two concurrent edits against version 4: one commits as version 5, the other is rejected with an explicit conflict
ApproachFavorsCosts
Last-write-wins, no version checkSimplest possible implementation, no extra fields or checksSilently 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 loserThe 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 factA 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
Concurrency control options

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.

Decision 13

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.

StrategyFavorsCosts
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 valuesDestroys 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 auditEvery 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 replacementLoses 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
Expense correction strategies
Decision 14

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.

StrategyFavorsCosts
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 outrightUnless 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 onesSame event-volume cost as Decision 13
Delete strategies
Decision 15

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.

StrategyFavorsCosts
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 authorityIf 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 UITrusting 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 spaceRequires a durable, indexed idempotency-key store checked before every write, exactly as Decision 16 details
Identifier generation strategies
Decision 16

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.

StrategyFavorsCosts
No dedup at allNothing to buildAny 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 caseBoth 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 signalRequires 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
Deduplication strategies
Decision 17

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.

Expense Tablesame TXOutbox Tablesame TXRelay WorkerEvent Busat-least-onceone DB transactionpollpublish
Ledger write, balance projection update, and outbox row commit in one local transaction; a relay publishes from the outbox afterward
StrategyFavorsCosts
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 tableThe 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 anotherRequires 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 tableAvoids maintaining an application-level outbox table — the guarantee comes from the database's own commit logTies 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
Event publication strategies
Decision 18

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.

GuaranteeFavorsCosts
At-most-once (the relay publishes once and doesn't retry on an uncertain outcome)No duplicate events everAny 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 perfectlyGenuinely 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 pathEvery 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
Delivery guarantee options
Decision 19

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.

StrategyFavorsCosts
No cache (every balance read goes straight to the materialized projection from Decision 04)Simplest — the projection is already fast and purpose-built for thisAt 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 windowThe 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 changedRequires the write path to expose a cheap, reliable version signal and the read path to check it consistently
Read caching strategies
Decision 20

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.

StrategyFavorsCosts
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 caseA 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 readBreaks 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 isolationDestroys 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
Partitioning strategies
Decision 21

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?

OptionFavorsCosts
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 peopleSilently 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 kindZero risk of conflating two groups' debts; every group's ledger stays exactly zero-sum and nothing moreA 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 transferThe 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
Cross-group aggregation options
Decision 22

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.

OptionFavorsCosts
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 neededBakes 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 anywhereMatches Vector H precisely; every ledger stays exactly zero-sum in its own unit, with zero conversion-rate risk anywhere in the system of recordA 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 correctionThe 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
Multi-currency handling options
Decision 23

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.

OptionFavorsCosts
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 dependencyThe 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 guaranteeForces 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 directlyThe data model and UI need to carry and surface that confirmation-source distinction consistently, rather than treating all settled markers as equivalent
Settlement recording options
Decision 24

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.

InitiatedPendingnot yet confirmedConfirmedbalance reducedFailedbalance unchangedprovider confirmsprovider fails
Settlement lifecycle: pending stays outside the confirmed balance until it transitions explicitly to settled or failed
ModelFavorsCosts
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 awayDirectly 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 settledA 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 flightThe settlement workflow needs a real state machine instead of a single boolean, with every transition needing a defined trigger and no silent auto-transitions
Settlement state models
Decision 25

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?

ChannelFavorsCosts
Polling (client periodically re-fetches the group's balance)Simplest implementation, works everywhere with no persistent connection neededEither 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 dashboardA 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 openMaintaining 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
Update delivery options
Decision 26

Receipt Storage: Object Storage Reference vs Database Blobs

An expense can carry a receipt photo — where does that file actually live?

OptionFavorsCosts
Database blob (binary column on the expense record)One system to back up, one place to look, transactionally consistent with the expense row itselfReceipt 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 byteIntroduces 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 caseTwo 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
Receipt file storage options
Decision 27

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.

StrategyFavorsCosts
Retain everything forever, no deletion path at allMaximal auditability and dispute-resolution capability, with nothing ever at risk of being lostIgnores 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 completelyDirectly 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 membersRequires 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
Retention vs deletion strategies
Decision 28

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?

OptionFavorsCosts
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 modelThe 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 transactionsThe 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 servicesGets 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 fitStill 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
Build vs adopt options

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.

Summary

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.

DecisionChoiceWhyReconsider When
Source of truthImmutable ledger entries, with balances and simplification suggestions as derived, rebuildable projectionsEvery 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 ownOnly for a small, single-group, low-stakes toy with no disputes, no correction history, and no recovery requirement
Concurrency modelOptimistic concurrency with a version field on every expense; one concurrent edit wins explicitly, the other gets an explicit conflictMatches a low-contention, user-facing edit pattern without holding locks across a network round-trip, and never silently discards a user's editA 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 publicationTransactional outbox, written in the same transaction as the ledger and balance projection update, relayed to downstream consumers at-least-onceEliminates 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 designOutbox-table write volume itself becomes the ledger database's bottleneck, or infrastructure-level change-data-capture becomes available across many services uniformly
Debt simplification modelGreedy simplification over pairwise ledger-derived net positions, explicitly presented as a suggestion, never a provably minimal or forced resultMatches 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 justifiedA specific feature promises fewest-possible-transfers as a guarantee, or an atypically large settlement group makes the transaction-count gap materially annoying
Multi-currency isolationStrictly separate ledgers per currency as the system of record; any converted total is an optional, clearly-labeled, non-authoritative display estimatePrevents 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 directlyNever 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 modelExplicit pending state, separate from and never substituting for confirmed settlement; only a provider-confirmed or explicitly-recorded success event updates the confirmed balanceA 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 mostNever relax the state model itself; what legitimately varies is how quickly pending resolves depending on the settlement path
ADR summary — the handful of truly load-bearing decisions
Key takeaways
  • 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.