See It Flow
System Design · 17 chapters · ~33 min read

Design Splitwise: Complete System Design of an Expense Sharing App

"Design Splitwise" is one of the most common product-system-design interview prompts: build a service that lets a group of people share expenses, track who owes whom, and settle up. This walkthrough is an original design for that well-known interview problem — it is not a description of the real Splitwise product's internal architecture, which is proprietary and unknown to this module. Every number, table name, and endpoint here is invented for teaching purposes. We build the design around one fictional group, Weekend Trip, with three fictional members — Asha (A), Ben (B), and Cara (C) — tracking shared expenses in Indian Rupees (INR). Every rupee amount is tracked internally in paise (1 rupee = 100 paise, an integer minor unit), because money math done in floating-point decimals is a recurring source of real bugs, and this design never does that.

Mangesh Mandlik·Updated September 2026
Chapter 01

Problem Definition and Product Boundaries

Three friends go on a trip. One pays for dinner, another pays for the taxi, and by the end of the weekend nobody remembers who owes what. A shared-expense app exists to answer one question precisely and durably: given everything that was paid and by whom, who owes whom, and how much?

Weekend Trip is the fictional group used throughout this module. Asha, Ben, and Cara are its three members. Over the course of this design they go to dinner, take a taxi, and make a partial repayment — three small events that are enough to exercise every interesting part of a shared-expense system: multi-way splits, uneven shares, percentage and weighted splits, debt simplification, repayments, edits, concurrent updates, and settlement. We reuse these same people and the same running numbers in every chapter so that the arithmetic is never a surprise by the time it matters.

An expense is not a payment

This distinction is the single most important modeling decision in the whole system, and it is worth stating before anything else. An expense is a record of a real-world cost that was incurred and who is responsible for which share of it — "dinner cost ₹900, and Asha, Ben, and Cara each consumed ₹300 of it." A payment (or settlement) is a record of money that actually moved between two people to reduce a debt — "Cara paid Asha ₹200 in cash." An expense changes *who owes what*; a payment changes *how much of that debt remains outstanding*. Conflating the two — for example, treating "Ben paid for the taxi" as if it were itself a settlement of some other debt — produces a system that cannot correctly explain anyone's balance. Every balance in this design is the net effect of applied expenses, adjusted by recorded repayments, and nothing else.

Who the system serves

The product serves everyday group-expense scenarios — roommates splitting monthly rent and utilities, a trip group splitting travel costs, a friend circle splitting a dinner bill — through a mobile or web client. A user creates or joins a group (Weekend Trip), records expenses as they happen, views running balances at any time, and eventually settles up, either by just telling the app "I paid Asha back" or, optionally, through an integrated payment provider that can confirm money actually moved. The system itself is the backend: an API layer, an expense/group service, a durable ledger of financial facts, a materialized view of current balances, and — optionally — a connection to an external payment rail. The mobile and web clients are consumers of this backend, not where financial truth lives.

GroupWeekend Trip · INRMembershipAsha · creatorMembershipBen · memberMembershipCara · memberExpensepayer + beneficiaries + split
Weekend Trip: three members, an expense, its splits, and the resulting net balances

What this design is explicitly not

This is not a payments processor — it does not move real money by itself; where money actually changes hands through a provider, this design treats that provider as an external, untrusted-until-confirmed system that it integrates with (Chapter 15), not something it reimplements. It is not a general ledger / double-entry accounting product for businesses, though it borrows the discipline of "every credit has a matching debit" because that discipline is exactly what keeps balances correct. It is not a budgeting or spend-analytics app — understanding where your personal money went is a different product from understanding what you owe a specific group of people. And it is not a social network — groups exist to scope shared expenses, not to be a general-purpose feed. Keeping these boundaries explicit keeps the rest of this design focused on the one job that actually matters: correctly tracking who-owes-whom, over time, without ever losing or double-counting a rupee.

Key takeaways
  • An expense (who incurred what cost) and a payment/settlement (money that actually moved to reduce a debt) are different concepts and must never be conflated in the data model.
  • Weekend Trip (Asha, Ben, Cara, INR) is the one running example reused across every chapter and every companion page in this module.
  • All money is tracked internally in integer minor units (paise) — never floating point.
  • This is an original design for the well-known interview problem, not a description of the real Splitwise product's proprietary architecture.
  • The system's core job is narrow and precise: apply expenses to balances correctly and once, and make every balance explainable from immutable history.
Chapter 02

Domain Fundamentals

Before any architecture makes sense, we need a precise vocabulary. We define each term against a single worked example — Vector A, the dinner expense — and reuse that exact vocabulary for the rest of this module.

Vector A: the dinner expense

Asha pays ₹900 for dinner on the Weekend Trip. It is split equally among all three members — Asha, Ben, and Cara. Each person's share of the actual cost is ₹900 ÷ 3 = ₹300. We now define every core concept against this one event.

Expense

A single recorded cost: who paid (one or more payers), the total amount, the currency, the group it belongs to, and how it is divided among beneficiaries. Vector A's expense: total ₹900, paid entirely by Asha, split equally across Asha/Ben/Cara.

Payer

A participant who contributed money toward the expense's total cost. Vector A has exactly one payer, Asha, for the full ₹900 — but the model supports multiple payers on a single expense (for example, Asha and Ben splitting the bill payment itself), which is why payer is tracked as its own role, separate from beneficiary.

Beneficiary

A participant who is responsible for a share of the expense's cost, regardless of whether they paid anything. In Vector A, all three members are beneficiaries, each responsible for ₹300. A payer and a beneficiary can be the same person (Asha both paid and consumed ₹300 of the dinner) — the two roles overlap but are tracked independently, because a payer who consumes none of the expense (paying forward for someone else entirely) is a valid, different case.

Split

The rule that divides an expense's total among its beneficiaries: equal, exact amounts, percentages, or weighted shares (Chapter 09 works all of these with numbers). A split's shares must always sum to exactly the expense total, in the expense's minor currency unit — this is a hard invariant, not a best-effort target.

Net balance

A single participant's overall position within a group (or, more precisely, within one currency scope of a group — Chapter 15): amount paid on behalf of others, minus allocated personal share, adjusted by any recorded repayments. After Vector A: Asha paid ₹900 and owes ₹300 of consumption, so her net is +₹600 (the group owes her). Ben and Cara each consumed ₹300 and paid nothing, so each is −₹300 (they owe the group).

Pairwise debt

A directional obligation between two specific people — "Ben owes Asha ₹300." Pairwise debts are one way of representing the same underlying facts that net balances represent; a group can have many pairwise debts that are all consistent with the same set of net balances (Chapter 10 covers this relationship in depth).

Group balance

The full set of every member's net balance within a group, at a point in time. For a closed, single-currency ledger, every group balance set has one unbreakable property: the sum of all net balances is always exactly zero — every rupee someone is owed is a rupee someone else owes.

Settlement

An explicit record that money moved (or is claimed to have moved) between two members to reduce an outstanding debt. A settlement is always a distinct kind of record from an expense (Chapter 01), and whether it is confirmed or merely asserted matters a great deal (Vector J, Chapter 15).

Simplify debts

A suggestion-generation step that converts a group's raw pairwise obligations into a smaller set of payments that achieve the same net balances, without necessarily preserving who originally owed whom (Chapter 10). It is always a suggestion — never an automatic transfer of funds.

Audit entry

An immutable record describing one fact that happened — an expense was created, an expense was edited, a repayment was recorded — kept forever, never overwritten, so that any balance can be explained by replaying history (Chapter 11).

Ledger projection

The currently-displayed balance for a participant or group is a derived, materialized view computed from the immutable ledger of applied expense and settlement effects — it is not itself the source of truth, and it can, in principle, be rebuilt from scratch by replaying history (Chapters 06 and 13).

Expense ₹900paid by AshaAshashare ₹300Benshare ₹300Carashare ₹300300 + 300 + 300 = 900 — shares always conserve the total exactly
Vector A: ₹900 dinner, equal split across three beneficiaries, ₹300 each

Working Vector A's net balances

MemberPaidConsumption shareNet balance
Asha₹900₹300+₹600
Ben₹0₹300−₹300
Cara₹0₹300−₹300
Vector A result. +600 − 300 − 300 = 0 — the group's net balances always sum to zero.

From these net balances, the system suggests two repayments: Ben → Asha ₹300 and Cara → Asha ₹300. With only one payer and an equal split, the suggested repayments are obvious by inspection — but as soon as there are multiple payers and multiple expenses (Vector B, next), the mapping from "net balances" to "who should pay whom" stops being obvious, which is exactly why simplification is treated as its own algorithmic step rather than something read directly off the net-balance table.

Key takeaways
  • Payer and beneficiary are distinct roles that can overlap on the same person, but are tracked separately.
  • Net balance = paid-on-behalf-of-others minus allocated personal share, adjusted by recorded repayments.
  • A group's net balances always sum to exactly zero within one closed currency ledger.
  • Pairwise debts and simplified repayment suggestions are derived views over net balances, not the primary stored representation.
Chapter 03

Functional Requirements

Functional requirements describe what the system must do for a group like Weekend Trip. We separate the essential MVP from extensions so the core design stays focused on what actually makes an expense-sharing app work correctly.

Group and membership management

A user must be able to create a group (Weekend Trip), invite other users to it, and have invited users join. A member must be able to leave a group, subject to a policy constraint: a member with a nonzero outstanding balance in that group cannot simply disappear without a trace — the system must decide (and clearly surface) whether leaving is blocked, or whether the member's historical obligations remain visible and settleable even after they've left the active member list. Groups support roles at a basic level — at minimum, distinguishing an ordinary member from someone with administrative rights over the group itself (renaming it, removing a member, closing it).

