Classic Designs: Putting It Together
Last verified against its sources on 23 September 2026
Every prompt in this module is a recombination of decisions already made in this course, not a new toolkit. This module walks a URL shortener, a news feed, a chat system, and a rate limiter end to end — scoping each with named numbers, then choosing the API, data model, storage, sharding, caching, and messaging pieces that those numbers actually call for, and naming out loud which earlier decision each piece reuses.
Designing a URL Shortener
- Design a URL shortener's API, data model, and short-code generation approach that meets a stated read-heavy QPS and storage target.
- Choose caching and database placement for a URL shortener that meets a stated redirect latency SLO.
Every classic system design prompt is really the same exercise repeated: scope it, estimate it, then let the numbers choose the architecture, rather than reaching for a familiar tool first. For a URL shortener, the functional list from module one still holds — create a short link, redirect a short code, optionally a custom alias or expiry — and the numbers estimated there (100 million creates a day, redirects far outnumbering creates, on the order of 90 TB over five years) are the ones that decide everything from here. The non-functional target worth pinning down explicitly: redirects need to answer in well under 200 ms, because a slow redirect feels broken in a way a slow create call doesn't.
The data model is close to the simplest one this course covers: a single links table keyed by code, holding long_url, owner_id, created_at, and an optional expires_at — normalized, since there's no relationship complex enough to justify copying data around. The interesting design decision is how code gets generated. Encoding an auto-incrementing integer ID in base62 gives short, collision-free codes with no coordination between servers, at the cost of codes that reveal creation order; generating a random string and checking it against the table for a collision needs a uniqueness check on every create, which is cheap at this table's size but adds a round trip. The API from module one still applies directly: POST /links creates, GET /r/{code} redirects with a 301 or 302 status per RFC 9110's redirection semantics, and code is exactly the index this course covered earlier — the one column every redirect reads by.
With redirects dominating traffic by orders of magnitude, this design earns its keep from caching: a cache-aside cache in front of links, keyed by code, turns the overwhelming majority of redirects into a cache hit that never touches the database at all, and because a link's long_url essentially never changes after creation, a long TTL (or no TTL at all, invalidated only on explicit delete) is safe rather than reckless — this is exactly the read-heavy, rarely-written case the caching module's read/write framework was built for. The database underneath is relational, chosen the same way the storage module framed the choice: no cross-row transaction is needed, but a unique constraint on code is exactly the kind of guarantee a relational schema enforces for free. At today's estimated scale a single primary with a read replica or two covers it; sharding by a hash of code is the fallback once write volume or table size outgrows that, not a day-one requirement.
A links row's long_url is set once at creation and never updated afterward. What does this fact let the cache TTL policy safely do that a frequently-updated field couldn't?Answer it yourself first, then open this.
Use a very long TTL (or skip expiry and invalidate only on explicit delete) — since the underlying value can't change out from under the cache, the usual staleness risk from a long TTL doesn't apply here.
Designing a News Feed
- Choose between fan-out-on-write and fan-out-on-read for a news feed based on a stated follower-count distribution.
- Apply sharding and caching to a news feed's data model to meet a stated feed-load latency SLO.
A news feed's functional list is short — post an update, follow a user, view a feed of updates from everyone you follow — but the design hinges entirely on one decision: does the system compute each user's feed when they post, or when they view? Fan-out-on-write does the work at post time: when a user posts, the system immediately pushes that post into every follower's precomputed feed, so a feed read is just reading a list that's already built. Fan-out-on-read defers the work: a feed read queries every account the user follows and merges the results at request time, so posting is cheap and viewing does the work. Given a target like 500 million DAU and a feed-load SLO of a few hundred milliseconds, the choice is entirely about where you'd rather pay that cost.
Fan-out-on-write's failure mode is the same hot-key problem this course already named for sharding: a user with 50 million followers who posts once triggers 50 million individual feed-list writes, all from a single event, which looks exactly like the hot-shard scenario worked out earlier — a small number of keys (here, a small number of accounts) generating a wildly disproportionate share of write traffic. Fan-out-on-read has no such spike, but it pays a steady cost on every single feed view instead: merging updates from potentially thousands of followed accounts on every read is expensive precisely where the SLO is tightest. The common answer splits the difference by account size: fan-out-on-write for ordinary accounts, where the follower count is small enough that the write fan-out is cheap, and fan-out-on-read (merged in at view time) specifically for the small number of very-high-follower accounts that would otherwise create a hot key.
The data model mirrors the read pattern chosen: a posts table (normalized, one row per post) plus, for fan-out-on-write accounts, a feed_items table keyed by (user_id, post_id) that's exactly what gets read on a feed view — denormalized on purpose, the same trade this course covered when copying data to where it's read pays for itself. That feed_items table is itself a strong sharding candidate, partitioned by user_id the same way any table is once one node can't hold the write volume, since every fan-out write and every feed read is naturally scoped to one user. A cache-aside cache in front of the most-recently-viewed feed pages absorbs the read traffic from users who reload their feed repeatedly in a short window, the same pattern used for the URL shortener's redirect path.
An account with 80 million followers posts once. Under pure fan-out-on-write, what does this single event turn into, and why is that a problem this course has already named?Answer it yourself first, then open this.
80 million individual feed-list writes from one post — this is the hot-key/hot-shard problem: a small number of accounts generating a wildly disproportionate share of write traffic.
Designing a Chat System
- Design a chat system's delivery path using a persistent connection and a durable per-recipient queue, matching a stated delivery guarantee.
- Choose a data model that meets a stated per-conversation ordering requirement.
A chat system's functional list adds one thing the systems so far haven't needed: a live push to a connected client, not just a request-response. Send a message, deliver it to a recipient who's online right now, deliver it the next time they connect if they're not, load message history for a conversation — and the non-functional targets worth pinning down are a delivery SLO (messages should arrive in under a second when the recipient is online) and an ordering requirement (messages within one conversation must display in the order they were sent, even if delivery timing varies).
Delivering to an online recipient needs a connection the server can push through rather than one the client has to keep re-polling — a long-lived, bidirectional connection (commonly WebSocket) held open between client and server for exactly this purpose. But "push it down the open connection" only covers the recipient who's actually connected right now; the same at-least-once delivery problem this course covered for queues shows up here directly: a message sent while the recipient is offline has to be held somewhere and delivered once they reconnect, which is exactly a per-recipient queue — durable storage that survives a disconnect, delivered and acknowledged the same way any queued message is, with the same requirement that a client's message-handling logic tolerate a duplicate delivery rather than assume exactly-once.
The ordering requirement maps directly onto a concept this course already covered for streams: Kafka guarantees order only within a partition, and a chat conversation needs exactly that same scoped guarantee — order within one conversation, not globally across every conversation on the platform. A messages table keyed by (conversation_id, sent_at, message_id) gives that ordering for free from a single index, and sharding that table by conversation_id (not by a global auto-increment) keeps every write and every history read scoped to one shard, the same way a stream's per-key ordering only holds when related events share a partition key. Loading history is a straightforward range query on that index; nothing about it needs a cache as urgently as the URL shortener's redirect path did, since chat history is read far less often relative to how much of it accumulates.
Why does keying the messages table by (conversation_id, sent_at, message_id) — and sharding by conversation_id — give ordering within a conversation "for free," using a concept from the streams lesson?Answer it yourself first, then open this.
It mirrors Kafka's per-partition ordering guarantee: scoping both the shard and the sort order to conversation_id means every message in one conversation lands together and reads back in order, the same way a stream only guarantees order for events sharing a partition key.
Designing a Rate Limiter
- Design a distributed rate limiter's data storage and request path using a shared store, meeting a stated per-client throughput target.
- Explain why the check-and-increment step of a shared rate limiter must be atomic.
The messaging module covered the token bucket algorithm conceptually — a rate and a burst size, tokens spent and refilled. Building one is a storage problem before it's an algorithm problem: if each of a fleet of stateless application servers keeps its own in-memory count of a client's requests, a client hitting a load balancer that spreads traffic across ten servers effectively gets ten times its intended limit, because no server knows what the other nine have counted. A rate limiter enforcing, say, 100 requests/minute per API key across a fleet of stateless servers needs its counters to live somewhere every server can see — which is precisely the shared-state problem a cache like Redis, already covered in this course, is built to solve.
A simple fixed-window version of this is a direct application of two commands this course already covered: INCR on a key like ratelimit:{api_key}:{current_minute} bumps the count, and EXPIRE on that same key, set once when the counter is first created, clears it automatically at the window boundary — the exact TTL mechanism from the caching module, repurposed as the reset clock for a rate-limit window instead of a cache-eviction clock. If the incremented count comes back over the limit, the request is rejected with a 429, the same response this course's rate-limiting lesson named for API Gateway's own throttling. The one new subtlety: checking the count and deciding whether to reject has to happen as a single atomic step against the store, not as a separate read-then-write from the application, or two concurrent requests from the same client can both read the count just under the limit and both be allowed through — the same class of race a lock or an atomic increment exists to close.
A fixed window has a real edge case worth naming: a client that sends its whole limit in the last second of one window and its whole limit again in the first second of the next window bursts to twice the intended rate across that boundary, even though neither individual window was exceeded. A sliding-window or true token-bucket-in-Redis implementation (tracking a weighted count across the previous and current window, or storing token count and last-refill timestamp directly) closes that gap at the cost of a more complex update than a single INCR. Which version is worth building is, like everything else in this course, a question with a number attached: a fixed window is enough when a client bursting to 2x for a few hundred milliseconds at a boundary is an acceptable risk; it isn't when that burst is exactly the failure mode the rate limiter exists to prevent.
Why can't each application server in a fleet just keep its own local, in-memory request count per client for rate limiting?Answer it yourself first, then open this.
Because a client's requests are spread across every server behind the load balancer — a per-server count only sees a fraction of that client's true traffic, so the effective limit becomes (servers × intended limit) instead of the intended limit.