Chapter
Draw the Smallest Complete System
Connect clients, application logic, and durable storage first, then add components only for measured pressure or failure requirements.
Learning objectives
- Draw a complete read and write path before optimizing either
- Explain the responsibilities of DNS, load balancing, services, caches, and databases
- Scale the design by responding to a specific bottleneck or failure
- Review an architecture against requirements, estimates, and failure behavior
Start with a system that can actually complete the work
Treat this chapter as three short study sessions, not one list of technologies:
- Make it correct: trace creation, retries, and redirects.
- Respond to pressure: add service capacity and consider a cache.
- Reason about failure: compare replication choices, then attempt a different system.
At each boundary, close the text and explain the design so far. If you cannot say why a component exists, revisit its scenario before adding the next one. Begin by recalling the difference between a unique short code and an idempotency key.
We now have requirements, workload estimates, API contracts, and a data model. The first architecture should be the smallest arrangement that performs both core workflows correctly:
Client → URL service → links database The service validates creation requests, generates codes, reads mappings, and produces redirect responses. The database makes accepted mappings durable.
This design is intentionally plain. It is valuable because every component has a necessary responsibility. It also gives us a baseline: when a requirement or estimate exceeds it, we can identify exactly what to change.
Trace the write path
For POST /links:
1. Client sends destination, expiration, and an idempotency key.
2. Service validates the destination and applies rate limits.
3. In a transaction, claim the unique request key.
If already committed, compare the payload and replay or reject as contracted.
If another transaction owns it, wait or return a retryable conflict.
4. Generate a code and insert the mapping with a unique constraint on code.
5. On a code collision, roll back and retry the transaction with a new code.
6. Save the request payload and successful response with the claimed request key.
7. Commit the link and request result together, durably, then return the short URL. The acknowledgment boundary matters. If the service returns success before durable storage, an application crash can erase a link the user was told exists.
The database uniqueness constraint is the final authority on collisions. Checking whether a code exists and then inserting it as two unrelated operations creates a race:
service A checks code X → absent
service B checks code X → absent
service A inserts X
service B inserts X An atomic unique insert allows only one to succeed.
Here atomic means the operation cannot leave a half-applied result. A transaction extends that idea across the link and request record. This pseudocode describes the required behavior; the chosen database determines exact locking and conflict handling. Use bounded retries, not an infinite collision loop, and return a temporary error if the operation cannot complete.
Crash between commit and response
The database commits both records. Before sending the response, the service crashes. The client retries through a different service instance. What should happen? What would go wrong if the request-key record had existed only in the crashed process’s memory?
Walk through the failure
The new instance finds the committed request result, verifies the payload, and returns the original short URL. Process-local memory would have lost the key, allowing a second link. A crash before commit instead leaves neither record, so the retry can perform the operation. If commit status is uncertain, retry with the same key rather than assuming that a timeout proves nothing was written.
Trace the read path
For GET /{code}:
1. Service validates the shape of the code.
2. Service reads the committed mapping by primary key from the primary database.
3. Service verifies that it has not expired.
4. Service returns a redirect with Location and Cache-Control: no-store headers. Unknown and expired codes follow the contract’s explicit errors; storage failure returns a temporary error, not a misleading “not found.” Reads from the primary preserve the brief’s immediate visibility after a successful creation. Disabling links is not in our baseline schema. Adding moderation later requires a disabled state and a policy for invalidating cached records, not merely an extra condition in this flow.
This is functionally complete, not yet a production-ready reliability design: the single application instance and database can still stop service. Before proceeding, draw both paths from memory and mark the point after which creation may report success.
Add multiple service instances for capacity and availability
Session 2: change one constraint at a time. Suppose an instance restarts during a deployment. With one instance, no process remains to serve redirects. This is a reason to add a second healthy instance even if traffic is small.
The peak estimate is about 12,000 redirects per second. One service process may not safely handle that load, and one process is also a single failure point.
Run several instances and place a load balancer in front:
┌→ URL service A ┐
Client → load balancer ──┼→ URL service B ├→ links database
└→ URL service C ┘ A load balancer accepts traffic at one address and distributes requests among healthy service instances. Health checks help it stop sending work to an instance that cannot serve requests.
The service instances should be stateless with respect to link mappings: any healthy instance can handle the next request because durable shared state lives outside its process. An instance can restart without losing accepted links.
This does not mean an application holds no temporary memory. It means correctness does not depend on a client returning to the same instance.
Does adding a server fix every bottleneck?
Application CPU is mostly idle, but the database is saturated with repeated lookups of popular links. Would doubling application instances solve the measured bottleneck?
Compare the diagnosis
Not by itself: both sets of instances still query the same database. They could even increase pressure. Investigate the queries and repeated reads; a mapping cache is a candidate here. If application CPU were the bottleneck instead, more instances could help.
Where DNS fits
Users type a domain such as sho.rt, while networks route to numeric addresses. The Domain
Name System, or DNS, resolves the domain to the public endpoint serving the application,
often a load balancer or edge service.
sho.rt → DNS lookup → public endpoint → load balancer → service DNS responses are cached, so changing them is not an instantaneous per-request routing mechanism. It provides naming and coarse routing; the load balancer handles live request distribution among service instances.
Add a cache for the read-heavy path
Redirects outnumber creations about 100 to 1, and popular links may be requested repeatedly. A cache can keep frequently used mappings in faster storage:
Client → load balancer → URL service → cache
├─ hit → return mapping
└─ miss → database → fill cache A cache hit finds the mapping in the cache. A cache miss falls back to the database. The service, not the cache, remains responsible for checking the mapping’s expiration.
Cache-aside flow
For a redirect:
- Read the code from cache.
- On a hit, validate the cached record and redirect.
- On a miss, read the database.
- If a valid mapping exists, place it in cache with an expiration time.
- Redirect.
Only successful, active mappings may be cached initially. Caching “not found” results can protect the database from repeated invalid codes, but a short negative-cache lifetime is needed so a newly created code does not remain falsely absent.
For our immediate-visibility requirement, do not add negative caching yet. A short stale not-found response would still violate the requirement. Introducing it later needs invalidation or a changed visibility contract.
Our MVP mappings are immutable, which simplifies caching. Expiration can be enforced with a cache TTL no later than the link’s own expiration. If editing is added later, writes must invalidate or update cached copies.
Now try an editable destination. The database changes from A to B, but the cache still says A. Choose either a permitted stale interval or an invalidation strategy, and state what happens if invalidation fails.
Compare the policy, not just the mechanism
If the product permits one minute of stale redirects, a bounded TTL may be acceptable. If it requires immediate changes, a TTL alone is insufficient. One simple first design is to bypass the mapping cache for mutable records and read authoritative state. “Delete the cache entry” without considering failures or concurrent refills is not a full guarantee.
If the cache fails, falling back to storage is safe only while storage can absorb the additional load. Use a short cache timeout, bound database concurrency, and reject excess work with temporary errors rather than allowing an unbounded pileup. A cache outage is a capacity test, not just an instruction to “read the database instead.”
Protect the database from becoming the next single point
Session 3: decide which failures the design tolerates. First retrieve why multiple application instances did not remove the database bottleneck. The same dependency also matters when the database stops responding.
Multiple service instances do not help if every request depends on one database process that can fail. Storage replication keeps additional copies of data.
A common arrangement has one primary accepting writes and replicas serving as recovery targets or, when consistency requirements permit, reads:
service → database primary → replica
→ replica With asynchronous replication, the primary may acknowledge a write before a replica receives it. Imagine the new link is committed at 10:00:00 and copied at 10:00:02. A replica read between those moments can falsely report that the link does not exist. If the primary is permanently lost during that gap, promoting that replica can also lose the acknowledged write. This is why our baseline does not send immediate post-creation reads to a replica.
With synchronous replication, acknowledgment waits for the configured replica or quorum to confirm the required write state. This can protect acknowledged data against specified failures, but adds waiting and can block writes when required peers cannot be reached. The word “synchronous” alone does not specify how many copies are durable or which failures are covered; state those assumptions.
Failover means moving the primary role to another node. A safe design must prevent the old primary from continuing to accept conflicting writes, route clients to the new primary, and tell clients whether to retry during the interruption. Replicas also copy accidental deletions; separately retained, restore-tested backups address a different risk.
Choose against a requirement
The product now requires an acknowledged link to survive loss of the primary machine. Is an asynchronously updated replica sufficient? What cost might a stronger write policy introduce?
Compare the tradeoff
Not necessarily: the primary can fail before sending the newest acknowledged write. Require acknowledgment from another durable copy with a failover policy that preserves committed data under that failure model. The tradeoff is additional latency and potentially unavailable writes when required replicas cannot acknowledge. This requirement is stronger than merely surviving an application restart.
Replicas improve availability and durability only when failure detection and recovery are designed and exercised. “Three replicas” is not itself a recovery plan.
Keep analytics off the redirect’s critical path
Analytics was outside the MVP, but it illustrates asynchronous work. If every redirect waits for an analytics database write, an analytics slowdown increases redirect latency and may make redirects unavailable.
A future design could publish a small click event to a durable queue:
redirect service → return redirect
└────────→ event queue → analytics workers → analytics store A queue holds work until consumers can process it. This separates redirect latency from analytics processing speed, but it introduces delivery, duplication, retention, and backlog-monitoring questions. Add it when the feature exists, not merely because queues are common in diagrams.
There is still a choice at the branch: return the redirect first and a crash may lose the event; wait for durable enqueue and queue latency/failure affects the redirect. “Asynchronous” removes the wait for analytics processing, not every reliability tradeoff. Because analytics is outside this MVP, leave the branch out of the baseline rather than promise lossless events with no effect on redirect availability.
Choose Scaling Components for Specific Bottlenecks
| Pressure or requirement | Targeted response |
|---|---|
| One service cannot handle peak requests | Add stateless instances behind a load balancer |
| One service failure must not stop redirects | Health-check multiple instances |
| Repeated reads overload storage | Cache popular immutable mappings |
| Storage process failure loses availability | Replicate data and define failover |
| Data no longer fits or one write leader saturates | Partition by a stable key after measuring the bottleneck |
| Analytics slows redirects | Move analytics to an asynchronous event path |
This is the central discipline of system design: every added component should answer a named pressure, failure, or product requirement.
Guided design: a URL shortener
Close the chapter and reproduce the design without copying its boxes. Use this order:
1. Restate the problem
Write the two core workflows, exclusions, scale assumptions, and top three quality priorities. If you change an assumption, note how it may alter the design.
2. Recalculate the scale
Estimate average and peak read/write QPS, five-year storage, and the size of a seven-character base-62 namespace. Keep units visible.
3. Define the contract and record
Sketch create and redirect APIs, important errors, an idempotency strategy, and the minimal link record. Identify the unique key and dominant lookup.
4. Draw the baseline
Show the complete creation and redirect paths through one service and durable storage. Mark where success is acknowledged.
5. Add only justified components
Use the estimated peak and failure requirements to decide whether you need multiple service instances, load balancing, cache, replication, or partitioning. Write one sentence beside each added box explaining why it exists.
6. Walk through failures
Explain behavior when the cache is unavailable, the database is slow, a generated code collides, a service instance fails, and a link expires while cached.
Self-review rubric
Score each dimension from 0 to 2:
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| Requirements | vague or missing | behaviors listed | scope, scale, and priorities explicit |
| Estimates | absent | numbers without consequences | units, peaks, and design consequences shown |
| Data and APIs | generic | happy path only | access patterns, errors, uniqueness, and retries covered |
| Architecture | technology list | paths mostly connected | every component has a requirement-backed responsibility |
| Tradeoffs | one “best” answer | alternatives named | rejected alternatives explained against priorities |
| Failures | ignored | failures listed | behavior and recovery described |
Use these descriptions to find weaknesses, not as a validated interview score or pass mark. Do not let a high total hide a broken guarantee: acknowledging before storage, losing retry identity, or redirecting expired links each requires correction regardless of other scores.
Independent transfer: a notification service
Now use your notification brief, estimates, and API from the earlier chapters. Work without the URL-shortener diagram. Draw how a request is accepted durably and later sent through an external email or push provider. Show status lookup, retry identity, and what happens if a sending process crashes. Introduce a component only when you can explain its job.
The key difference: redirecting reads a mapping; sending a notification performs an external action. Our database transaction cannot automatically include the provider.
A starting hint, not the whole design
Separate “we retained your request” from “the provider accepted the send.” Could a durable job table hold pending work while another process performs sends? Where would that process record status? What if the provider acts but its response is lost?
Compare with one defensible first design
An API transaction saves a job and its request-key result, then returns 202 with a job
ID. A worker—a process that performs pending work—claims a job for a bounded period,
sends through the provider, and records the result. A status API reads by job ID. A durable
job table can serve as the initial work queue; a separate broker is not automatically needed.
An expired claim allows another worker to recover abandoned work, but does not prove that
the first worker never sent the message.
If the provider accepts a send and the worker crashes before recording success, retrying can send twice. Prefer a stable job ID as the provider’s idempotency key if supported, within its documented retention window. Otherwise acknowledge the duplicate-versus-loss tradeoff: retry for delivery at the risk of duplicates, or stop for investigation at the risk of leaving work undelivered. Do not claim exactly-once delivery from a local unique key.
Compare your artifacts: do the workers cover the estimated arrival rate and recovery backlog? Does acceptance wait for durable storage? Do provider outages preserve pending jobs? Have you distinguished API retry deduplication from external-send deduplication? Fix an unsupported guarantee before adding more infrastructure.
A short review loop
Next session, reconstruct the URL-shortener write path and the notification failure case from memory, then compare with the text. Later in the week, ask someone—or an AI acting as an interviewer—to change one assumption: editable links, a cache outage, or a provider timeout. Explain which part of your design changes and which stays the same.
Record a missed explanation and retry it later. Reading completion is useful bookkeeping; being able to rebuild, explain, and adapt the design is the evidence of understanding.
Continue with authoritative material
Use production architecture frameworks as review lenses rather than vendor-service shopping lists:
- Google Cloud Well-Architected Framework organizes guidance around operational excellence, security, reliability, cost, performance, and sustainability.
- AWS Well-Architected Framework provides questions for evaluating architecture tradeoffs across similar qualities.
- Google’s Site Reliability Engineering books connect design to reliability objectives, monitoring, incident response, and secure operation.
- MIT 6.5840 Distributed Systems is optional deeper study for replication, consistency, and fault tolerance after the foundation is comfortable.
These sources go beyond the current MVP. Later parts of this curriculum should introduce their ideas only when a concrete system creates the need.
Part checkpoint
You can now move from an ambiguous product request to a defensible first architecture:
requirements → estimates → API and data → baseline paths → targeted scaling → failure review The next part should deepen individual building blocks—caching, storage, replication, partitioning, asynchronous work, and reliability—without losing this end-to-end sequence.