Expense recording and correction

The system must support creating an expense with one or more payers, a total amount, and a split among beneficiaries using any of four split types: equal, exact amounts, percentage, and shares/weights (all four are worked with real numbers in Chapter 09). It must support editing an existing expense (correcting the amount, the split, or the participants) and deleting an expense — both of which must be handled as versioned, auditable operations rather than silent in-place overwrites (Chapter 11), because an expense is a financial fact, and financial facts are corrected, not erased.

Balances, simplification, and settlement

A member must be able to view current balances — their own net position, their pairwise position with any other specific member, and the group's full balance sheet. The system must offer debt simplification: given the group's current net balances, suggest a minimal-ish set of repayments that would bring everyone to zero, while being explicit that this is a suggestion a user can act on or ignore (Chapter 10). It must support recording a settlement — a member marking a repayment as having happened, optionally backed by a real payment-provider confirmation (Chapter 15).

History, notifications, and discovery

The system must expose a full, chronological activity history for a group (every expense created, edited, deleted, and every settlement recorded), support notifications when a group member adds an expense or records a payment that affects another member, and support basic search over a group's expense history (by description, date range, or participant). An export of a group's expense and settlement history (for example, to a spreadsheet-friendly format) is a reasonable and common extension.

Extensions beyond the core

Receipt attachments (an image or file linked to an expense, for reference, not for OCR-based auto-splitting), recurring expenses (automatically creating a new expense on a schedule, like monthly rent), and payment-provider integration for actual money movement (Chapter 15) are all real, valuable capabilities — but they sit outside the essential MVP and are explained as explicit extensions rather than baked into the core design as if every deployment needs them.

CapabilityEssential MVPAdvanced extension
Group membershipCreate, invite, join, leave, basic admin roleSub-groups, group-level default currency policy, bulk invites
SplitsEqual, exact, percentage, sharesItemized receipt-line splitting, tax/tip proportional splitting
BalancesNet balance, pairwise balance, group balance sheetHistorical balance-over-time charts
SimplificationGreedy minimal repayment suggestionUser-constrained simplification (e.g. "never suggest I pay X")
SettlementManual "mark as settled"Payment-provider-confirmed settlement, partial settlement scheduling
History & discoveryChronological activity feed, basic searchFull-text search, CSV/PDF export, spend analytics
What this design treats as essential for a credible MVP vs. an explained-but-optional extension.
Key takeaways
  • Functional scope splits into group/membership, expense CRUD with versioned correction, balances and simplification, settlement, and history/discovery.
  • All four split types — equal, exact, percentage, shares — are first-class, MVP-level requirements, not extensions.
  • Editing and deleting an expense are auditable, versioned operations, never silent overwrites.
  • Receipts, recurring expenses, and payment-provider integration are valuable but explicitly optional extensions.
Chapter 04

Non-Functional Requirements

For a shared-expense system, the sharpest non-functional requirements are about financial correctness and precisely what "up to date" means — not raw throughput. A leaderboard can tolerate a stale rank for a second; a balance that is silently wrong erodes trust in a way that is very hard to recover from.

Three separate moments, stated explicitly

This design distinguishes three separate moments whenever it talks about "when does an expense take effect," and never collapses them into a single vague promise. Write acknowledgment: the moment the client that submitted an expense is told it was accepted — the client can trust this as "recorded," even if every read path hasn't caught up yet. Durable commit: the moment the expense and its ledger effects are safely and atomically persisted in the authoritative transactional store — this is the point past which the fact can never be lost, only corrected by a new, explicit, audited action. Read-model visibility: the moment a balance query (the group balance sheet, a dashboard) actually reflects the new expense — in a simple single-database design this can be the same instant as durable commit, but once a separate materialized balance projection exists (Chapter 12), this can legitimately lag commit by a small, bounded amount, and that lag must be explicit, never silently hidden.

Financial correctness

Every participant's net balance must, at all times, be exactly explainable as a function of applied expense and settlement history — never off by a rounding error, never double-counted, never silently dropped. This is the single non-negotiable requirement everything else is secondary to.

Authorization, not just authentication

A member being logged in is not sufficient to let them read or modify an expense — the system must check that the acting user is actually a member of the specific group the expense belongs to. Being authenticated answers "who are you"; authorization answers "are you allowed to touch this specific object," and the two checks are not interchangeable.

Durability

Once an expense is acknowledged as committed, it must survive any individual component's crash. An illustrative target for this design: zero acknowledged, durably-committed expenses ever lost (RPO of zero for committed financial facts).

Idempotency

A client retry of the same expense-creation request (Vector F) must never create a second expense, a second ledger effect, or a second activity entry — the system must recognize the retry and return the original outcome, not a new one.

Consistency of the authoritative ledger

Within the transactional store, an expense's row, its split rows, and its ledger effects commit together, atomically, or not at all — there is never a state where an expense exists but its splits don't, or vice versa.

Privacy and least exposure

A member of one group should never be able to see or infer the expenses or balances of a group they do not belong to, and personally identifying details (linked receipts, in particular) are exposed only to group members with a legitimate reason to see them.

Auditability

Any balance, at any time, must be traceable back to the specific sequence of expense and settlement events that produced it — this is both a user-trust requirement ("why do I owe this?") and an operational one (diagnosing a reported discrepancy).

Mobile availability and offline tolerance

A mobile client should be able to queue an expense entry while briefly offline and sync it once connectivity returns, without creating a duplicate if the sync retries — which is exactly why idempotent expense creation (Vector F) is a hard requirement, not a nice-to-have, for a product used on phones with unreliable connectivity.

Predictable read latency

Viewing a group's balance sheet or a user's dashboard should be fast and bounded, even as a group's expense history grows into the hundreds or thousands of entries — which motivates maintaining a precomputed balance projection (Chapter 06, Chapter 13) rather than aggregating full history on every read.

Recoverability

If a materialized balance projection is ever found to be wrong or corrupted, it must be possible to rebuild it from the authoritative ledger and event history — the projection is disposable; the ledger is not.

Observability

Expense-write failures, idempotency conflicts, version conflicts, and settlement-webhook anomalies must be observable and alertable — silent failure of a financial write path is one of the worst possible failure modes for this kind of product.

NFRIllustrative targetWhy it's this shape
Expense write acknowledgment (p99)< 300 ms (illustrative)A user adding an expense mid-conversation needs a fast, trustworthy confirmation
Durable commitSame transaction as write ack in the MVP designA monolith's single transactional commit makes ack and durable commit coincide (Chapter 08)
Read-model visibility lag (p99, once projections exist)~1-2 s (illustrative)Bounded, explicit staleness is acceptable; unbounded or hidden staleness is not
Idempotent write guaranteeExactly-once effect per scoped idempotency keyAt-least-once delivery from retries/offline sync is the realistic client behavior
Durability (RPO) of committed expensesZero committed expenses lostAn acknowledged expense is a promise to the user, not a best-effort note
All figures here are explicit design assumptions for this walkthrough, not measured or claimed production benchmarks.
Chapter 05

Scale Estimation

Every number in this chapter is a stated, illustrative design assumption for this walkthrough — not a measured benchmark of any real product. The goal is to demonstrate the estimation process and the architectural conclusions it motivates.

Assumed workload

Assume 10 million registered users, 1 million daily active users (DAU), 5 million expense writes/day system-wide, 20 million balance reads/day, an average group size of 5, and a peak multiplier of 10 over the average rate (to account for evening/weekend concentration of trip and dinner expenses, and payday-driven settlement spikes).

Write throughput

5,000,000 expense writes/day ÷ 86,400 seconds/day ≈ 58 writes/sec average. Applying the 10× peak multiplier: 58 × 10 ≈ 580 writes/sec at peak.

Read throughput

20,000,000 balance reads/day ÷ 86,400 seconds/day ≈ 231 reads/sec average. At the same 10× peak multiplier: 231 × 10 ≈ 2,315 reads/sec at peak. The read:write ratio here (roughly 4:1) reflects a realistic usage pattern — people check "who owes what" far more often than they add a new expense, which is exactly the shape of workload that benefits from a precomputed, fast-to-read balance projection (Chapter 06) rather than recomputing balances from full history on every check.

Expense and split row growth

Each expense produces, on average, one Expense row plus roughly average group size = 5 ExpenseSplit rows (one per beneficiary). At 5,000,000 expenses/day: 5,000,000 expense rows/day, and 5,000,000 × 5 = 25,000,000 split rows/day. Over a year (365 days): ~1.83 billion expense rows and ~9.1 billion split rows accumulated, before any archival policy. This is a meaningful but entirely manageable volume for a sharded relational store with time-based partitioning and an archival policy for old, closed groups — not, by itself, a reason to abandon a relational ledger.

Ledger/event-log storage per year

Assume each durable ledger event (one row per participant-effect of an expense or settlement — a credit or a debit) averages 200 bytes (event ID, expense ID, group ID, participant ID, currency, signed amount in minor units, event type, timestamp, schema version). Each expense produces roughly average group size = 5 ledger-effect rows (one per participant touched). At 5,000,000 expenses/day: 5,000,000 × 5 = 25,000,000 ledger events/day. 25,000,000 × 200 bytes ≈ 5 GB/day, or roughly 1.8 TB/year of raw ledger-event volume before compaction. This is a modest, entirely manageable volume for a durable, append-only event store with a sensible retention and archival policy for seasons/groups that have gone inactive.

Read fan-out and notification rate

