How to read this guide
rules before recipesSystem design isn't a list of buzzwords to memorize — it's a small set of heuristics that keep repeating: spread load, tolerate failure, pick your trade-off consciously, make retries safe. Once you see the pattern, every "new" system is a remix of the same fifteen ideas.
Each chapter below is one mechanism, with one thing you can click and break. Do the breaking — that's where the lesson lives, not in the paragraph above it.
Why does almost every "design X" interview question start with "how many users, how many requests per second"? Keep this question in mind — the finale ties it together.
Scale out, not just up
the first heuristicVertical scaling means a bigger machine — more CPU, more RAM. It's simple and it works, but it has a ceiling (the biggest machine money can buy) and a single point of failure. Horizontal scaling means more machines doing the same job behind a load balancer. It has (almost) no ceiling, but it only works if any request can be handled by any machine — which is why later chapters spend so much effort making services stateless.
Before scaling anything, estimate the load: requests per second, data per day, read/write ratio. A back-of-envelope number tells you whether you need three servers or three thousand — and whether the bottleneck is CPU, network, or disk.
This is a simplified M/M/1-style intuition: latency ≈ base / (1 − utilization). As utilization creeps toward 100%, queueing time explodes even though average load looks "almost fine" — this is why healthy systems run at 60–70% utilization, not 95%.
- Vertical scaling: simple, has a hard ceiling, stays a single point of failure.
- Horizontal scaling: needs statelessness, scales further, needs a load balancer.
- Always start from an estimate — requests/sec and data/day decide the architecture.
- Latency doesn't rise linearly with load — it rises sharply near saturation.
Redundancy & failover
nothing runs on one boxReliability is what happens after something breaks — and something always breaks. The fix is redundancy: no component that matters should exist as a single copy. A single point of failure (SPOF) is any one thing whose death takes the whole system down with it.
Availability is usually expressed in "nines": 99.9% ("three nines") allows about 8.8 hours of downtime a year; 99.99% allows about 52 minutes. Each extra nine costs real engineering effort, so pick a target the business actually needs — not the highest number that sounds impressive.
Redundancy that shares a failure domain isn't redundancy. Three replicas in the same rack, same power circuit, same availability zone can all die together. Spread copies across zones/regions to survive correlated failures.
- No single copy of anything that matters — that's redundancy.
- A SPOF is any component whose failure takes the whole system down.
- Losing the majority of replicas is different from losing one — the survivors can't safely elect a leader.
- Spread redundancy across real failure domains (racks, zones, regions).
CAP & the consistency spectrum
pick two, but only during a partitionThe CAP theorem says: when a network Partition happens between nodes that hold the same data, you must choose between Consistency (every read sees the latest write, so you refuse writes on the cut-off side) and Availability (every request gets a response, even if it might be stale). Outside of a partition, you can have both — CAP is specifically about what happens during a network split, not a permanent trade-off.
A related, more practical framing is PACELC: if there is a Partition, choose Availability or Consistency; Else (normal operation), choose Latency or Consistency. Strongly consistent systems pay a latency tax even when nothing is broken, because they must coordinate before answering.
- CAP only forces a choice while a partition exists.
- CP: refuse requests on the minority side, stay correct.
- AP: answer everywhere, accept that copies can diverge, reconcile later.
- PACELC: even without a partition, strong consistency costs latency.
Load balancing
who gets the next requestA load balancer sits in front of a pool of identical servers and picks one for each request. Round robin cycles through servers in order — simple, ignores current load. Least connections sends the request to whichever server has the fewest in-flight requests — better when requests take unequal time. Consistent hashing maps both servers and requests onto a ring by hash, so a given key (e.g. a user ID) usually lands on the same server — useful for cache locality — and when a server dies, only its slice of the ring remaps, not everything.
Load balancers also work at different layers: L4 balances raw TCP/UDP connections (fast, protocol-blind); L7 reads HTTP (host, path, headers) and can route /api/* to one pool and /static/* to another. Either way, a load balancer must stop sending traffic to a node that fails its health check.
- Round robin: fair by turn, blind to actual load.
- Least connections: fair by current load, needs live state.
- Consistent hashing: stable placement, minimal remap on node changes.
- L4 = fast and protocol-blind; L7 = content-aware routing.
- Health checks remove dead nodes from rotation automatically.
Caching
remember what's expensive to recomputeA cache trades a little staleness for a lot of speed. Cache-aside (lazy loading): the app checks the cache first; on a miss, it reads the database and writes the result into the cache. Write-through: every write goes to the cache and the database together, so the cache is never stale but writes are slower. Write-behind: writes land in the cache first and are flushed to the database asynchronously — fast, but can lose data if the cache dies before flushing.
Every cache entry needs an eviction rule — a TTL (time to live) or a policy like LRU (evict the least recently used entry) — otherwise the cache grows forever. And watch for a cache stampede: when a hot key expires, hundreds of requests miss at once and all hammer the database simultaneously.
"Singleflight" (a term from Go's golang.org/x/sync/singleflight) collapses concurrent identical requests into one in-flight call and shares its result — a general fix for stampedes, not just caches.
- Cache-aside: app manages the cache, simplest, can serve stale data.
- Write-through: always fresh, slower writes.
- Write-behind: fastest writes, risk of loss on cache failure.
- Always set a TTL or eviction policy — unbounded caches are a memory leak with extra steps.
- Guard hot keys against stampedes with a lock or early refresh.
Partitioning & sharding
one database is not enoughWhen one database can't hold or serve all the data, split it into shards, each on its own machine. Range-based sharding (e.g. users A–M on shard 1, N–Z on shard 2) keeps ranges queryable together but can create uneven load. Hash-based sharding (hash the key, mod by shard count) spreads load evenly but makes range queries expensive. Directory-based sharding keeps an explicit lookup table of key → shard, which is flexible but adds a lookup hop.
A hot key is a single key (a viral post, a celebrity account) that gets so much traffic it overwhelms its one shard, no matter how many shards exist. Fixes include splitting the key's traffic further (e.g. appending a random suffix and merging results) or caching it aggressively.
- Range sharding: easy range queries, uneven load risk.
- Hash sharding: even load, expensive range queries.
- Directory sharding: flexible, adds a lookup hop.
- A hot key overloads one shard regardless of shard count — fix the key, not the shard.
Replication & quorums
how many copies must agreeReplication keeps multiple copies of the same data for durability and read scale. In leader–follower replication, writes go to the leader and are streamed to followers; reads can go to either, trading freshness for read capacity. Replication lag is the delay before a follower catches up — read a follower during lag and you can see stale data.
Quorum-based systems make this tunable: with N replicas, a write must be acknowledged by W of them, and a read must query R of them. If W + R > N, every read overlaps with the latest write on at least one replica, guaranteeing you see it — this is strong consistency via quorum overlap, not via a single leader.
- Leader–follower: simple, followers can lag behind the leader.
- W = replicas that must ack a write; R = replicas a read must query.
- W + R > N guarantees the read overlaps the latest write.
- Lower W/R = faster, higher chance of staleness; higher W/R = slower, stronger guarantees.
Timeouts, retries, circuit breakers, back-pressure
failing on purpose, safelyEvery network call needs a timeout — otherwise a slow dependency can hold a thread (or a connection) forever, and enough stuck threads can take down a healthy service. A retry (ideally with exponential backoff and jitter) handles transient failures, but blind retries can turn one slow dependency into a traffic avalanche.
A circuit breaker tracks failures to a dependency: in the Closed state, calls go through normally; once failures cross a threshold, it trips Open and fails fast without even calling the dependency, giving it time to recover; after a cooldown it goes Half-Open and lets one test call through — success closes the circuit, failure re-opens it. Back-pressure is the general idea behind all of this: a system tells its upstream "slow down" (via a bounded queue, a 429 response, or dropping the connection) instead of silently accepting more work than it can do and collapsing.
Retries without backoff, or without a circuit breaker, are how a five-minute blip in one dependency becomes a five-hour outage everywhere that calls it — the retries themselves become the overload.
- Timeouts bound how long a bad dependency can hurt you.
- Retries need backoff + jitter, or they synchronize into a thundering herd.
- A circuit breaker fails fast and gives a struggling dependency room to recover.
- Back-pressure: push overload signals upstream instead of absorbing them silently.
Idempotency & statelessness
the two properties that make retries and scaling safeAn operation is idempotent if doing it once has the same effect as doing it many times. By the HTTP spec, GET, PUT, and DELETE are idempotent — sending the same PUT twice leaves the resource in the same state. POST is not — sending "create an order" twice, naively, creates two orders. Since retries are unavoidable (timeouts don't tell you whether the server actually finished), clients attach a unique idempotency key to unsafe operations, and the server stores "I already handled key X, here's that same response" instead of repeating the side effect.
Statelessness means a server keeps no client-specific state between requests — session data lives in a token (e.g. a JWT) the client holds, or in a shared store (e.g. Redis) any server instance can read. That's what makes horizontal scaling and load balancing trivial in Chapter 1: any server can handle any request.
- Idempotent by spec: GET, PUT, DELETE. Not idempotent by default: POST.
- Idempotency keys make unsafe operations safe to retry.
- Statelessness: no per-client memory on the server — any instance can serve any request.
- Statelessness is what makes horizontal scaling (Ch. 1) and load balancing (Ch. 4) work without sticky sessions.
REST: resources, verbs, status codes
the API is a contract tooREST models the world as resources named by nouns (/orders, /orders/42), not verbs (never /getOrder). The HTTP method says what to do to that resource: GET read, POST create (or trigger a non-idempotent action), PUT replace fully, PATCH update partially, DELETE remove.
The status code is part of the contract, not decoration: 200 OK, 201 Created (with a Location header), 204 No Content, 400 Bad Request (malformed input), 401 Unauthorized (no/bad credentials), 403 Forbidden (authenticated but not allowed), 404 Not Found, 409 Conflict (e.g. duplicate), 422 Unprocessable Entity (well-formed but semantically invalid), 429 Too Many Requests, 500 Internal Server Error, 503 Service Unavailable. A good error contract returns a consistent JSON shape (e.g. {"error":{"code":"...","message":"..."}}) every time, not a different shape per endpoint.
POST /orders// pick a scenario above
- Resources are nouns and URLs; verbs live in the HTTP method.
- Status codes are part of the contract — clients branch on them.
- 409 = conflict with current state; 422 = the request itself is invalid semantically.
- Keep the error body shape identical across every endpoint.
REST: versioning, pagination, HATEOAS
APIs live longer than you planAPIs need versioning because clients update on their own schedule: a URI version (/v2/orders) is explicit and cacheable; a header version (Accept: application/vnd.api+json;version=2) keeps URLs stable but is easy to miss. Either way, never break a version that clients already depend on — add a new one instead.
Offset pagination (?offset=20&limit=10) is simple but drifts under concurrent writes: if an item is inserted before page 2 is fetched, page 2 can repeat or skip a row. Cursor (keyset) pagination (?after=<last_id>&limit=10) anchors to a stable position and doesn't drift, and it's cheaper for the database at high offsets. Filtering belongs in query parameters (?status=shipped), never in the path. HATEOAS means responses include links to the actions available on that resource (e.g. a cancel link on an order that can still be cancelled) so clients discover capabilities instead of hardcoding URLs — the most-cited but least-implemented REST constraint in practice.
- Version explicitly (URI or header); never break a shipped version.
- Offset pagination: simple, drifts under concurrent writes.
- Cursor pagination: stable, cheaper at scale, needs a sortable unique key.
- HATEOAS: let responses tell the client what it can do next.
Contracts & compatibility
a schema is a promise to every callerAn API contract defines the shape of requests and responses (e.g. an OpenAPI spec); a data contract defines the shape of stored or transmitted data (e.g. a database schema or an event schema). Both need a compatibility rule so producers and consumers can change independently. A change is backward compatible if new code can still read data written by old code (a new consumer handles old messages). It's forward compatible if old code can still read data written by new code (an old consumer survives new messages, usually by ignoring fields it doesn't recognize).
Safe changes: adding an optional field, adding a new enum value if consumers already ignore unknown ones, loosening a validation rule. Breaking changes: removing a field still in use, renaming a field, changing a field's type, or adding a new required field with no default. Contract testing (e.g. a consumer-driven contract, or a schema registry that rejects incompatible changes) catches these breaks in CI, before a deploy — not after a downstream service starts throwing errors in production.
- Backward compatible: new code reads old data.
- Forward compatible: old code survives new data.
- Adding optional fields is almost always safe; removing or retyping used fields almost never is.
- Enforce compatibility in CI with contract tests or a schema registry, not by convention.
Event, command, or notification?
naming the message shapes the systemAn event is a fact about something that already happened (OrderShipped) — the producer doesn't know or care who reacts to it; any number of consumers can subscribe. A command tells one specific receiver to do something (ShipOrder) — it implies an expected outcome and usually one intended handler. A notification is a lightweight event carrying little or no payload (just "something changed, go fetch it if you care") — cheap to send, but every consumer pays a round trip to find out what changed.
Whichever shape you pick, the message's schema evolves over time just like an API's, and the same compatibility rules from Chapter 12 apply — plus one more: put a version identifier in every event so consumers can tell old shapes from new ones, and never silently repurpose a field.
- Event: a fact, any number of subscribers, producer doesn't care who listens.
- Command: an instruction, one intended handler, implies an outcome.
- Notification: cheap signal, consumers fetch details themselves.
- Version every event; treat schema changes with the same rigor as an API contract.
Delivery guarantees & the outbox pattern
the DB write and the event publish must not disagreeMost message brokers give you at-least-once delivery by default: a message might be delivered more than once (after a retry, a rebalance, or a crash before an acknowledgment), but never zero times. Exactly-once processing — as an observed effect — is achieved on top of at-least-once delivery by making consumers idempotent (the same idempotency-key idea from Chapter 9: track processed message IDs and skip duplicates), not by a broker magically deduplicating for you.
The classic bug is the dual-write problem: a service writes to its database, then separately publishes an event — but a crash between those two steps leaves the database changed with no event ever sent (or vice versa). The outbox pattern fixes this: write the business row and an outbox row in the same database transaction (so both commit or neither does), then a separate relay process reads the outbox and publishes to the broker, retrying until it succeeds. This guarantees the event is eventually published if and only if the transaction committed.
- At-least-once is the realistic default; build idempotent consumers, don't assume exactly-once.
- Dual write = DB write + separate publish call = a gap where a crash loses one of them.
- Outbox: write data + outbox row in one transaction, relay publishes asynchronously.
- Consumers still need dedup — the outbox guarantees "at least one publish," not "exactly one delivery."
Design a system, step by step
putting all fifteen mechanisms to workWorked example: design a URL shortener. Below is the order that turns a vague prompt into a defensible design — click each stage.
Notice what didn't come first: the database schema. It came after the estimate and the API contract, because the estimate decides whether you need sharding at all, and the contract decides what the data model has to support.
- Estimate first — it decides everything downstream.
- Contract before storage — the API is what callers actually depend on.
- Pick your CAP/consistency trade-off deliberately, in writing, not by accident.
- Design for failure (Ch. 2, 8) from the start — it's expensive to retrofit.
Exam + cheat sheet
check what stuckCheat sheet
| Parameter / concept | What it does | Use when |
|---|---|---|
| Horizontal scaling | Add more machines behind a load balancer | Need to scale past one box; state is externalized |
| Vertical scaling | Bigger machine, same count | Quick fix, low complexity, you accept a ceiling |
| CAP (during partition) | Choose Consistency or Availability | Deciding a data store's failure behavior |
| Consistent hashing | Stable key→node mapping, minimal remap | Load balancing or sharding with elastic node count |
| Cache-aside | App checks cache, falls back to DB on miss | Default caching strategy for read-heavy data |
| W + R > N | Quorum read overlaps latest write | Need strong consistency without a single leader |
| Circuit breaker | Fails fast after a failure threshold | Protecting callers from a struggling dependency |
| Idempotency key | Dedupes retried non-idempotent requests | Any POST that has a side effect and might be retried |
| 409 Conflict | Request valid, but clashes with current state | Duplicate create, version mismatch |
| Cursor pagination | Anchors to a stable key, not a position | Feeds/lists under concurrent writes, large offsets |
| Backward compatible | New code reads old data | Rolling out a consumer/schema change safely |
| Outbox pattern | DB write + event enqueue in one transaction | Publishing an event that must match a DB write |
| At-least-once + idempotent consumer | Realistic "exactly-once" effect | Any consumer of a message broker |
Back to the opening question: why "how many requests per second" first? Because every heuristic above — scale out vs up, which CAP side to pick, how many shards, what quorum — is a function of load. Estimate first, then design. Next: pick one real system you use daily and sketch its 15-mechanism checklist from Chapter 15.