Skip to content

πŸ—οΈ Splitwise / Expense Sharing β€” High-Level Design

Target Level: Senior/Staff Engineer | Focus: Debt graph algorithms, financial accuracy, payment integration


1. SYSTEM OVERVIEW

Purpose: Expense sharing platform where groups track shared expenses and settle debts with minimal transactions.

Scale: 10M users, 50M expenses/month, 5M groups active

Users: End users (splitting bills), Power users (roommates, trip groups), Admins

Use Cases: Add expense (equal/exact/percentage), Split restaurant bills, Settle debts, Trip expense tracking, Monthly recurring bills

Constraints: Financial accuracy (no rounding errors to lose money), <500ms balance calculation, eventual consistency for cross-device sync


2. HIGH-LEVEL ARCHITECTURE

Mobile App / Web (React/PWA)
      β”‚
β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
β”‚ API Gateway │── Auth (OAuth2) ── Rate Limit
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
      β”‚
β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
β”‚ Expense    β”‚  β”‚ Settlement β”‚  β”‚ Notificationβ”‚
β”‚ Service    β”‚  β”‚ Service    β”‚  β”‚ Service     β”‚
β”‚ (Python)   β”‚  β”‚ (Python)   β”‚  β”‚ (Node.js)   β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
      β”‚               β”‚               β”‚
β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
β”‚              Message Queue (RabbitMQ)       β”‚
β”‚  - expense.created β†’ notification          β”‚
β”‚  - settlement.due β†’ reminder               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              PostgreSQL                       β”‚
β”‚  - Users, Groups, Expenses, Settlements      β”‚
β”‚  - Optimistic locking with version numbers   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Figure: services, queue and database.

flowchart TB
  C["Mobile / web"] --> G["API gateway: OAuth2, rate limit"]
  G --> E["Expense service"]
  G --> S["Settlement service"]
  G --> N["Notification service"]
  E --> Q["Message queue"]
  S --> Q
  N --> Q
  Q --> P[("PostgreSQL: expenses, balances, settlements")]

🎬 Animated Sequence Diagram


🎬 Animated Splitwise Sequence β€” Add β†’ Split β†’ Balance Update β†’ Settlement. Click β–Ά to play/pause. Created with Remotion.


3. KEY COMPONENTS & INTERVIEW Q&A

Expense Service (Python/FastAPI)

  • CRUD expenses with split calculation
  • Strategy pattern: Equal, Exact, Percentage, Share
  • Rounding: largest-remainder allocation in integer cents

πŸ”΄ Interview Question: "How do you handle rounding errors in expense splits?"

βœ… Answer: Never use floats, and do the rounding in one place. Allocate in integer cents with the largest-remainder method:

exact    = [Fraction(total_cents) * w / sum(weights) for w in weights]
floors   = [floor(x) for x in exact]
leftover = total_cents - sum(floors)            # always < len(weights)
for i in sorted(range(n), key=lambda i: (-(exact[i] - floors[i]), i))[:leftover]:
    floors[i] += 1
This guarantees sum(shares) == total and that nobody is more than one cent from their exact share. Pushing the whole difference onto one person (payer or first participant) looks simpler but can produce a negative share: $0.05 split 7 ways rounds every share up to 0.01 (0.07 total), and the βˆ’0.02 correction leaves one person owing βˆ’0.01. Store amounts as NUMERIC(12,2) (or BIGINT cents) so the database never reintroduces floats.


Settlement Service (Python)

  • Balance reads: O(members) from the materialised balances rows
  • Debt simplification (greedy min-transactions, NP-hard optimal)
  • Payment request creation

πŸ”΄ Interview Question: "What algorithm do you use for debt simplification?"

βœ… Answer: The minimum is n βˆ’ k, where n is the number of people with a non-zero balance and k is the largest number of disjoint zero-sum subgroups. Finding k is NP-hard, so: 1. Net balances come from the materialised balance rows (see Data Model). 2. Greedy: match the largest debtor with the largest creditor (two heaps), transfer the smaller amount, repeat. O(n log n), at most n βˆ’ 1 transfers, but not optimal: {-8, -7, -2, +9, +8} takes 4 greedy transfers where 3 suffice. 3. Exact for small groups: bitmask DP over subsets, O(2ⁿ Β· n). Most groups have well under 15 active members, so 2ⁿ Β· n stays in the hundreds of thousands of steps; cap n and fall back to greedy above the cap.

Simplification can make someone pay a person they never shared an expense with, so offer it as an opt-in group setting.

Figure: greedy debt simplification from net balances.

