Filter concepts by levelShowing all levels.

System Design · Practical Projects

Eight projects that validate the theory

Each one is small enough to finish and forces exactly one lesson the others cannot teach — with a build order and a definition of done

  • Build orderevery step works before the next one starts
  • One lesson eachasymmetry · intent vs delivery · leases · transaction boundaries
  • Definition of donea failure you can cause on purpose and survive
  • Two share one bugthe inventory race and the rate-limit race are the same shape

1.Project 1 — URL shortener

Build a service that turns a long URL into a short code and redirects that code back to the original. It looks small, and that is why it is the first project: the whole thing fits in a weekend, but it forces four decisions that recur everywhere. The first is the read/write asymmetry — redirects outnumber creations by a factor of hundreds or thousands, so the create path and the redirect path get designed separately rather than as two endpoints on the same service. The second is ID generation, which has no obviously correct answer until you name the property you want: a random code is unguessable but needs a collision check, a counter in base62 is short and dense but leaks how many links exist and needs coordination between instances, and a hash of the URL deduplicates identical links but makes two users share one code and therefore one set of analytics. The third is caching, because the redirect path is a pure key lookup and belongs in Redis — with the database still the system of record, so a cache flush costs latency rather than data. The fourth is that analytics must not be on the redirect path: a click writes an event to a queue and the redirect returns, because a redirect that waits on an analytics write has coupled its availability to a system nobody would page for.

Key features

  • Reads dominate by orders of magnitude — design the redirect path and the create path as separate concerns
  • ID scheme is a property choice: random (unguessable, needs collision retry) · counter in base62 (short, leaks volume, needs coordination) · hash (dedupes, but shares analytics between users)
  • Redis holds code → URL as a read-through cache; PostgreSQL stays the system of record so a flush costs latency, not data
  • Click analytics goes on a queue — a redirect that waits on an analytics write inherits its availability
  • Rate limit creation by API key or IP; the abuse case is bulk-generating links for phishing, not heavy redirect traffic
  • Expiry and deletion need a decision up front: a deleted code returns 410, and reusing it later would resurrect old links

Three ID schemes, and the property each one buys

Three ID schemes, and the property each one buys
SchemeBuysCostsPick it when
Random base62, 7 charsUnguessable; no coordination between instancesNeeds a uniqueness check and a retry on collisionLinks may be private, and enumeration would be a leak
Counter → base62Shortest possible codes; no collision check at allLeaks total link count; needs a coordinated counter or per-instance blocksCodes are public anyway and shortness is the product
Hash of the URLIdentical URLs collapse to one code, saving storageTwo users share a code, so they share analytics and expiryDedup is genuinely wanted and per-user tracking is not

The two paths, sized separately

The two paths, sized separately
PathShapeWhat it needsWhat it must not do
POST /links (create)Low volume, validated, authenticatedRate limiting, URL validation, abuse checks, an IDAssume the caller is friendly
GET /{code} (redirect)High volume, one key lookupCache read-through, a 301 or 302, sub-10 ms p99Write to the analytics store synchronously
URL shortener — the two paths
POST /linksnext codeinsertGET /{code}lookupon missclick eventaggregate

Client

Create API

POST /links · rate limited

ID generation

base62 · scheme is a choice

PostgreSQL

system of record

Redirect service

GET /{code} → 302

Redis

code → URL, read-through

Click events

fire and forget

Analytics store

  • Client
    • leads to Create API (POST /links)
    • leads to Redirect service (GET /{code})
  • Create API — POST /links · rate limited
    • leads to ID generation (next code)
    • leads to PostgreSQL (insert)
  • ID generation — base62 · scheme is a choice
  • PostgreSQL — system of record
  • Redirect service — GET /{code} → 302
    • leads to Redis (lookup)
    • leads to Click events (click event)
  • Redis — code → URL, read-through
    • leads to PostgreSQL (on miss)
  • Click events — fire and forget
    • leads to Analytics store (aggregate)
  • Analytics store

Command

text
Build order — each step works before the next

  1  POST /links + GET /{code}, Postgres only
  2  ID generation: pick a scheme, write down
     which property you chose it for
  3  Redis read-through on the redirect path
  4  Click events -> queue -> analytics store
  5  Rate limit on POST /links (per API key)
  6  Expiry: TTL column + a sweep, 410 on
     an expired code

Definition of done: you can state your p99
redirect latency, what happens when Redis is
down, and why you chose 301 or 302.

A URL shortener is two products sharing a database: a low-volume create path that needs validation and rate limiting, and a very high-volume redirect path that is one cached key lookup. Choose the ID scheme by the property you want (unguessable / shortest / deduplicating), keep Postgres as the record with Redis read-through so a flush costs latency and not data, push click events to a queue so redirect availability never depends on analytics, and choose 301 versus 302 deliberately — 301 is cached forever and silently ends your click counting.

2.Project 2 — Notification platform