With an average group size of 5, a single expense write is relevant to up to 4 other members besides the one who recorded it. At 5,000,000 expense writes/day, that is up to 5,000,000 × 4 = 20,000,000 notification-worthy events/day, or 20,000,000 ÷ 86,400 ≈ 231 notifications/sec average, and at the 10× peak multiplier, ~2,315/sec at peak. This is the same order of magnitude as the read-query rate, and it is why notification fan-out (Chapter 16) is treated as an asynchronous, best-effort-delivery concern decoupled from the synchronous expense-write path — a slow push-notification provider must never add latency to the actual expense commit.

Hot-group skew

10,000,000 users ÷ average group size 5 implies on the order of several million groups system-wide, but — exactly as with any sharded, skewed social system — activity is never remotely even across them. A small number of active groups (a trip happening *this weekend*, a household's shared rent group during the first few days of the month) absorb a disproportionate share of both writes and reads, while the long tail of dormant groups (an old trip from two years ago) sees essentially zero traffic. Because a group's balance projection is naturally partitioned by group ID, a hot group's peak write/read rate, not the system-wide average, is what that group's projection-update and read path must sustain — this motivates per-group partitioning and caching decisions in Chapters 12 and 13, not a single global average-throughput target.

Storage growth over time

Combining expense rows (~1.83B/year), split rows (~9.1B/year), and ledger events (~1.8 TB/year raw), the dominant storage driver is the ledger-event log, not the expense/split tables themselves (expense and split rows are each well under a few hundred bytes and dominated by row-count rather than payload size). This is exactly the pattern that justifies tiering: keep recent and active-group data in fast, fully-indexed storage, and move closed or long-dormant groups' full history to cheaper, colder storage while keeping their final balance snapshot immediately available (Chapter 13).

Key takeaways
  • All figures here (10M users, 1M DAU, 5M writes/day, 20M reads/day, ~580 writes/sec peak, ~2,315 reads/sec peak, ~1.8 TB/year ledger volume) are illustrative design assumptions, not measured benchmarks.
  • The 10x peak multiplier is an explicit, stated assumption for burstiness — not a universal constant.
  • Ledger-event volume, driven by (expenses × average group size), dominates raw storage growth more than the expense/split tables themselves.
  • Hot-group skew means per-group peak throughput, not the system-wide average, is the real capacity constraint.
Chapter 06

Data Model and Invariants

The data model has to cleanly separate three things: the immutable record of what actually happened (expenses, splits, ledger events), the derived materialized view that balance reads actually hit (the projection), and the supporting tables that make writes safe and idempotent. Confusing these is the most common design mistake in this space.

User

user_id, display_name, email, default_currency. A user's identity is independent of any specific group.

Group

group_id, name (Weekend Trip), default_currency (INR), created_by, created_at, status (active/closed). A group is the scope that every expense and balance belongs to.

Membership

group_id + user_id (composite key — exactly one membership row per user per group), role (member / admin), joined_at, left_at (nullable — a member who has left retains their historical membership row for audit purposes rather than being deleted).

Expense

expense_id, group_id, description, total_amount_minor (integer, e.g. 90000 for ₹900.00), currency (INR), split_type (equal/exact/percentage/shares), created_by, created_at, version (integer, incremented on every edit — Vector G), status (active/reversed), reversed_by_expense_id (nullable, set when this expense has been reversed — Chapter 11).

ExpensePayer

expense_id + user_id (composite key), paid_amount_minor. One or more rows per expense; the sum of all paid_amount_minor for an expense must equal its total_amount_minor exactly.

ExpenseSplit

expense_id + user_id (composite key), share_amount_minor (the beneficiary's exact allocated share, always an integer). The sum of all share_amount_minor rows for an expense must equal total_amount_minor exactly — this is validated at write time, not assumed (Chapter 07).

Settlement

settlement_id, group_id, from_user_id, to_user_id, amount_minor, currency, method (manual/provider), status (pending/confirmed/failed), provider_reference (nullable), created_at, confirmed_at (nullable — only set by an authoritative confirmation event, Vector J).

LedgerEvent

The immutable, append-only source of truth for every balance-affecting fact: event_id, group_id, user_id, currency, amount_minor (signed — positive is a credit toward that user, negative is a debit), source_type (expense/expense_reversal/settlement), source_id (the expense or settlement that produced this event), sequence_number (monotonic per group, for ordered replay), created_at. For any single expense, the sum of its ledger events' amount_minor across all affected users is exactly zero.

BalanceProjection

The materialized, fast-to-read current state: group_id + user_id + currency (composite key), net_balance_minor, last_applied_sequence_number (the highest LedgerEvent.sequence_number reflected here — this is what makes projection staleness explicit and checkable), updated_at. This table is disposable and rebuildable from LedgerEvent history; it is never the source of truth.

Invitation

invitation_id, group_id, invited_by, invited_email_or_user_id, status (pending/accepted/expired/revoked), created_at, expires_at.

IdempotencyRecord

idempotency_key + scope (e.g. the group and endpoint) as a composite unique key, request_fingerprint (a hash of the semantically relevant request body, used to detect a conflicting reuse of the same key), result_expense_id (or equivalent), created_at. This is the mechanism behind Vector F — see Chapter 14.

OutboxEvent

outbox_id, aggregate_type (expense/settlement), aggregate_id, event_type, payload, created_at, published_at (nullable). Written in the same transaction as the expense/ledger commit, then relayed to the event bus asynchronously — this is the transactional outbox pattern, detailed in Chapter 12.

CurrencyRateSnapshot

rate_id, from_currency, to_currency, rate, as_of, source. Used only when an explicit conversion event occurs (Chapter 15) — never used implicitly to compare or net balances held in different currencies.

AuditEntry

audit_id, group_id, actor_user_id, action_type (expense_created/expense_edited/expense_reversed/settlement_recorded/member_added/...), target_id, before_snapshot (nullable), after_snapshot, created_at. Append-only; never edited or deleted, even when the thing it describes is later reversed.

Sample Expense and ExpenseSplit records — Vector A

jsonVector A's expense. 90000 paise = ₹900.00 — amounts are always stored as integer minor units.
{
  "expense_id": "exp-1001",
  "group_id": "grp-weekend-trip",
  "description": "Dinner",
  "total_amount_minor": 90000,
  "currency": "INR",
  "split_type": "equal",
  "created_by": "user-asha",
  "version": 1,
  "status": "active"
}
jsonExpensePayer rows for Vector A — a single payer, Asha, for the full amount.
[
  { "expense_id": "exp-1001", "user_id": "user-asha", "paid_amount_minor": 90000 }
]
jsonExpenseSplit rows for Vector A. 30000 + 30000 + 30000 = 90000 — shares sum exactly to the total, with no remainder to allocate.
[
  { "expense_id": "exp-1001", "user_id": "user-asha",  "share_amount_minor": 30000 },
  { "expense_id": "exp-1001", "user_id": "user-ben",   "share_amount_minor": 30000 },
  { "expense_id": "exp-1001", "user_id": "user-cara",  "share_amount_minor": 30000 }
]
jsonLedgerEvent rows for Vector A. 60000 − 30000 − 30000 = 0 — the per-expense ledger effect always nets to zero across all affected users.
[
  { "event_id": "ev-5001", "group_id": "grp-weekend-trip", "user_id": "user-asha", "amount_minor": 60000,  "source_type": "expense", "source_id": "exp-1001", "sequence_number": 1 },
  { "event_id": "ev-5002", "group_id": "grp-weekend-trip", "user_id": "user-ben",  "amount_minor": -30000, "source_type": "expense", "source_id": "exp-1001", "sequence_number": 2 },
  { "event_id": "ev-5003", "group_id": "grp-weekend-trip", "user_id": "user-cara", "amount_minor": -30000, "source_type": "expense", "source_id": "exp-1001", "sequence_number": 3 }
]
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
Vector A's ledger effect: Asha credited ₹600, Ben and Cara each debited ₹300, net zero
UserGroupMembershipExpenseversion, group_idExpensePayerExpenseSplitLedgerEventimmutableSettlementIdempotencyRecord
Entity-relationship overview: User, Group, Membership, Expense, ExpensePayer, ExpenseSplit, Settlement, LedgerEvent, BalanceProjection

Keys, uniqueness, and ownership

(group_id, user_id) is unique for Membership and for BalanceProjection per currency — exactly one current balance row per user per group per currency, never more. expense_id is globally unique and is the foreign key every ExpensePayer, ExpenseSplit, and derived LedgerEvent row references, so that an expense's full effect can always be reconstructed or reversed as one unit. LedgerEvent.sequence_number is monotonic per group (not globally), which is what lets a group's balance projection track exactly how caught-up it is independent of any other group's activity. Every money-bearing table uses an indexed foreign key back to its owning group_id, and every read query is required to filter by a group the requesting user is actually a member of (Chapter 16) — ownership is enforced at the query layer, not assumed from the existence of a valid session token.

Source-of-truth tables vs. materialized read models

Expense, ExpensePayer, ExpenseSplit, Settlement, and LedgerEvent are the source of truth — they are written once (or, for Expense, versioned forward) inside a single atomic transaction, and LedgerEvent rows are never edited or deleted after being written. BalanceProjection is a materialized read model — it exists purely to make balance reads fast, it is always computable (in principle) by replaying LedgerEvent history for a group from sequence 1, and if it and the ledger ever disagree, the ledger wins, by construction, and the projection is corrected to match it (Chapter 13).

Chapter 07

API Design

The API separates group/membership management, expense writes, balance and activity reads, and settlement — each with its own consistency and idempotency expectations. Every illustrative endpoint below uses Weekend Trip and the canonical vectors.

Group and invitation management

httpCreating the Weekend Trip group. The creator becomes its first admin member implicitly.
POST /v1/groups
Authorization: Bearer {user_token}

{ "name": "Weekend Trip", "default_currency": "INR" }

Response 201 Created:
{ "group_id": "grp-weekend-trip", "name": "Weekend Trip", "default_currency": "INR" }
httpInvite and accept flow. Only a member of the group (or someone with an un-expired invitation) can take these actions.
POST /v1/groups/grp-weekend-trip/invitations
Authorization: Bearer {user_token}

{ "invited_user_id": "user-cara" }

Response 201 Created:
{ "invitation_id": "inv-301", "status": "pending" }

POST /v1/invitations/inv-301/accept
Authorization: Bearer {user_token}

Response 200 OK:
{ "invitation_id": "inv-301", "status": "accepted", "group_id": "grp-weekend-trip" }

Creating an expense (Vector A), with idempotency

httpVector A as an API call. The server computes the equal split server-side and returns it — the client never computes and submits shares itself for an equal split.
POST /v1/groups/grp-weekend-trip/expenses
Authorization: Bearer {user_token}
Idempotency-Key: client-exp-7781

{
  "description": "Dinner",
  "total_amount_minor": 90000,
  "currency": "INR",
  "split_type": "equal",
  "payers": [ { "user_id": "user-asha", "paid_amount_minor": 90000 } ],
  "beneficiaries": ["user-asha", "user-ben", "user-cara"]
}

Response 201 Created:
{
  "expense_id": "exp-1001",
  "version": 1,
  "splits": [
    { "user_id": "user-asha", "share_amount_minor": 30000 },
    { "user_id": "user-ben",  "share_amount_minor": 30000 },
    { "user_id": "user-cara", "share_amount_minor": 30000 }
  ]
}

Validating that splits sum to the total — shown explicitly

httpAn exact split that is missing ₹50 (5,000 paise) is rejected before anything is committed — this check runs for every split type, every time, inside the same transaction as the write.
POST /v1/groups/grp-weekend-trip/expenses
Authorization: Bearer {user_token}
Idempotency-Key: client-exp-7782

{
  "description": "Taxi",
  "total_amount_minor": 60000,
  "currency": "INR",
  "split_type": "exact",
  "payers": [ { "user_id": "user-ben", "paid_amount_minor": 60000 } ],
  "splits": [
    { "user_id": "user-asha", "share_amount_minor": 10000 },
    { "user_id": "user-ben",  "share_amount_minor": 20000 },
    { "user_id": "user-cara", "share_amount_minor": 25000 }
  ]
}

Response 422 Unprocessable Entity:
{
  "error": "SPLIT_SUM_MISMATCH",
  "message": "Exact splits sum to 55000 but total_amount_minor is 60000.",
  "expected_total_minor": 60000,
  "actual_split_sum_minor": 55000
}

Editing an expense with optimistic concurrency (Vector G)

httpVector G: two simultaneous edits to version 4 cannot both silently succeed. The `If-Match` header carries the expected version; a mismatch returns an explicit, retryable conflict rather than overwriting the other edit.
PUT /v1/groups/grp-weekend-trip/expenses/exp-1001
Authorization: Bearer {user_token}
If-Match: 4

{ "total_amount_minor": 95000, "split_type": "equal" }

Response 409 Conflict:
{
  "error": "VERSION_CONFLICT",
  "message": "Expense exp-1001 is at version 5, not 4. Reload and retry.",
  "current_version": 5
}

Balance and activity reads

httpGroup balances after Vector B (dinner + taxi). `as_of_sequence` makes projection freshness explicit rather than implicit. Activity pagination uses an opaque cursor, not a raw offset.
GET /v1/groups/grp-weekend-trip/balances
Authorization: Bearer {user_token}

Response 200 OK:
{
  "group_id": "grp-weekend-trip",
  "currency": "INR",
  "as_of_sequence": 6,
  "balances": [
    { "user_id": "user-asha", "net_balance_minor": 50000 },
    { "user_id": "user-ben",  "net_balance_minor": 10000 },
    { "user_id": "user-cara", "net_balance_minor": -60000 }
  ]
}

GET /v1/groups/grp-weekend-trip/activity?limit=20&cursor=
Authorization: Bearer {user_token}

Response 200 OK:
{
  "entries": [
    { "type": "expense_created", "expense_id": "exp-1002", "at": "2026-10-13T18:40:00Z" },
    { "type": "expense_created", "expense_id": "exp-1001", "at": "2026-10-13T18:00:00Z" }
  ],
  "next_cursor": null
}

Simplify debts

httpVector B's simplified repayments: Cara → Asha ₹500, Cara → Ben ₹100. A suggestion only — nothing is transferred by this call.
GET /v1/groups/grp-weekend-trip/simplify
Authorization: Bearer {user_token}

Response 200 OK:
{
  "as_of_sequence": 6,
  "suggested_repayments": [
    { "from_user_id": "user-cara", "to_user_id": "user-asha", "amount_minor": 50000 },
    { "from_user_id": "user-cara", "to_user_id": "user-ben",  "amount_minor": 10000 }
  ]
}

Recording a settlement (Vector C)

httpVector C: Cara repays ₹200 to Asha, recorded manually. A manual settlement is confirmed immediately as a user assertion (Chapter 15) — it carries no implicit claim that a payment provider verified it.
POST /v1/groups/grp-weekend-trip/settlements
Authorization: Bearer {user_token}
Idempotency-Key: client-settle-220

{
  "from_user_id": "user-cara",
  "to_user_id": "user-asha",
  "amount_minor": 20000,
  "currency": "INR",
  "method": "manual"
}

Response 201 Created:
{ "settlement_id": "set-77", "status": "confirmed" }

Recurring expense (extension) and notifications

httpRecurring expenses and notifications are explicit extensions beyond the MVP core.
POST /v1/groups/grp-weekend-trip/recurring-expenses
Authorization: Bearer {user_token}

{
  "description": "Monthly rent",
  "total_amount_minor": 1500000,
  "split_type": "equal",
  "schedule": "monthly",
  "day_of_month": 1
}

Response 201 Created:
{ "recurring_expense_id": "rec-9", "next_run_at": "2026-11-01T00:00:00Z" }

GET /v1/users/user-ben/notifications?unread_only=true
Authorization: Bearer {user_token}

Response 200 OK:
{ "notifications": [ { "type": "expense_added", "group_id": "grp-weekend-trip", "expense_id": "exp-1002" } ] }

Error semantics, pagination, and idempotency headers

Every mutating request (expense create/edit, settlement create) carries an Idempotency-Key scoped to the acting user and the specific group/endpoint; a retried request with the same key and the same payload returns the original result, and a retried request with the same key but a different payload returns an explicit IDEMPOTENCY_KEY_CONFLICT rather than silently applying either version (Vector F, Chapter 14). Edits use an explicit If-Match version field rather than a timestamp-based check, since timestamps can collide or skew across clients (Vector G). List endpoints (activity, group listing) use opaque cursors, never raw numeric offsets, because a raw offset is not stable across concurrent writes. Distinct error codes are used for distinct failure categories — SPLIT_SUM_MISMATCH, VERSION_CONFLICT, IDEMPOTENCY_KEY_CONFLICT, FORBIDDEN_NOT_A_MEMBER, CURRENCY_MISMATCH — because a caller needs to know *which* kind of failure happened to react correctly (retry vs. reload vs. surface to the user).

Chapter 08

Simple Correct Monolith

The smallest correct version of this system is one service and one transactional relational database. It is a genuinely good starting point, and understanding exactly where it starts to strain is more valuable than skipping straight to a distributed design.

ClientExpense Serviceone monolithRelational DBexpenses · splits · ledger
One API service, one transactional database holding Expense, ExpenseSplit, LedgerEvent, and BalanceProjection tables

In this design, a single backend service exposes the API from Chapter 07, and a single relational database holds every table from Chapter 06. When a client creates Vector A's dinner expense, the service, inside one database transaction, inserts the Expense row, the ExpensePayer row(s), the ExpenseSplit rows, the corresponding LedgerEvent rows (computed server-side from the splits), and updates the three affected BalanceProjection rows directly — all committed together, atomically, or not at all. There is no network hop, no message queue, and no eventual consistency anywhere in this picture: by the instant the transaction commits, the write is acknowledged, durably committed, and visible to the very next balance read.

Balances by direct aggregation, as an alternative read path

Even in this simplest design, a BalanceProjection table (updated in the same transaction as the ledger write) is preferable to computing SUM(amount_minor) GROUP BY user_id over LedgerEvent on every single balance read, for the same reason discussed for leaderboards and order books elsewhere in this series: a value that changes relatively rarely but is read far more often (Chapter 05's 4:1 read:write ratio) should be maintained incrementally at write time, not recomputed from scratch at read time. The monolith can absolutely do both — keep the aggregation query available as a slower, always-correct fallback/reconciliation path (used to detect projection drift), while serving ordinary reads from the maintained BalanceProjection table.

Why this is a legitimately good MVP

For a comparatively modest write rate and a modest number of concurrently-active groups, this design is fully correct: atomicity is handled by the database transaction, idempotency is handled by a unique constraint on (idempotency_key, scope) in the IdempotencyRecord table checked inside the same transaction, and authorization is a straightforward membership-table lookup. There is exactly one place money math happens, exactly one place that can disagree with itself, and no distributed-consistency puzzle to solve. Many real products never need more than a well-indexed, well-partitioned version of exactly this.

Where it starts to strain

Three pressures eventually show up. First, coupling of the write path to every read-side concern: as notifications, search indexing, and analytics are added, cramming all of it into the same transaction that commits the expense either slows down every expense write or risks a partial failure (the expense commits, but the search index update inside the same transaction fails and rolls back the whole thing). Second, a single database's read capacity eventually becomes the bottleneck once balance-read volume (Chapter 05's 2,315 reads/sec peak, concentrated on hot groups) grows well beyond what a single primary's query planner comfortably serves alongside write traffic. Third, cross-service evolution: once settlement-webhook processing, notification fan-out, and multi-currency conversion are real, independently-scaled concerns, forcing them all through one service's deploy cycle and one database's connection pool becomes an operational liability, not just a performance one. None of these are correctness problems with the monolith — they are scaling and operational-boundary problems, and they motivate the evolved architecture in Chapter 12, not a rewrite of the core data model from Chapter 06.

Chapter 09

Split Calculations and Ledger Math

This chapter works the exact arithmetic for equal, percentage, and shares splits, and shows how a repayment adjusts an already-computed balance. Every number here is exact, integer, and reproducible.

Payer-credit vs. consumption-debit, restated as arithmetic

For any expense, every participant's ledger effect is: (amount this participant paid) − (this participant's allocated share). Summed across all participants on one expense, this is always exactly total_paid − total_allocated_shares, and because the system enforces total_paid == total_amount and sum(shares) == total_amount (Chapter 07's validation), that difference is always exactly zero. This is the concrete mechanism behind the conservation invariant from Chapter 02 — it is not a coincidence or an approximation, it is arithmetic forced by the two sum constraints the write path enforces on every single expense.

Vector A, worked again as pure arithmetic

Total ₹900 (90,000 paise), equal split over 3 people: 90,000 ÷ 3 = 30,000 paise exactly, no remainder. Asha: paid 90,000, share 30,000, effect +60,000 (+₹600). Ben: paid 0, share 30,000, effect −30,000 (−₹300). Cara: paid 0, share 30,000, effect −30,000 (−₹300). Sum: 60,000 − 30,000 − 30,000 = 0.

Vector D: percentage split and the rounding remainder

₹100.00 (10,000 paise) paid by Asha, split 33.33% / 33.33% / 33.34%. Naively computing 33.33% of 10,000 gives 3,333.0 paise for the first two shares and 3,334.0 for the third — in this particular case the percentages were deliberately chosen so the integer math comes out exact: 3,333 + 3,333 + 3,334 = 10,000. In the general case, though, percentages will not divide evenly into whole paise, and some remainder will be left over after taking the integer floor of each raw percentage share. The rule this design uses is a documented, deterministic allocation rule: compute each participant's floor share first, then distribute the leftover 1-paise (or N-paise) remainder across participants in a fixed, stable order — for example, ascending by user_id — never arbitrarily and never using floating-point rounding. For this exact vector, that rule produces no remainder to distribute at all, which is exactly why 3,333 / 3,333 / 3,334 is the correct, exact result: Asha +₹66.67 (6,667 paise... see note below), Ben −33.33, Cara −33.34, net of paid vs. share for Asha specifically being 10,000 − 3,333 = 6,667.

MemberShare %Share (paise)Paid (paise)Net effect (paise)
Asha33.33%3,33310,000+6,667
Ben33.33%3,3330−3,333
Cara33.34%3,3340−3,334
Vector D. 3,333 + 3,333 + 3,334 = 10,000 exactly — the deterministic allocation rule assigns the extra 1 paise to the last member in this split, never to a client-chosen or arbitrary party.

Vector E: shares/weights split

₹1,000 (100,000 paise) paid by Ben, weights A:B:C = 1:2:2 (total weight 5). Each unit of weight is worth 100,000 ÷ 5 = 20,000 paise. Asha: 1 × 20,000 = 20,000 paise (₹200). Ben: 2 × 20,000 = 40,000 paise (₹400). Cara: 2 × 20,000 = 40,000 paise (₹400). Check: 20,000 + 40,000 + 40,000 = 100,000 exactly — weighted splits divide evenly here because the total weight (5) divides the total amount (100,000) with no remainder; when it doesn't divide evenly, the same documented floor-plus-deterministic-remainder rule from Vector D applies.

MemberWeightShare (paise)Paid (paise)Net effect (paise)
Asha120,0000−20,000
Ben240,000100,000+60,000
Cara240,0000−40,000
Vector E. −20,000 + 60,000 − 40,000 = 0. Net: Asha −₹200, Ben +₹600, Cara −₹400.

Group net calculation across multiple expenses: Vector B

From Vector A's state, Ben pays ₹600 (60,000 paise) for a taxi, with exact shares Asha ₹100 / Ben ₹200 / Cara ₹300 (10,000 / 20,000 / 30,000 paise — summing exactly to 60,000). Taxi-only effect: Asha paid 0, share 10,000, effect −10,000. Ben paid 60,000, share 20,000, effect +40,000. Cara paid 0, share 30,000, effect −30,000. A participant's group net balance is simply the sum of that participant's ledger effects across every expense in the group — there is no special combination logic beyond addition, which is exactly why maintaining BalanceProjection as a running sum updated per LedgerEvent is correct and sufficient.

MemberDinner effectTaxi effectCombined group net
Asha+60,000−10,000+50,000 (+₹500)
Ben−30,000+40,000+10,000 (+₹100)
Cara−30,000−30,000−60,000 (−₹600)
Vector B combined state. 50,000 + 10,000 − 60,000 = 0 — group-wide conservation holds across any number of expenses, not just one.
ClientGatewayauthExpense Svcvalidate splitDBexpense+splits+ledgerPOST expenseBEGIN...COMMITValidate: shares sum to total → one atomic transaction → 202/201 ack → outbox event enqueued
Sequence: dinner (Vector A) then taxi (Vector B) applied in order, each producing its own zero-sum ledger effect, combining into the group net

How a repayment adjusts balances: Vector C

From Vector B's combined state, Cara repays ₹200 (20,000 paise) to Asha. A settlement produces its own ledger events, symmetric to an expense's: the payer-of-the-debt (Cara) is credited (her debt shrinks, so her net balance moves toward zero — a credit of +20,000 relative to her current −60,000), and the recipient (Asha) is debited by the same amount relative to her current +50,000 (because part of what she was owed has now been received). Cara: −60,000 + 20,000 = −40,000. Asha: +50,000 − 20,000 = +30,000. Ben is untouched: +10,000.

MemberBefore repaymentSettlement effectAfter repayment
Asha+50,000−20,000+30,000 (+₹300)
Ben+10,0000+10,000 (+₹100)
Cara−60,000+20,000−40,000 (−₹400)
Vector C. 30,000 + 10,000 − 40,000 = 0 — a confirmed settlement is just another zero-sum ledger effect, same mechanism as an expense.
Key takeaways
  • Every ledger effect is (paid − allocated share), and the write-path's sum constraints force every expense's effects to net to exactly zero.
  • Rounding remainders (Vector D) are allocated by a documented, deterministic rule — never arbitrarily, never with floating point.
  • A group's net balance for a participant is simply the running sum of that participant's ledger effects across every expense and settlement.
  • A confirmed settlement (Vector C) produces a symmetric, zero-sum ledger effect, using the exact same mechanism as an expense.
Chapter 10

Debt Simplification

Raw pairwise obligations and simplified repayments are two different, equally valid representations of the same net balances. This chapter explains the relationship between them using Vector B, and is deliberately light on algorithmic pseudocode — the Architecture Deep Dive owns that.

Raw pairwise debts after Vector A and Vector B

If the system tracked every individual expense's pairwise obligations without ever simplifying, Vector A (dinner) produces two raw debts — Ben owes Asha ₹300, Cara owes Asha ₹300 — and Vector B (taxi) produces two more: Asha owes Ben ₹100 (her ₹100 share of a taxi Ben paid for), Cara owes Ben ₹300 (her share of the same taxi). That is four raw pairwise debts outstanding after two expenses, even though only three people are involved.

FromToAmountSource expense
BenAsha₹300Dinner
CaraAsha₹300Dinner
AshaBen₹100Taxi
CaraBen₹300Taxi
Four raw pairwise debts from two expenses, before simplification.

Collapsing to net positions, then to two simplified debts

The four raw debts above are exactly consistent with the combined net balances already computed in Chapter 09: Asha +₹500, Ben +₹100, Cara −₹600 (net ₹300 Ben owed Asha, minus ₹100 Asha owed Ben, is already a net +₹200 for Asha from that pair alone — working through all four raw debts nets out to the same ₹500/₹100/−₹600 split). Debt simplification takes the net positions directly — not the raw pairwise history — and produces the smallest convenient set of payments that bring every net balance to zero: Cara → Asha ₹500 and Cara → Ben ₹100. Four raw debts become two simplified ones.

RAW PAIRWISE (4 debts)AshaBenCara100300300SIMPLIFIED (2 debts)AshaBenCara500100
Vector B: four raw pairwise debts collapse to two simplified repayments with the same net effect

The greedy creditor/debtor matching intuition

A simple, widely-used approach is greedy matching: separate participants into creditors (positive net balance — owed money) and debtors (negative net balance — owe money), repeatedly take the largest-magnitude creditor and the largest-magnitude debtor, settle the smaller of the two amounts between them (which zeroes out at least one of the two), and repeat until every balance is zero. Applied here: Cara is the only debtor (−₹600), Asha the largest creditor (+₹500). Settle min(600, 500) = ₹500 between them: Cara → Asha ₹500, leaving Cara at −₹100 and Asha at ₹0. Now Ben (+₹100) is the only remaining creditor and Cara (−₹100) the only remaining debtor: Cara → Ben ₹100, both reach zero. Two payments — matching the result above.

Greedy is not guaranteed to be globally minimal

It is worth stating plainly: the greedy largest-creditor/largest-debtor approach produces a *small* number of transactions, but it is not guaranteed to produce the fewest possible transactions in every case. The intuition for why: greedy makes a locally-optimal choice at each step (fully settling whichever pairing clears the largest amount right now) without looking ahead to whether a different, smaller-looking pairing now might enable a bigger simplification later — in rare configurations with several creditors and several debtors whose amounts happen to divide evenly into shared sub-groups, a global search over how to partition and pair balances can occasionally find a strictly smaller transaction count than greedy does. For almost all realistic small-to-medium group sizes, greedy's result is the same as, or very close to, the true minimum, and it runs fast and is easy to reason about — but "small" and "provably smallest" are different claims, and this design only ever makes the former.

Simplification is a suggestion, never a forced settlement

Nothing about running the simplification algorithm moves any money or changes anyone's stored balance — it is a read-only computation over the current BalanceProjection, presented to users as a suggestion (Chapter 07's GET /simplify). A user can act on it (recording a settlement that matches the suggestion), ignore it, or settle differently (paying the original payer directly instead of the simplified counterparty) — the net balances update correctly from whatever settlement is actually recorded, regardless of whether it matches a simplification suggestion. This also means simplification can legitimately suggest that Cara pays Ben, even though Cara's only direct, chronological interaction was overpaying... no, in this case Cara never paid Ben directly at all for the taxi — the point stands generally: simplification is free to reassign who-pays-whom across the group as long as the resulting net positions are preserved, because preserving net positions, not preserving original pairwise provenance, is the actual invariant it must respect.

Key takeaways
  • Raw pairwise debts and net-balance-derived simplified debts are both valid, consistent representations of the same underlying ledger state.
  • Greedy largest-creditor/largest-debtor matching is simple, fast, and produces a small (but not provably minimal) number of settling transactions.
  • Simplification preserves net positions, not original pairwise provenance — it can legitimately suggest a payment between two people who never transacted directly.
  • Running or viewing a simplification result never itself changes any stored balance — it is a suggestion, and only a recorded settlement changes balances.
Chapter 11

Expense Lifecycle, Edits and Deletes

An expense moves through create, validate, commit, publish, and read stages, and a correction is always a new, versioned, auditable action — never an in-place rewrite of financial history.

Create → validate → commit → publish → read

Create: a client submits an expense request, carrying an idempotency key. Validate: the service checks that the requester is a group member, that payer amounts sum to the total, that split shares sum to the total exactly, and that the idempotency key hasn't already been used with a conflicting payload. Commit: inside one transaction, the Expense, ExpensePayer, ExpenseSplit, and LedgerEvent rows are written, the BalanceProjection rows are updated (in the monolith) or an OutboxEvent is written in the same transaction (in the evolved architecture, Chapter 12), and the IdempotencyRecord is written. Publish: downstream consumers (notifications, search indexing, the balance projection service in the evolved design) are informed, asynchronously, that the expense happened. Read: any subsequent balance or activity query reflects the new expense, subject to the explicit visibility-lag budget from Chapter 04.

Versioned edits as reversal-plus-replacement

Editing an expense is never an in-place UPDATE of its ExpenseSplit or LedgerEvent rows. Instead, an edit is modeled as: (1) reverse the original expense's ledger effect — emitting new LedgerEvent rows that are the exact negation of the original ones, bringing the affected balances back to what they were before the expense existed; (2) apply the corrected expense's ledger effect as a fresh set of events; (3) increment the expense's version and record the change in AuditEntry, with the original Expense/ExpenseSplit rows retained, unmodified, for history. The net observable effect on balances is exactly as if the corrected amount had been entered originally, but the full sequence — original, reversal, replacement — remains in the immutable ledger forever.

Vector G, worked through the lifecycle

Dev... in this module's cast there is no Dev, so: suppose Asha and Ben both attempt to edit exp-1001 (currently at version 4) at nearly the same instant — Asha correcting the amount to ₹950, Ben correcting the description only, each unaware of the other's in-flight request. Both requests carry If-Match: 4. Whichever request's transaction commits first succeeds, moving the expense to version 5, with its corrected ledger effects applied. The second request's commit attempt finds the stored version is no longer 4 — it fails with an explicit VERSION_CONFLICT response (Chapter 07) rather than silently overwriting the first edit or silently being discarded. The losing client reloads the now-version-5 expense, re-applies its intended change on top of the current state, and resubmits with If-Match: 5 — a clear, explicit retry path, not a silent data loss.

EXPENSE ROW — starts at version 4Asha PATCH v4→v5Ben PATCH v4→v5DB: now v5wins — commits409 conflict
Vector G: two concurrent edits to version 4 — one commits as version 5, the other receives an explicit version conflict

Permissions on edits and deletes

By default, the expense's creator and the group's admins can edit or delete it; an ordinary member who is merely a beneficiary (and not the creator) generally cannot unilaterally rewrite an expense they didn't create, though a real product may relax this for small trusted groups — the important architectural point is that this is an explicit authorization check against the Membership/role data, resolved before the transaction begins, not an afterthought.

Deleting an expense that already has repayments against it

Vector I: reversing the ₹900 dinner expense from Vector A. Deleting (or reversing) an expense never erases its row or its ledger events — it emits a new set of LedgerEvent rows that exactly negate the original expense's effect (Asha −60,000, Ben +30,000, Cara +30,000, restoring each to what they'd have been without the dinner ever happening), marks the Expense.status as reversed, and links a reversed_by_expense_id or an equivalent reversal record, while the original Expense, ExpensePayer, and ExpenseSplit rows remain exactly as they were, permanently. If repayments have already been recorded against the net position that included this expense (for instance, if Ben had already repaid part of his ₹300 from this dinner before it was deleted), those settlement records are similarly left untouched and still fully explain their own effect — the reversal only cancels the expense's own contribution to the balance, and the resulting net balance is whatever the arithmetic says it should be, which may now be a credit in the other direction if more was repaid than is still owed. The system never silently "forgives" or discards a settlement just because the expense it was originally motivated by was later reversed.