flowchart TD
  A["Read net balances per member"] --> B["Put debtors and creditors in two heaps"]
  B --> C["Match largest debtor with largest creditor"]
  C --> D["Transfer the smaller amount"]
  D --> E{"Anyone non-zero?"}
  E -- Yes --> C
  E -- No --> F[Done]

Notification Service (Node.js)

  • Push: Payment reminders, new expenses, settlement confirmations
  • Email: Weekly/monthly summaries
  • WebSocket: Real-time expense updates within group

4. DATA MODEL

CREATE TABLE groups (
    id UUID, name TEXT, created_by UUID, created_at TIMESTAMP
);
CREATE TABLE group_members (
    group_id UUID, user_id UUID, role TEXT DEFAULT 'member'
);
CREATE TABLE expenses (
    id UUID PRIMARY KEY, group_id UUID, description TEXT, amount DECIMAL(12,2),
    currency CHAR(3), paid_by UUID, split_type TEXT,
    request_id UUID UNIQUE,                 -- client idempotency key
    deleted_at TIMESTAMP, version INT,      -- edits = soft delete + new row
    created_at TIMESTAMP
);
CREATE TABLE expense_shares (
    expense_id UUID, user_id UUID, share_amount DECIMAL(12,2),
    PRIMARY KEY (expense_id, user_id)
);
-- Derived, updated in the same transaction as the expense insert:
-- UPDATE balances SET net = net + :delta WHERE group_id = ? AND user_id = ?
CREATE TABLE balances (
    group_id UUID, user_id UUID, currency CHAR(3), net DECIMAL(14,2),
    PRIMARY KEY (group_id, user_id, currency)
);
CREATE TABLE settlements (
    id UUID, group_id UUID, from_user UUID, to_user UUID,
    amount DECIMAL(12,2), status TEXT, created_at TIMESTAMP
);

5. TRADE-OFF ANALYSIS

Decision Choice Rationale
Currency Store in base + original Multi-currency support; show in user's preferred currency
Balance calc Materialised balances rows, updated in the expense transaction O(participants) per write, O(1) per read; a nightly job recomputes from expense_shares and alerts on drift
Rounding Largest remainder in cents Exact total, nobody more than 1 cent off, never negative
Edits Soft delete + new row Audit trail; the balance delta is "reverse old, apply new" in one transaction
Settlements Peer-to-peer Not a payment processor; generate requests to PayPal/UPI

6. CONSISTENCY, IDEMPOTENCY & FAILURE MODES

Concern Choice
Consistency Strong within a group: the expense insert, its share rows and the balance deltas commit in one Postgres transaction. Shard by group_id so that transaction never crosses shards. Notifications and cross-device sync are eventually consistent (outbox β†’ queue).
Concurrent writes Inserts do not conflict. Balance updates use net = net + :delta, which row-locks briefly; no read-modify-write in the app. Optimistic version checks guard edits to the same expense.
Mobile retries Client generates request_id per create; UNIQUE(request_id) turns a retry into a no-op that returns the original expense.
Lost notifications Transactional outbox: the event row is written in the expense transaction, a relay publishes it; consumers are idempotent on event id.
Payment provider timeouts Settlement is PENDING until the provider confirms; retries reuse the same provider idempotency key; a reconciliation job closes the gap.
Balance drift (bug or bad migration) Balances are derived data. Recompute from expense_shares + settlements per group and compare; the ledger is the source of truth.
Non-group (friend-to-friend) expenses Model as an implicit two-person group so the same per-group transaction and sharding rule apply.

Figure: adding an expense; shares and balances commit atomically, notification goes through an outbox.

sequenceDiagram
  participant C as Client
  participant E as Expense service
  participant D as PostgreSQL
  participant R as Outbox relay
  participant N as Notification
  C->>E: Create expense (request_id)
  E->>D: One txn: expense, shares, balance deltas, outbox row
  D-->>E: Commit (retry hits UNIQUE request_id)
  E-->>C: Created
  R->>D: Read outbox
  R->>N: Publish expense.created

Capacity (from the numbers in Β§1): 50M expenses/month β‰ˆ 20 writes/s average, perhaps 200/s at peak (weekend evenings, month-end rent). Each expense is ~1 KB with ~4 share rows, so ~50 GB/year including indexes. A single well-sized Postgres primary with read replicas handles this comfortably; sharding by group_id is a growth plan, not a day-one need.


7. COST (Monthly)

Component Cost
API Compute $2,000
PostgreSQL $1,200
Notifications (SES + Push) $800
Cache + Queue $400
Total $4,400