Build a service other services call to send email, SMS and push. It teaches the intent/delivery split better than anything else, because the split is unavoidable here: one accepted request ("tell user 91 their order shipped") fans out into several attempts across several channels, each with its own state, its own provider and its own failure. So there are two tables, not one. A notification row records what was asked for and is created inside the caller's request. Delivery rows record each attempt on each channel and are created by workers afterwards. Separate worker pools per channel matter more than they look: email providers rate-limit differently from SMS providers, and one pool means a backlog of SMS retries starves email. Deduplication is the other unavoidable piece — callers retry, and a retried "order shipped" must not send a second email, so the caller supplies a key and the same key returns the same notification. Templates belong on the platform rather than in each caller so that a wording fix is one deploy. And delivery tracking is what makes the whole thing operable: without a per-attempt state you cannot answer "did user 91 get it", which is the only question anyone ever asks.

Key features

  • Two records: a notification (what was asked for, created synchronously) and one delivery per channel attempt (created by workers)
  • One worker pool per channel — a shared pool lets an SMS provider backlog starve email
  • The caller supplies a dedupe key; the same key must return the original notification, never send twice
  • Providers define "sent" as "accepted by us", not "arrived" — real delivery status comes back later via webhooks
  • Respect user preferences and quiet hours at send time, not at enqueue time — preferences can change while a message is queued
  • Every provider has its own rate limit; exceeding it returns 429s that must slow the pool rather than become retries

The two records, and why they cannot be one row

The two records, and why they cannot be one row
RecordCreated byHoldsLifecycle
Notification (intent)The API, inside the caller's requestuser, event, payload, dedupe key, channels chosenaccepted → resolved (all channels terminal)
Delivery (attempt)A worker, per channelchannel, provider, provider message id, attempt number, errorqueued → sent → delivered / bounced / failed

What a provider actually tells you, and when

What a provider actually tells you, and when
SignalWhen it arrivesWhat it meansWhat it does not mean
2xx from the send callImmediatelyThe provider accepted the messageThe user received it
429 from the send callImmediatelyYou exceeded the provider's rate limitThe message failed — it was never attempted
Delivery webhookSeconds to hours laterThe provider believes it reached the device or inboxThe user read it
Bounce webhookSeconds to days laterPermanent failure — stop retrying this addressA transient error worth a retry
Notification platform — intent in, deliveries out
POST/notificationsinsertintentenqueue perchannelsendsendattemptstateattemptstatewebhook: delivered/ bouncedexhausted

Calling service

Notify API

dedupe key → 202 + id

Notifications

intent, one row

Work queue

one topic per channel

Email pool

own rate limit

SMS pool

own rate limit

Providers

2xx = accepted, not delivered

Delivery rows

per attempt, per channel

Dead letter

after 5 attempts

  • Calling service
    • leads to Notify API (POST /notifications)
  • Notify API — dedupe key → 202 + id
    • leads to Notifications (insert intent)
    • leads to Work queue (enqueue per channel)
  • Notifications — intent, one row
  • Work queue — one topic per channel
    • leads to Email pool
    • leads to SMS pool
  • Email pool — own rate limit
    • leads to Providers (send)
    • leads to Delivery rows (attempt state)
    • on error, leads to Dead letter (exhausted)
  • SMS pool — own rate limit
    • leads to Providers (send)
    • leads to Delivery rows (attempt state)
  • Providers — 2xx = accepted, not delivered
    • leads to Delivery rows (webhook: delivered / bounced)
  • Delivery rows — per attempt, per channel
  • Dead letter — after 5 attempts

Command

text
Build order — each step works before the next

  1  POST /notifications -> row + 202 + id
     GET /notifications/{id} -> status
  2  Queue + one email worker. Provider call,
     store the provider message id.
  3  Delivery rows, one per channel attempt.
  4  Retries: 5 attempts, backoff + jitter,
     then dead-letter. 429 slows the pool.
  5  Dedupe key on POST, unique per caller.
  6  Templates, versioned, rendered by the
     worker so a fix is one deploy.
  7  SMS + push as separate pools.
  8  Provider webhooks -> delivery status.

Definition of done: you can answer "did user
91 get the shipping email, and if not, why"
from stored state alone.

Two records: a notification is the intent, created synchronously with a caller-supplied dedupe key; a delivery is one attempt on one channel, created by a worker. Separate worker pools per channel so one provider's backlog cannot starve another. A provider 2xx means accepted, not delivered — real status arrives later by webhook, and a bounce is terminal. Treat 429 as backpressure on the pool rather than a per-message retry, and re-check preferences and quiet hours at send time, because a queue is a decision made in the past.

3.Project 3 — File processing platform

