High-level architecture: the whole system on one page
Before the deep-dives, the map: what Fable is made of and how a request flows through it. A React Native app talks REST + WebSockets to a single NestJS backend on one Mumbai VM, which fronts a managed Postgres, a co-located Redis, and Cloudflare R2 for media — with Firebase for identity and FCM for push. This chapter is the boxes-and-arrows view and the honest label for the shape: a modular monolith on a single node, chosen deliberately, with every component justified by a requirement from the previous chapter.
High-level architecture: the whole system on one page
With the requirements and constraints in hand, here's the system they produced — the map you'd draw first in an interview, and the one to hold in your head while reading the deep-dive chapters. The guiding principle, forced by the one-developer constraint, is as few moving parts as the requirements allow, and the shape that produced has an honest name: a modular monolith on a single node.
The components
- Client — a React Native (Expo) mobile app. It holds optimistic local state, generates ULIDs and idempotency keys before it talks to the server, and speaks two protocols to the backend: REST for request/response and WebSockets (Socket.IO) for realtime.
- Backend — a single NestJS application (TypeScript). It's a monolith — one deployable process — but internally modular, organized into feature modules (auth, groups, expenses, settlements, chat, media, inbox, moderation…) with clear boundaries. One process, many well-separated rooms.
- Database — managed PostgreSQL (GCP Cloud SQL, Mumbai). The single source of truth for everything: the ledger, users, groups, chat messages, all of it. Chosen managed so someone else owns backups and failover.
- Cache & realtime backplane — Redis, self-hosted next to the API. It does triple duty: the cache/idempotency store, the session-revocation set, and the Socket.IO pub/sub adapter that lets realtime scale across processes.
- Object storage — Cloudflare R2 for media (receipts, photos), served through a CDN, accessed via presigned URLs so bytes never flow through the API.
- Identity — Firebase Auth issues identity (phone OTP, Google); Fable verifies the Firebase token and mints its own session tokens.
- Push — Firebase Cloud Messaging (FCM) delivers push notifications to devices.
The deployment: one VM, one Compose stack
Physically, it's one virtual machine in Mumbai running a single Docker Compose stack, fronted by Caddy (TLS termination + reverse proxy), with the NestJS API container, a cloud-sql-proxy sidecar (the keyless, service-account-authenticated tunnel to Cloud SQL), and the Redis container. The API talks to Postgres through the proxy over a private Docker network; it talks to Redis over the same network; it talks to R2 and FCM over the internet. That's the entire production footprint — described in full in the infra-evolution chapter.
Following a request through the system
Two flows show how the pieces fit.
Adding an expense (REST write):
- The app optimistically shows the expense, having minted its ULID and idempotency key locally.
POST /v1/expenses(withAuthorization: Bearer <JWT>andIdempotency-Key) hits Caddy, which terminates TLS and reverse-proxies to the NestJS process.- The auth guard verifies the JWT signature and checks the Redis revocation set; the idempotency middleware checks Redis for a replay.
- The expenses module validates membership, computes the splits via
@hisaab/money, and writes the expense + splits + payers in one Postgres transaction through the cloud-sql-proxy, invalidating the balance cache. - It emits a realtime event so other members' devices update, and enqueues an inbox/push notification.
201returns (recorded in the idempotency store for replay).
Receiving a chat message (WebSocket): the sender's message is persisted first, then broadcast to the group's Socket.IO room — routed, if the recipient is on a different process, through the Redis adapter — and delivered down the recipient's socket; offline recipients reconcile via cursor-paginated history on reconnect.
Why a monolith, honestly
The instinct in a system-design interview is to draw microservices. Fable is deliberately not microservices, and the reasoning is the requirements chapter's discipline applied to topology:
- One developer can't operate a fleet of services — the operational cost (deployment, inter-service networking, distributed tracing, the dual-write/outbox problem) is enormous and buys nothing at Fable's scale.
- A monolith gets real transactions for free. Because expenses, settlements, and balances live in one database in one process, a money operation is a single ACID transaction with a local lock — no sagas, no 2PC, no distributed consistency to get wrong. Splitting the ledger across services would manufacture the hardest problems in this curriculum for no benefit.
- Modular internally means the boundaries that matter (feature modules) exist for code clarity, without paying the network and consistency tax of making them physical.
Planned (TDD)
A conventional cloud architecture, roughly along microservice/serverless lines, with managed data services on free tiers.
Shipped
A modular-monolith NestJS process on a single Mumbai VM (Docker Compose: Caddy + api + cloud-sql-proxy + redis), managed Cloud SQL, self-hosted Redis, R2 for media, Firebase for identity, FCM for push.
Every split-out service would cost a solo developer operational time and manufacture distributed-systems problems (dual writes, sagas, cross-service consistency) that a single process with one database avoids entirely. The monolith is the simplest thing that meets the requirements, and it keeps money operations as local ACID transactions.
Interview takeaway
- Draw the whole system at box altitude first, then walk one request end to end. "Client → TLS-terminating proxy → app → cache-check → DB transaction → emit event → enqueue notification → respond" is exactly the high-level-design step, and it gives you and the interviewer a shared map before any deep-dive.
- Defend the monolith when it's right. Saying "one service, one database, because it keeps money operations as local transactions and a small team can actually operate it — I'd extract a service only when a specific part needs independent scaling" is a stronger answer than reflexive microservices, and it signals you know what distribution costs.
- Name what each component is for. Every box in Fable maps to a requirement; a box you can't justify is a box you shouldn't draw.
The chapters from here zoom into the boxes: the data model and expense engine inside the database, auth and chat and media inside the app, and the infra under it all.
Go deeper
- Martin Fowler — "MonolithFirst" — The argument that you should almost always start with a monolith and extract services only when a proven need appears — exactly Fable's reasoning, from the person who popularized microservices.
- NestJS — modules — How the "modular monolith" is actually structured — feature modules with clear boundaries inside one process, the organization Fable's backend uses.
- Cache invalidation, monoliths, and scaling talks (Shopify/Stack Overflow scale on monoliths) — Evidence that monoliths scale far further than folklore admits — the empirical backing for choosing one deliberately rather than apologetically.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- List Fable's major components and state what each one is for. Which single component does triple duty, and what are its three jobs?
- Walk an "add expense" request from the app through TLS termination, auth, idempotency, the database transaction, and the notification — naming the component at each step.
- Fable is a modular monolith, not microservices. Give the strongest single technical reason (about money operations and transactions), plus the operational reason tied to the team constraint.
- What does "modular internally, monolithic in deployment" mean, and what tax does it avoid compared to making those module boundaries physical services?
- The production footprint is one Compose stack. Name the four containers and what each does, and how the API reaches the managed database.
- In an interview, why can "one service, one database, extract later if needed" be a stronger answer than microservices, and what would justify extracting a service?
Requirements & constraints: what Fable had to be
Before any architecture, the forces that shaped every later decision. Fable is a group-expenses app, which sounds simple until you write down what 'simple' actually demands: money that's exactly right forever, a mobile client on flaky networks that you can't hot-patch, one developer's time and a near-zero budget, and real users who split dinners on trains. This opening chapter states the functional and non-functional requirements and the hard constraints — and shows how each one is the reason a later chapter chose what it chose.
Data model: money you can trust and ids you can sort
How Fable's schema is built around two decisions most apps get wrong — never storing money as a float, and never using an id you can't generate on the client. This chapter walks the real Prisma schema: paisa-as-bigint with a currency-branded Minor type, ULIDs over auto-increment and UUIDv4, splits that always sum to the total, and the idempotency-key unique constraint that makes a retried payment write safe. Then the drift: 33 tables planned, 37 shipped, and a session-lookup column added after cold-start refresh got slow.