Receipts and settlement effects

A receipt attachment is linked to an Expense by ID and is never itself a source of ledger truth — it is reference material for a human reviewing a disputed or unclear expense, not an input the matching/ledger logic parses or trusts. Settlement effects on balances follow the same immutable-ledger discipline as expenses (Chapter 09's Vector C mechanism) — a settlement, once confirmed, is as permanent a fact as an expense, correctable only by its own explicit reversal process, never by deletion.

Key takeaways
  • An expense's lifecycle is create, validate, commit, publish, read — with validation covering membership, split-sum, and idempotency before anything commits.
  • Edits are modeled as an explicit reversal of the original ledger effect plus a fresh replacement effect, never an in-place row mutation.
  • Optimistic concurrency (If-Match / version field) ensures two simultaneous edits never both silently succeed — one wins, the other gets an explicit, retryable conflict (Vector G).
  • Deleting an expense reverses its ledger effect and preserves its full history and any settlements recorded against it — it never erases the audit trail (Vector I).
Chapter 12

Evolved Architecture

Each component added beyond the Chapter 08 monolith exists to fix one specific, named bottleneck from that chapter — nothing here is added by reflex or because a distributed architecture diagram looks more impressive.

ClientGatewayExpense SvcLedger DBOutboxEvent BusBalance ProjectionCacheNotificationsPayment Provideroptional
API gateway/auth → expense/group service → transactional store + ledger → outbox → event bus → balance projection/read model → cache → notifications/activity

API gateway and authentication

Fixes: centralizing authentication, rate limiting, and request routing so that every one of potentially several backend services doesn't reimplement its own auth check. The gateway terminates the user's session token and attaches a verified identity to every downstream request — it does not itself decide object-level authorization (is this user a member of this group), which remains the expense/group service's job (Chapter 16).

Expense/group service and the relational transaction store

This is the evolved form of the Chapter 08 monolith's core: it still owns the atomic commit of Expense, ExpensePayer, ExpenseSplit, and LedgerEvent rows in one transaction, and it still enforces the split-sum and idempotency checks from Chapter 07. What's different is that it no longer also updates BalanceProjection synchronously, and it no longer directly calls notification or search systems — both of those responsibilities move out, which is exactly what the outbox below enables.

Transactional outbox

Fixes: the "expense committed but downstream notification failed/rolled back everything" coupling problem named in Chapter 08. An OutboxEvent row is written in the same transaction as the Expense/LedgerEvent commit — so either both happen or neither does, with no window where one exists without the other. A separate relay process reads uncommitted-to-the-bus OutboxEvent rows and publishes them to the event bus, retrying as needed, completely decoupled from the original transaction's success or failure.

Expense Tablesame TXOutbox Tablesame TXRelay WorkerEvent Busat-least-onceone DB transactionpollpublish
The expense write and its outbox row commit atomically in one transaction; a relay process publishes to the event bus afterward, independently and retriably

Event bus and balance-projection service

Fixes: the single-database read-capacity bottleneck named in Chapter 08. The event bus delivers expense/settlement/reversal events at-least-once to a dedicated balance-projection consumer, which applies each event's effect to BalanceProjection, advancing last_applied_sequence_number per group. This consumer can be scaled, restarted, and rebuilt independently of the write path — a surge in balance-read traffic no longer has any ability to compete with expense-write transactions for the same database connections.

Event BusProjectoridempotent per event IDBalance Viewversioned
An expense's ledger events flow through the outbox and event bus to the balance-projection consumer, which updates BalanceProjection and advances the per-group sequence watermark

Cache

Fixes: hot-group read amplification identified in Chapter 05. A cache in front of BalanceProjection (keyed by group and tagged with the projection's last_applied_sequence_number) absorbs the bulk of read traffic for a group's balance sheet, which is read far more often than it changes. The cache is explicitly invalidated or versioned on every projection update — never left to a blind TTL alone for a financial read, because silently serving a balance from before the latest expense is exactly the kind of staleness this design insists on making explicit rather than hiding (Chapter 13).

Notifications and activity

Fixes: the coupling of notification delivery latency/reliability to the expense-write path. A notification consumer, subscribed to the same event bus, generates per-user notifications and activity-feed entries asynchronously — a slow or temporarily-down push provider delays only notification delivery, never the expense commit itself.

What was deliberately not added

No separate "simplification service" — simplification is a stateless, read-only computation over the balance projection and doesn't need its own durable component. No separate microservice per split type — split computation is a small, shared library used by the one expense service, not a reason to fragment into network hops. No distributed transaction across services for an expense write — the outbox pattern specifically avoids needing one, by keeping the single atomic unit of work inside one database transaction and making everything downstream of it asynchronous and idempotent.

Chapter 13

Read Paths and Caching

Once a balance projection and a cache exist, every read needs an explicit answer to one question: how stale, at most, is this data allowed to be, and how would a caller know if it's stale right now?

Group balances and the user dashboard

The group balance sheet (Chapter 07's GET /balances) reads from the cache first, falling back to BalanceProjection on a cache miss. A user's personal dashboard (their net position across every group they belong to) is a fan-out read across each group's cached or projected balance for that user — it is itself a good candidate for its own small, per-user cache entry, since it is one of the most frequently-viewed screens in the product and changes only when one of the user's groups changes.

History pagination

Activity history (Chapter 07's GET /activity) is paginated with an opaque cursor derived from (created_at, audit_id) rather than a raw numeric offset, because new activity is always being appended and a numeric offset would skip or duplicate entries as the underlying set grows during pagination — a classic, avoidable bug in any append-heavy, offset-paginated feed.

Denormalized projection and cache keys

BalanceProjection is itself already denormalized for fast reads (one row per group/user/currency, no joins needed). The cache layer on top of it uses a key like balance:{group_id}:{currency} with a value tagged by last_applied_sequence_number — this tag is what lets a reader distinguish "this cached value reflects the latest known event" from "this cached value is one or more events behind," rather than treating every cache hit as unconditionally fresh.

Invalidation strategy

On every projection update, the balance-projection consumer invalidates (or proactively refreshes) the corresponding cache entry for that group/currency — invalidation is event-driven, not purely TTL-based, because a financial balance that is wrong for even a short, TTL-bounded window after a known update is a worse user experience than a very occasional cache miss.

Read-your-writes UX

A user who just added an expense expects to immediately see their own updated balance reflecting it, even if the asynchronous projection consumer hasn't caught up yet system-wide. A practical approach: the client that just performed the write already has the authoritative, just-committed ledger effect from the write response itself (Chapter 07's response includes the computed splits), and can optimistically merge that into its locally displayed balance while the server-side projection catches up in the background — the client should not have to wait on projection-visibility lag to show the user the effect of their own action, as long as it is clear, internally, that this optimistic merge is reconciled against the authoritative projection on the next real fetch, not treated as a permanent local override.

Stale data and repair/rebuild

BalanceProjection is, by design, always rebuildable: replaying a group's LedgerEvent rows in sequence_number order from the beginning and re-deriving each user's running total reproduces the exact same projection state. This is the mechanism used both for routine repair (if a bug or an outage is suspected to have corrupted a projection) and for onboarding a brand-new read-model implementation later without any special-casing — replay is the same operation either way, and it is the reason LedgerEvent being append-only and sequence_number being strictly ordered per group (Chapter 06) are non-negotiable properties, not conveniences.

Event BusProjectoridempotent per event IDBalance Viewversioned
A balance read checks the cache first (tagged by sequence number), falls back to BalanceProjection, and can be fully rebuilt by replaying LedgerEvent history
Chapter 14

Concurrency, Idempotency and Eventual Consistency

Networks retry, clients duplicate requests, and multiple people edit the same group at once. This chapter is about the three mechanisms — idempotency, optimistic locking, and the outbox's at-least-once delivery — that make all of that safe, explicitly assuming exactly-once network delivery never happens.

Vector F: duplicate expense creation

A mobile client submits a new expense, the network stalls before the response arrives, and the client — not knowing whether the first attempt succeeded — retries with the same Idempotency-Key. The service looks up IdempotencyRecord for (idempotency_key, scope) before doing any work: if found, and the retried request's fingerprint matches the original, it returns the original expense_id and result directly, performing no second commit, no second ledger effect, and no second activity entry. If the key is reused with a different payload (a genuinely conflicting request, not a true retry), it returns an explicit IDEMPOTENCY_KEY_CONFLICT — never a silent second effect, and never a silent overwrite of the first one.

Why idempotency keys are scoped

An idempotency key is only meaningful relative to a specific user, group, and endpoint — the same raw key value submitted by two different users, or against two different groups, must not be treated as the same logical request. This is why IdempotencyRecord's uniqueness is (idempotency_key, scope), not just idempotency_key alone.

Vector G: optimistic concurrency, revisited at the mechanism level

Chapter 11 walked through Vector G's user-facing behavior; the underlying mechanism is a version column on Expense, checked with a conditional update: UPDATE expense SET ..., version = version + 1 WHERE expense_id = :id AND version = :expected_version. If this affects zero rows, the expected version didn't match the stored one — someone else committed an edit first — and the service returns the explicit VERSION_CONFLICT response rather than retrying blindly or silently discarding the request. This is optimistic (rather than pessimistic, lock-based) concurrency: no lock is held across the request/response round trip, which keeps the system responsive, at the cost of occasionally needing a client-driven retry after a lost race — a reasonable trade-off given that true simultaneous edits to the same expense are rare.

EXPENSE ROW — starts at version 4Asha PATCH v4→v5Ben PATCH v4→v5DB: now v5wins — commits409 conflict
Vector F (duplicate retry recognized and deduplicated) and Vector G (version-4 race resolved by a conditional update) as two distinct concurrency mechanisms

Transactional outbox and at-least-once delivery, as a stated assumption

The event bus delivers OutboxEvent messages at least once — a consumer (the balance-projection service, the notification service) may see the same event more than once, for instance after a consumer restart that re-reads a not-yet-acknowledged message. This design never assumes exactly-once network delivery is achievable, and it does not need to: every consumer is written to be idempotent on `event_id`, tracking which event IDs it has already applied (the same pattern as IdempotencyRecord, applied on the consuming side) so that a redelivered event has no additional effect the second time. At-least-once delivery plus idempotent consumption together produce an effectively-once outcome — which is the only honest claim to make about a distributed delivery pipeline.

Versioned projections and gap detection

Because BalanceProjection.last_applied_sequence_number is tracked per group, the projection consumer can detect whether it has processed events in order and without gaps for that group — an event arriving with sequence_number greater than last_applied_sequence_number + 1 signals a gap (a missed or out-of-order delivery) that should trigger a re-fetch of the missing range rather than silently advancing past it and leaving a hole in the applied history.

Settlement webhook replay and reconciliation

An external payment provider's webhook, confirming a settlement, is itself subject to the same at-least-once reality — a provider may redeliver the same "payment confirmed" webhook more than once (its own retry policy, or a network blip on the provider's side). The settlement-confirmation handler is idempotent on the provider's own event/transaction ID, exactly like any other consumer in this design, so a redelivered confirmation webhook updates Settlement.status to confirmed at most effectively once, never double-applying the ledger effect of a settlement that was already confirmed (Chapter 15 covers the full settlement-confirmation flow).

Chapter 15

Multi-Currency and Payment Integration

A group that spans two currencies is not one ledger with two kinds of numbers in it — it is two separate ledgers that happen to share the same group and members. This chapter also covers the boundary between a settlement a user merely claims happened and one a payment provider has actually confirmed.

Vector H: ₹1,000 and $20 are never netted

Suppose Weekend Trip later adds a $20 expense (perhaps a souvenir bought on a layover, paid in USD). This design never combines a ₹1,000 INR position and a $20 USD position into one number — BalanceProjection's key is (group_id, user_id, currency), so INR and USD balances for the same group and the same user are two entirely separate rows, each independently summing to zero across that currency's participants. A group balance sheet, in the presence of multiple currencies, is displayed as multiple independent per-currency balance sets — never as a single blended total — because summing ₹1,000 and $20 directly would be arithmetically meaningless without an explicit, policy-governed conversion.

FX rate snapshots and explicit conversion

If a product chooses to offer a "show me everything in INR equivalent" convenience view, that view is computed by applying an explicit CurrencyRateSnapshot (Chapter 06) — a recorded rate, as-of a specific time, from a stated source — to each foreign-currency balance, and the result is presented as a clearly-labeled converted estimate, never silently merged into or replacing the authoritative per-currency balances. The underlying per-currency ledgers and projections are never themselves rewritten in a different currency; conversion is a display-time or reporting-time operation layered on top, governed by an explicit, versioned rate-and-policy record so that a conversion can always be explained ("converted using rate X, captured at time Y, from source Z").

Currency precision and rounding

Different currencies have different minor-unit conventions (INR and USD both use 2 decimal places / 100 minor units per major unit, but not every currency does — some have zero minor-unit places). The minor-unit precision for a given currency is a stated property the system looks up, not assumed to be universally "2 decimal places," and all the same integer-arithmetic and deterministic-remainder rules from Chapter 09 apply per currency, using that currency's own minor-unit definition.

Vector J: pending vs. confirmed settlement

A Settlement record's status starts at pending when a payment has been initiated through an integrated provider but not yet confirmed. A pending settlement must not reduce any confirmed balance — BalanceProjection is only updated once the settlement's status moves to confirmed, which happens exclusively in response to the provider's authoritative confirmation event (a webhook or callback carrying the provider's own transaction reference), never in response to the client simply saying "I initiated a payment." If the provider instead reports the payment as failed, the settlement's status moves to failed and, critically, it must never be treated as settled — no ledger effect is ever applied for a failed or merely-initiated payment, under any circumstance.

InitiatedPendingnot yet confirmedConfirmedbalance reducedFailedbalance unchangedprovider confirmsprovider fails
Settlement states: pending → confirmed (ledger effect applied) or pending → failed (no ledger effect, ever)

Manual settlement is a user assertion, not a confirmation

A method: manual settlement (Vector C, Chapter 09) is, by contrast, confirmed immediately upon creation — but it is confirmed as a user's own assertion that a repayment happened (e.g. cash handed over in person), not as something independently verified by any third party. The API and UI are explicit about this distinction: a manually-recorded settlement is trusted because the user who is owed the money is the one recording it (or because both parties can see and dispute it in the shared activity feed), not because real money movement was cryptographically or institutionally confirmed. This is never presented to the user as equivalent in evidentiary strength to a provider-confirmed settlement.

Provider reconciliation, disputes, and refunds

A periodic reconciliation job compares the provider's own transaction ledger against this system's confirmed settlements for the same date range, flagging any settlement confirmed here but missing or contradicted on the provider's side (or vice versa) for manual review — the provider's authoritative record, not this system's cached webhook history, is the tiebreaker for any such discrepancy. A refund or disputed/reversed payment from the provider is modeled the same way an expense reversal is modeled (Chapter 11): a new, linked reversal record that negates the original settlement's ledger effect, never a silent rewrite of the original Settlement row.

Chapter 16

Security, Privacy, Resilience and Operations

A shared-expense app handles two sensitive categories at once — other people's money and other people's personal spending patterns — which makes authorization, auditability, and careful deletion policy first-class operational concerns, not an afterthought bolted on before launch.

Membership authorization on every object access

Every request that reads or writes an Expense, Settlement, or balance must check that the acting, authenticated user is actually a current (or, for read access to historical data, formerly valid) member of the specific group_id the object belongs to — being authenticated proves identity, not entitlement to this particular group's data. This check happens on the server for every request, never trusted from a client-supplied group ID or expense ID alone, precisely because a group ID or expense ID is often guessable or sequential-looking and must never function as an implicit access token.

Invitation security

Invitation tokens are single-use, expiring, and scoped to a specific group and (where an email-based invite is used) a specific invited address — accepting an invitation does not grant access to any group other than the one it was issued for, and an expired or already-accepted invitation link must be rejected, not silently re-honored.

Abuse prevention

Rate limiting on expense creation, invitation sending, and settlement recording protects against both accidental client bugs (a retry loop gone wrong, mitigated further by idempotency) and deliberate abuse (spamming invitations, flooding a group's activity feed). Anomalous patterns — an account rapidly joining and leaving many groups, or creating and reversing a large volume of expenses — are reasonable signals for an abuse-detection pipeline, kept separate from the core ledger logic itself.

Encryption and receipt PII

Data in transit is encrypted (TLS) end to end between clients, the gateway, and internal services; data at rest (particularly the transactional database and any receipt-image storage) is encrypted at rest. Receipt attachments can contain personally identifying or sensitive information (a restaurant receipt showing a card's last four digits, a medical-expense receipt) and are access-controlled at the same group-membership granularity as the expense they're attached to — never exposed via a guessable, unauthenticated URL.

Audit trail as a security property, not just a correctness one

Because every AuditEntry is immutable and append-only (Chapter 06), it also serves a security function: a compromised account's actions (editing or reversing expenses it shouldn't have) remain fully visible after the fact for investigation, rather than being erasable by whoever performed them.

Deletion requests and financial-record retention

A user's request to delete their personal data (common under privacy regulations in many jurisdictions) runs into a real tension with the system's core correctness invariant: other group members' historical balances depend on that user's past expenses and settlements remaining intact and explainable. The practical resolution is to anonymize, not erase, the financial facts — retaining the Expense, ExpenseSplit, and LedgerEvent rows (because deleting them would silently corrupt every other affected member's auditable history) while scrubbing or replacing the deleted user's directly-identifying profile fields (name, email, avatar) with an anonymized placeholder, and removing any attached receipts that contain their personal information. This is a genuine trade-off between an individual's deletion request and the group's collective right to an accurate, explainable financial history, and it needs to be a deliberate, documented policy decision, not something discovered during an actual deletion request.

Backups, alerting, and disaster recovery

The transactional store (the source of truth for Expense, ExpensePayer, ExpenseSplit, Settlement, LedgerEvent) is backed up on a regular schedule with tested restore procedures, since it is the one component whose loss cannot be recovered from any other part of the system (the balance projection and cache, by contrast, are rebuildable from it, per Chapter 13). Alerting covers idempotency-conflict spikes, version-conflict spikes, projection-consumer lag exceeding its stated budget, and settlement-webhook processing failures — each of these is a leading indicator of either a client-side bug, a concurrency hotspot, or an integration problem with the payment provider, and each deserves its own distinct alert rather than a single generic "errors are elevated" signal.

Data export and controlled corrections

A user-facing export of a group's expense and settlement history (Chapter 03) draws from the same authoritative tables as the application itself, so an export is never a separately-maintained, potentially-drifting copy of the truth. Any correction to history outside the normal edit/reverse flow described in Chapter 11 — for example, a support-tooling-driven correction for a bug that produced an incorrect ledger event — goes through the same reversal-plus-replacement mechanism and the same AuditEntry logging as an ordinary user-initiated edit, with the acting support identity recorded, so that even an operator-level correction remains fully explainable from history.

Chapter 17

Final Architecture and Recap

Pulling every chapter together: one authoritative ledger, one evolved pipeline from expense to projection, and a small, explicit set of trade-offs that were made deliberately and can be named out loud.

ClientsGatewayExpense SvcLedger DBOutboxEvent BusProjectionCacheNotificationsPayment ProviderReconciliationzero-sum checks
The complete Weekend Trip architecture: gateway, expense/group service, transactional ledger, outbox, event bus, balance projection, cache, notifications, and the payment-provider boundary

Expense-to-ledger-to-projection, recapped as one sequence

A client submits Vector A's dinner expense with an idempotency key. The expense/group service validates membership, split-sum, and idempotency, then commits Expense, ExpensePayer, ExpenseSplit, LedgerEvent, and OutboxEvent rows in one atomic transaction, and acknowledges the client immediately — this is the write-acknowledgment moment. The relay process publishes the outbox event to the event bus. The balance-projection consumer applies it idempotently, advancing last_applied_sequence_number for the group — this is the read-model-visibility moment, which can legitimately lag acknowledgment by a small, bounded, and explicit amount. The cache is invalidated or refreshed. A notification consumer, independently, informs Ben and Cara that a new expense was added. Every one of these steps traces back to specific chapters: validation and commit to Chapters 06-08, the outbox and event bus to Chapter 12, projection and caching to Chapter 13, and the idempotency/delivery guarantees underneath all of it to Chapter 14.

Settlement integration, recapped

A manual settlement (Vector C) is confirmed immediately as a user assertion and applies its ledger effect right away. A provider-backed settlement starts pending and only applies its ledger effect once the provider's authoritative confirmation event arrives (Vector J) — a failed payment never applies any effect at all. Both paths converge on the same mechanism underneath: a Settlement record producing symmetric LedgerEvent rows, processed through the exact same idempotent, at-least-once-tolerant pipeline as an expense.

Evolution table

StageWhat it addsBottleneck it fixes
Monolith (Ch. 08)One service, one transactional DB, atomic expense+ledger commitBaseline correctness — nothing yet to fix
Transactional outbox (Ch. 12)OutboxEvent written in the same transaction as the commitSafe decoupling of downstream effects from the write transaction
Event bus + balance projection (Ch. 12-13)Async, independently-scaled balance-projection consumerSingle-database read-capacity contention between writes and hot-group balance reads
Cache (Ch. 12-13)Sequence-tagged cache in front of BalanceProjectionRead amplification from hot groups being read far more often than they change
Multi-currency & payment integration (Ch. 15)Per-currency ledgers, FX rate snapshots, pending/confirmed settlement statesCorrectly handling more than one currency and an external, untrusted-until-confirmed payment provider
Each stage is motivated by a specific, named limitation of the stage before it — never added speculatively.

Constraints and trade-offs carried forward

This design accepts a small, explicit, bounded read-model staleness window in exchange for read scalability, rather than forcing every balance read through the same synchronous path as writes. It accepts that greedy debt simplification is not provably minimal, in exchange for a simple, fast, easy-to-explain algorithm that is correct about the one thing that actually matters — preserving net positions. It accepts the operational cost of anonymization-not-erasure for deletion requests, in exchange for never silently corrupting another member's auditable balance history. None of these are free; each is a deliberate choice made explicit rather than an accident discovered later.

Key takeaways
  • The authoritative ledger (Expense, ExpensePayer, ExpenseSplit, Settlement, LedgerEvent) is the one source of truth throughout every architectural stage in this module.
  • Write acknowledgment, durable commit, and read-model visibility are three distinct moments, explicitly bounded rather than conflated, from the monolith all the way through the evolved architecture.
  • Every added component — outbox, event bus, projection consumer, cache — is traceable to a specific, named bottleneck from an earlier chapter.
  • Debt simplification, deletion-request handling, and settlement confirmation are all places where this design makes a deliberate, named trade-off rather than claiming a free lunch.