Build something that accepts a large file, does slow work on it, and gives the result back — resizing images, transcoding video, extracting text from a PDF. The central lesson is that the bytes never touch your application servers. The client asks the API for a signed upload URL, uploads directly to object storage, then tells the API the upload finished; the API stores metadata and enqueues a job. Doing it the other way, streaming the file through your API, ties every upload to one server's memory, bandwidth and deploy cycle, and a rolling restart kills every upload in flight. The second lesson is that progress tracking is a requirement, not a nicety: work measured in minutes needs a job record with a state, a percentage and an error field, because a client that cannot poll will either retry (doubling the work) or hang. The third is the multipart upload — a 4 GB file over a phone connection will fail partway, and multipart lets it resume from the failed part rather than from zero. And the whole thing has an ownership rule worth stating: object storage holds bytes, the database holds truth. A file that exists in storage with no row is garbage to be swept; a row with no object is a failed upload to be reported.

Key features

  • Bytes go client → object storage directly via a signed URL; the API only ever handles metadata
  • Signed URLs are short-lived and scoped to one key and one method — an upload URL must not also grant reads
  • Multipart upload lets a large file resume from the failed part; without it a 4 GB upload restarts from zero
  • A job row with state, percent and error is what lets a client poll instead of retrying or hanging
  • Object storage holds bytes, the database holds truth: an object with no row is garbage, a row with no object is a failed upload
  • Workers must be idempotent — a redelivered job must overwrite the same output key, not produce a second output

Two upload designs, and what each one costs

Two upload designs, and what each one costs
DesignPath the bytes takeWhat breaksWhen it is acceptable
Through the APIclient → app server → object storageServer memory and bandwidth scale with file size; a deploy kills uploads in flightSmall files only, where you must inspect content before storing
Signed URL (direct)client → object storageYou cannot inspect the file until after it landsAlmost always — validate after upload, before enqueueing the job

The job record — the states a client can be shown

The job record — the states a client can be shown
StateSet byMeansClient shows
awaiting_uploadAPI, on job creationSigned URL issued; no object yetUpload progress from the browser
queuedAPI, when the client confirms uploadObject exists and was validatedWaiting to start
processingWorker, on claimA worker holds a lease on this jobPercent complete
succeededWorkerOutput written; result key recordedA signed download URL
failedWorker, after retries exhaustedTerminal; error is stored verbatimThe reason, and whether retrying will help
File processing — bytes bypass the API entirely
1. POST/jobsawaiting_upload2. PUT bytes(signed URL)3. POST/complete4. enqueueclaim +leasestream inwrite resultpercent →succeededpoll GET/jobs/{id}

Client

Job API

metadata only — never bytes

Jobs table

state · percent · error

Object storage

signed PUT, then signed GET

Job queue

lease + visibility timeout

Worker pool

idempotent, streams the object

Output object

deterministic key

  • Client
    • leads to Job API (1. POST /jobs)
    • leads to Object storage (2. PUT bytes (signed URL))
    • leads to Job API (3. POST /complete)
    • leads to Jobs table (poll GET /jobs/{id})
  • Job API — metadata only — never bytes
    • leads to Jobs table (awaiting_upload)
    • leads to Job queue (4. enqueue)
  • Jobs table — state · percent · error
  • Object storage — signed PUT, then signed GET
  • Job queue — lease + visibility timeout
    • leads to Worker pool (claim + lease)
  • Worker pool — idempotent, streams the object
    • leads to Object storage (stream in)
    • leads to Output object (write result)
    • leads to Jobs table (percent → succeeded)
  • Output object — deterministic key

Command

text
Build order — each step works before the next

  1  POST /jobs -> job row + signed PUT URL
     Client PUTs the object directly.
  2  POST /jobs/{id}/complete -> validate the
     object exists, size + type, then enqueue
  3  Worker: claim with a lease, process,
     write output object, update job
  4  GET /jobs/{id} -> state + percent
  5  Signed GET URL for the result, short TTL
  6  Multipart upload for files over ~100 MB
  7  Sweep: objects with no row, rows stuck
     in awaiting_upload past their URL expiry

Definition of done: kill a worker mid-job and
the job completes anyway, exactly once.

The bytes never touch your API. Client asks for a signed PUT URL, uploads straight to object storage, then confirms; the API stores metadata and enqueues. Object storage holds bytes, the database holds truth — an object with no row is garbage, a row with no object is a failed upload. Workers claim with a lease they extend, stream rather than load, and write to a deterministic output key so a retry overwrites instead of duplicating. Progress is a job field a client can poll; a client that cannot poll will retry, and doubling minutes of work is expensive.

4.Project 4 — E-commerce order system

Build checkout: reserve stock, take payment, create an order, then fulfil it. This is the hardest of the eight projects and the one worth the most, because it is the smallest realistic system where money, inventory and a third party all have to agree. Three things make it hard. First, the payment provider is outside your database, so no transaction can cover both the charge and the order row — the design has to decide what happens in the gap between them, in both directions. Second, inventory is contended: two customers want the last unit at the same moment, and the database, not the application, has to be the thing that decides. Third, every step is retried by somebody — the customer refreshes, the client times out and retries, the queue redelivers — so every write needs to be safe to run twice. The shape that handles all three is: one short local transaction that reserves stock and creates the order in `pending_payment`, then the payment attempt outside any transaction, then a second short transaction that records the result and writes an outbox row, then fulfilment consuming that outbox asynchronously. The rule underneath it is that a database transaction must never contain a network call to something you do not control.

Key features

  • A database transaction must not contain a call to an external provider — the transaction holds locks for the provider's p99, and its outcome is unknown on timeout
  • Reserve inventory with a database-enforced rule (conditional update or constraint), never a read-then-write in application code
  • Idempotency key on POST /orders and on the payment attempt: the same key returns the first result, never charges twice
  • The gap between "provider charged" and "we recorded it" is real in both directions and needs reconciliation, not just retries
  • Payment state and order state are different state machines — an order can be paid and unfulfillable
  • The outbox turns "update the order and publish an event" into one atomic local write, with a relay publishing afterwards

The three transaction boundaries, and what is deliberately outside them

The three transaction boundaries, and what is deliberately outside them
StepInside one local transactionOutside itWhy the split
1. ReserveDecrement available stock · insert order (pending_payment) · insert reservation with an expiryNothingStock and order must agree or neither happens
2. Charge—The provider call, with an idempotency key and a timeoutA network call inside a transaction holds locks for someone else's latency
3. RecordUpdate order to paid or payment_failed · release stock on failure · insert outbox rowNothingThe state change and the event it publishes must be one atomic write
4. FulfilConsumer's own local transactionReads the outbox asynchronouslyFulfilment failure must not roll back a completed payment

The gap between charge and record — both directions

The gap between charge and record — both directions
What happenedSymptomResolution
Charged, we timed out before recordingMoney taken, order stuck in pending_paymentRe-send the same idempotency key: the provider returns the original charge, not a second one
Not charged, we recorded paidOnly possible if you record before the provider confirms — do notPrevented by ordering, not by cleanup
Charged, then we crashed permanentlyOrder never leaves pending_paymentA reconciliation job compares provider charges to orders daily and flags mismatches
Provider webhook arrives before our own recordA payment event for an order still marked pendingThe webhook handler must be idempotent and safe to arrive first
Checkout — three short transactions and one gap
POST /orderscommitor failcharge(idempotency key)resultoutboxOrderPaideventtimeout /unknownresolvethe gap

Client

Idempotency-Key

Order API

TX 1 — reserve

stock decrement + order (pending_payment)

Payment provider

OUTSIDE any transaction

TX 2 — record

order → paid + outbox row

Outbox relay

reads committed rows only

Fulfilment

own transaction, idempotent

Reconciliation

daily: provider vs orders

  • Client — Idempotency-Key
    • leads to Order API (POST /orders)
  • Order API
    • leads to TX 1 — reserve (commit or fail)
  • TX 1 — reserve — stock decrement + order (pending_payment)
    • leads to Payment provider (charge (idempotency key))
  • Payment provider — OUTSIDE any transaction
    • leads to TX 2 — record (result)
    • on error, leads to Reconciliation (timeout / unknown)
  • TX 2 — record — order → paid + outbox row
    • leads to Outbox relay (outbox)
  • Outbox relay — reads committed rows only
    • leads to Fulfilment (OrderPaid event)
  • Fulfilment — own transaction, idempotent
  • Reconciliation — daily: provider vs orders
    • leads to TX 2 — record (resolve the gap)

Command

text
Build order — each step works before the next

  1  Orders + items. POST /orders with an
     Idempotency-Key; same key -> same order.
  2  Inventory: conditional decrement
       UPDATE stock SET qty = qty - 1
       WHERE sku = ? AND qty >= 1
     0 rows updated = out of stock. No read
     first. The database decides.
  3  Payment outside any transaction, with
     the provider's idempotency key = your
     payment attempt id.
  4  Outbox row written in the same
     transaction as the order state change.
  5  Relay publishes outbox -> fulfilment
     consumes, idempotently.
  6  Reservation expiry sweep: unpaid holds
     return stock after N minutes.
  7  Reconciliation job vs the provider.

Definition of done: kill the process between
every pair of steps; no money is lost and no
stock is double-sold.

Three short local transactions with the provider call deliberately outside them: reserve stock and create the order, charge with an idempotency key, then record the result and write an outbox row atomically. Never put a network call inside a transaction — it lends your locks to a third party and its rollback cannot undo the charge. Let the database decide contention with a conditional update rather than read-then-write. Expect the gap between charged and recorded to happen in both directions, and reconcile against the provider rather than hoping retries cover it.

5.Project 5 — Chat platform

Build real-time messaging: rooms, live delivery, history, and a sensible experience for someone who was offline. Its lesson is stateful connections. Every other project here scales by adding stateless instances behind a load balancer; this one cannot, because a WebSocket lives on one specific process, and the recipient of a message is almost never connected to the same process as the sender. That single fact produces most of the design. You need a registry mapping user to instance, or — simpler and more common — a pub/sub channel per room that every instance subscribes to, so a sender publishes once and whichever instances hold the recipients deliver. You need message ordering that does not depend on clocks, because two servers' clocks disagree and a message is not ordered by when it was typed: give each room a monotonic sequence assigned by the database on insert, and clients order by that. You need persistence before delivery, not after, because a message that was delivered live and never stored disappears on refresh. Offline sync then becomes simple — the client stores the last sequence it saw and asks for everything after it. Presence is deliberately approximate: it is high-churn, low-value data that belongs in a keyed store with a TTL, never in your primary database.

Key features

  • A WebSocket is pinned to one process, so delivery needs pub/sub between instances or a connection registry
  • Persist first, then fan out — a message delivered live but not stored vanishes on refresh
  • Order by a per-room monotonic sequence assigned on insert, never by client or server wall-clock time
  • Offline sync is a cursor: the client keeps its last seen sequence and asks for everything after it
  • Presence is high-churn and approximate — a keyed store with a TTL, refreshed by heartbeat, not a database row
  • Push notification is the fallback path when no connection is live, and it needs its own dedupe against the live delivery

Two ways for instance A to reach a recipient connected to instance B

Two ways for instance A to reach a recipient connected to instance B
ApproachHow it worksCostChoose when
Pub/sub per roomEvery instance subscribes to the rooms its connections are in; sender publishes onceEvery instance receives every message for a room it holds any member ofAlmost always — it is simpler and has no registry to keep correct
Connection registryA shared store maps user → instance; the sender routes directlyThe registry must be kept accurate through crashes, and a stale entry silently drops messagesVery large rooms, where broadcasting to every instance is genuinely wasteful

Three sources of "order", and why only one works

Three sources of "order", and why only one works
Ordering byFails whenVerdict
Client timestampA device clock is wrong, or deliberately set wrongNever — it is user-controlled input
Server receive timeTwo servers' clocks differ by milliseconds, which is normalNo — produces different orders for different readers
Per-room sequence from the databaseOnly under a room-level write bottleneck, which is measurableYes — one writer per room defines one order for everyone
Chat — persist first, then fan out across instances
send1. persist(seq = n)2. publishroom eventfan outdeliverwho is live?no socket→ pushreconnect:after=last_seq

Sender

socket on instance A

Instance A

Messages

assigns room sequence

Pub/sub

one channel per room

Instance B

Recipient

socket on instance B

Presence store

heartbeat + 30s TTL

Push notification

only if no live socket

  • Sender — socket on instance A
    • leads to Instance A (send)
  • Instance A
    • leads to Messages (1. persist (seq = n))
    • leads to Pub/sub (2. publish room event)
  • Messages — assigns room sequence
  • Pub/sub — one channel per room
    • leads to Instance B (fan out)
  • Instance B
    • leads to Recipient (deliver)
    • leads to Presence store (who is live?)
    • leads to Push notification (no socket → push)
  • Recipient — socket on instance B
    • leads to Messages (reconnect: after=last_seq)
  • Presence store — heartbeat + 30s TTL
  • Push notification — only if no live socket

Command

text
Build order — each step works before the next

  1  REST: POST /rooms/{id}/messages,
     GET /rooms/{id}/messages?after=<seq>
     No sockets yet. It already works.
  2  Persist with a per-room sequence.
  3  WebSocket: subscribe to a room, receive
     new messages. Persist THEN publish.
  4  Multiple instances: pub/sub per room.
  5  Reconnect: client sends last_seq,
     server replays the gap over REST.
  6  Presence: heartbeat -> keyed store,
     30s TTL. Absence means offline.
  7  Push when no live connection, deduped
     against the live delivery.

Definition of done: kill one instance while
a conversation is running; clients reconnect
and lose no message.

A WebSocket is pinned to one process, so cross-instance delivery is pub/sub per room. Persist before you fan out: the insert assigns a per-room sequence, and that sequence — never a clock — is what orders the conversation for every reader. Offline sync is a client-held cursor plus `?after=<seq>`, not a server-side delivery buffer; subscribe before fetching history so a gap becomes a removable duplicate. Presence goes in a keyed store with a TTL, where expiry is the offline signal.

6.Project 6 — Multi-tenant SaaS platform

Build one system that serves many customer organisations from shared infrastructure. The lesson is that tenant isolation is a property, not a control: it has to hold on every path that touches data, and the paths people miss are the ones that do not go through a request handler. A query in a controller is easy to scope. A cache key is not — omit the tenant from it and one company sees another's data, with no error and no log line. A background job has no request context at all, so it has to carry a tenant id explicitly or it will happily run across everyone. Exports, search indexes, webhooks and log messages are the same story. The strongest structural answer is to make the tenant id impossible to forget rather than mandatory to remember: a database session variable with row-level security, a repository layer that will not build a query without one, cache keys constructed by a helper that requires it. RBAC sits inside the tenant boundary and is a different question — isolation says which rows exist for you at all, roles say what you may do with them, and conflating the two produces a system where an admin of one organisation can act on another. Quotas and billing then both need the same per-tenant usage counters, so build them once.

Key features

  • Isolation must hold on every path: queries, cache keys, background jobs, exports, search indexes, webhooks and logs
  • Prefer structural enforcement — row-level security or a repository that cannot build an unscoped query — over remembering a WHERE clause
  • A cache key without a tenant id is a cross-tenant data leak with no error and no log entry
  • A background job carries no request context; the tenant id must be part of the job payload
  • RBAC is inside the tenant boundary: isolation decides which rows exist for you, roles decide what you may do with them
  • Quotas and billing read the same per-tenant usage counters — build the counters once, for both

Three isolation models, and what each actually buys

Three isolation models, and what each actually buys
ModelShapeIsolation strengthCost
Shared schema, tenant_id columnOne database, one set of tables, a column on every rowDepends entirely on every query being scoped — enforce with row-level securityCheapest to run; a single missing filter is a full breach
Schema per tenantOne database, one schema per tenantA connection is scoped by search path; cross-tenant queries are unnaturalMigrations run per schema; hundreds of schemas get slow
Database per tenantSeparate database, sometimes separate instanceStrongest; also gives per-tenant backup and restoreMost expensive to operate; connection pools multiply

The paths where isolation is usually forgotten

The paths where isolation is usually forgotten
PathThe leakThe structural fix
Cache keyuser:42:dashboard collides across tenantsA key builder that takes tenant id as a required argument
Background jobA nightly job iterates all rows with no tenant filterTenant id in the job payload; the worker sets the session variable before any query
Search indexOne index for everyone; a query returns another tenant's documentsTenant id as a mandatory filter clause, or an index per tenant
Export / reportA CSV built by a different code path than the UIExports go through the same scoped repository as everything else
Logs and errorsA stack trace or error payload containing another tenant's recordLog identifiers, not payloads; scrub before shipping
Webhook deliveryAn event delivered to the wrong tenant's endpointResolve the destination from the event's tenant, never from a shared config
Isolation has to hold on every path, not just the request

The path everyone scopes

HTTP request

tenant from token

Query

row-level security

The paths people forget

Cache key

no error when wrong

Background job

no request context

Export / report

Search index

Logs and traces

Webhook delivery

Inside the boundary

RBAC

what you may do

Quotas

from usage counters

Billing

same counters

  • Tenant identity
  • The path everyone scopes
    • HTTP request — tenant from token
    • Query — row-level security
  • The paths people forget
    • Cache key — no error when wrong
    • Background job — no request context
    • Export / report
    • Search index
    • Logs and traces
    • Webhook delivery
  • Inside the boundary
    • RBAC — what you may do
    • Quotas — from usage counters
    • Billing — same counters

Command

text
Build order — each step works before the next

  1  tenants table; tenant_id on every row.
  2  Middleware resolves tenant from the
     token, sets a session variable.
  3  Row-level security policy per table
     using that variable. Now an unscoped
     query returns zero rows, not a leak.
  4  RBAC inside the tenant: roles ->
     permissions, checked per object.
  5  Cache keys via a builder requiring
     tenant id. No raw string keys.
  6  Background jobs carry tenant_id and set
     the session variable before querying.
  7  Usage counters per tenant -> quotas
     (enforce) and billing (invoice).

Definition of done: delete the WHERE clause
from one query and watch it return nothing
rather than another tenant's rows.

Tenant isolation is a property that must hold on every path, and the leaks are on the paths with no request context: cache keys, background jobs, search indexes, exports, webhooks and logs. Enforce it structurally — row-level security, a repository that cannot build an unscoped query, a cache-key builder that requires a tenant — rather than by remembering. RBAC lives inside the boundary: isolation says which rows exist for you, roles say what you may do with them. Quotas and billing share one set of per-tenant usage counters.

7.Project 7 — Search platform

Build search over a corpus you own: take documents in, index them, and serve ranked queries. The organising idea is that the index is derived, never authoritative. Every document has a home in a primary store, and the index is a projection of it that you must be able to throw away and rebuild at any moment. That single rule decides most of the design. Ingestion becomes a pipeline from the primary store to the index rather than a separate write path, so nothing can be searchable that does not exist. Failure recovery becomes "rebuild from the primary" rather than "restore an index backup", which is both simpler and always correct. And reindexing stops being an emergency: you will change the analyzer, add a field or fix a mapping far more often than you expect, and each of those needs a full rebuild. The way to make that routine is aliases — build the new index alongside the live one, then atomically swing a name from old to new, keeping the old one until you are sure. The other thing this project teaches is that ranking is a product decision with an evaluation loop, not a configuration value: you need a set of queries with known good results before you can tell whether a ranking change helped.

Key features

  • The index is derived: it must be rebuildable from the primary store, so recovery is a rebuild rather than a restore
  • Ingest from the primary store — usually via an outbox or change stream — so nothing indexed can fail to exist
  • Reindex behind an alias: build the new index, swing the alias atomically, keep the old one until you are sure
  • Index lag is normal and must be visible to users ("may take a few seconds") rather than hidden
  • Ranking needs an evaluation set — queries with known good results — or a ranking change cannot be judged
  • Cache query results, not documents; a query is far more likely to repeat than an individual document is to be requested

The pipeline, and the failure each stage owns

The pipeline, and the failure each stage owns
StageDoesFails asRecovery
IngestionReads changes from the primary (outbox or change stream)Lag — new documents are not yet searchableCatch-up; the cursor is durable so nothing is lost
IndexingAnalyzes and writes documents into the indexRejected documents, mapping conflictsDead-letter the document, alert, fix the mapping, reindex
Query APIParses the query, applies filters, calls the indexSlow or failing indexDegrade to a primary-store filter, or return an honest error
RankingOrders results by relevance and business rulesSilently worse resultsOnly detectable against an evaluation set — nothing else will tell you
ReindexRebuilds the whole index under a new nameHalf-built index serving trafficPrevented by the alias swap; never index into the live one

What to cache, and what not to

What to cache, and what not to
CandidateCache it?Why
Full query result page 1YesQuery distribution is heavily skewed; a small set of queries is most of the traffic
Deep pagination (page 40)NoAlmost never repeated, and expensive to hold
Individual documentsRarelyThe index already serves them fast; the win is in avoiding the query, not the fetch
Autocomplete prefixesYesExtremely repetitive and latency-sensitive
Personalised resultsOnly per user, brieflyA shared cache key here is a cross-user leak, not a performance win
Search — a derived index, rebuilt behind an alias
changesbulk writeswap whencompletequerylookupon missdegraded:filter directly

Primary store

the truth

Change stream

outbox or CDC · durable cursor

Indexer

analyze · map · write

Index v2

built alongside the live one

Alias "search"

atomic swap v1 → v2

Query API

parse · filter · rank

Result cache

hot queries, page 1

User

  • Primary store — the truth
    • leads to Change stream (changes)
  • Change stream — outbox or CDC · durable cursor
    • leads to Indexer
  • Indexer — analyze · map · write
    • leads to Index v2 (bulk write)
  • Index v2 — built alongside the live one
    • leads to Alias "search" (swap when complete)
  • Alias "search" — atomic swap v1 → v2
  • Query API — parse · filter · rank
    • leads to Result cache (lookup)
    • on error, leads to Primary store (degraded: filter directly)
  • Result cache — hot queries, page 1
    • leads to Alias "search" (on miss)
  • User
    • leads to Query API (query)

Command

text
Build order — each step works before the next

  1  Primary store + a naive query API using
     SQL LIKE. Slow, correct, a baseline.
  2  Index one document type. Full rebuild
     script from the primary, run by hand.
  3  Query API against the index. Compare
     results with the baseline.
  4  Ingestion: outbox/change stream ->
     indexer, with a durable cursor.
  5  Alias: search_v1 behind alias "search".
     Rebuild into v2, swing the alias.
  6  Evaluation set: 50 queries with known
     good results. Score before/after.
  7  Cache the hot query results.
  8  Degraded mode when the index is down.

Definition of done: delete the entire index
and have it back, correct, without touching
a backup.

The index is derived, never authoritative — recovery is a rebuild from the primary store, not a restore. Ingest from a committed change stream or outbox so search can never contain a document the store does not have. Reindex behind an alias: build the new index alongside, catch up or dual-write, verify against an evaluation set, then swap atomically and keep the old one for a day. Cache query results rather than documents, and show index lag to the user instead of letting it look like an empty result.

8.Project 8 — Distributed rate limiter

Build something that lets a caller through 100 times a minute and refuses the 101st, correctly, when the check is running on twelve application servers at once. It is the smallest project here and it teaches atomicity better than any of the others, because the obvious implementation — read the counter, compare it to the limit, write the new value — is wrong in a way that only shows up under concurrency, which is precisely the condition a rate limiter exists for. Under load, many servers read the same value before any of them writes, and the limit is exceeded by roughly the number of concurrent servers. The fix is to make the read, the decision and the write one operation the store performs indivisibly: an atomic increment, or a small script the store runs to completion without interleaving. Choosing the algorithm is a separate decision. A fixed window is trivial and allows twice the limit across a window boundary. A sliding window fixes that at the cost of more state. A token bucket allows a controlled burst and then a steady rate, which is usually what an API actually wants. The last decision is the one people skip: what happens when the store holding the counters is down — fail open and lose all protection, or fail closed and take an outage.

Key features

  • GET-then-SET is wrong under concurrency: N servers reading before any writes overshoot the limit by roughly N
  • Make check-and-update one atomic store operation — INCR with an expiry, or a script the store runs to completion
  • Fixed window allows up to 2× the limit across a boundary; sliding window and token bucket both fix that differently
  • Token bucket allows a bounded burst then a steady refill, which matches how APIs are actually used
  • The identity key (user, API key, IP, tenant) is a design decision — IP alone punishes shared networks and misses distributed abuse
  • Decide fail-open versus fail-closed for a counter-store outage; not deciding means fail-closed by accident, via a timeout

Three algorithms, with the burst each one permits

Three algorithms, with the burst each one permits
AlgorithmState per keyBurst behaviourPick it when
Fixed windowOne counter + expiryUp to 2× the limit across a boundary (100 at 11:59:59, 100 at 12:00:00)Rough protection is enough and simplicity matters most
Sliding window logA timestamp per requestExact — no boundary effect at allLimits must be precise and request volume per key is modest
Sliding window counterTwo counters, weighted by position in the windowApproximate, no boundary spike, small fixed stateA good default: nearly exact, cheap
Token bucketToken count + last refill timeA deliberate burst up to bucket size, then a steady ratePublic APIs — it matches how clients actually behave

The decisions that are not the algorithm

The decisions that are not the algorithm
DecisionOptionsConsequence of choosing badly
Identity keyuser id · API key · IP · tenant · endpoint + userIP alone throttles an entire office behind one NAT and misses a distributed attacker
ScopeGlobal · per endpoint · per tenant · per planOne global limit means an expensive endpoint and a cheap one share a budget
Store failureFail open (allow) · fail closed (reject)Undecided means fail closed by timeout — a counter outage becomes a full outage
Response429 + Retry-After + limit headersWithout Retry-After, well-behaved clients retry immediately and make it worse
Enforcement pointEdge/gateway · application · bothApplication-only means rejected traffic still costs you a connection and a process
Why GET-then-SET overshoots, and what atomic does
Server A
Server B
Redis
  1. 1GET count:user42Naive version — three separate operations
  2. 2→ 99
  3. 3GET count:user42Arrives before A has written anything back
  4. 4→ 99
  5. 5SET 100 · allowA believes it took the last slot
  6. 6SET 100 · allow101 requests allowed with a limit of 100 — and with 12 servers, ~112
  7. 7INCR count:user42Atomic version — one operation, the store orders it
  8. 8→ 100 · allow
  9. 9INCR count:user42
  10. 10→ 101 · rejectNo interleaving is possible: the read and the write are the same operation
  1. Server A → Redis: GET count:user42 (Naive version — three separate operations)
  2. Redis → Server A: → 99
  3. Server B → Redis: GET count:user42 (Arrives before A has written anything back)
  4. Redis → Server B: → 99
  5. Server A → Redis: SET 100 · allow (A believes it took the last slot)
  6. Server B → Redis: SET 100 · allow (101 requests allowed with a limit of 100 — and with 12 servers, ~112)
  7. Server A → Redis: INCR count:user42 (Atomic version — one operation, the store orders it)
  8. Redis → Server A: → 100 · allow
  9. Server B → Redis: INCR count:user42
  10. Redis → Server B: → 101 · reject (No interleaving is possible: the read and the write are the same operation)

Command

text
Build order — each step works before the next

  1  In-process fixed window. One server.
     Correct, and useless with two servers.
  2  Move the counter to Redis with INCR +
     EXPIRE. Now shared, still boundary-spiky.
  3  Make it atomic in ONE round trip
     (script or pipelined INCR+EXPIRE) --
     then prove it with concurrent load.
  4  Token bucket via a script: refill by
     elapsed time, take one, return allowed.
  5  Headers: X-RateLimit-Limit/Remaining/
     Reset, and Retry-After on 429.
  6  Decide and implement fail-open or
     fail-closed, with a short timeout.
  7  Per-plan quotas reading the same
     counters.

Definition of done: 12 concurrent clients, a
limit of 100, and exactly 100 get through.

Read-compare-write is wrong under concurrency; make the check and the update one atomic store operation — an increment, or a script the store runs without interleaving. Pick the algorithm by the burst you want to allow: fixed window is spiky at boundaries, sliding window counter is a cheap near-exact default, token bucket allows a bounded burst and matches real API clients. Then answer the three questions that are not the algorithm: what identity shares a budget, what happens when the counter store is down, and whether the flood is being rejected at the edge or deep inside your application.

Remember

  • Never put a network call inside a database transaction — it lends your locks to a third party and rollback cannot undo the charge.
  • Read-then-write is a race: make the check and the update one atomic operation, in the database or in the store.
  • Derived stores (cache, search index) must be rebuildable from the primary; that turns recovery into a routine rebuild.
  • Persist before you fan out, and write the event in the same transaction as the state change.

Pro tip

  • Finish the boring version first — REST before WebSockets, SQL LIKE before an index, one server before twelve.
  • Your definition of done should be a failure you cause deliberately: kill a worker, kill an instance, delete the index.

Next up

  • What a 5-Year Engineer Should Be Able to Explain
  • Twelve questions, answered from these projects
  • Then the priority roadmap and learning order

Sources

  • System Design Primer · AWS S3 and SQS docs · microservices.io · RFC 6455 · Redis docs
  • Checked 2026-09-04

Spotted a mistake on this page? Tell us.