Chapter

Draw the Smallest Complete System

Connect clients, application logic, and durable storage first, then add components only for measured pressure or failure requirements.

110 min 4 objectives
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

My notes & review

Start with a system that can actually complete the work

Treat this chapter as three short study sessions, not one list of technologies:

  1. Make it correct: trace creation, retries, and redirects.
  2. Respond to pressure: add service capacity and consider a cache.
  3. 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:

Code
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:

Code
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:

Code
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.

Checkpoint

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}:

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:

Code
                         ┌→ 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.

Checkpoint

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.

Code
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:

Code
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:

  1. Read the code from cache.
  2. On a hit, validate the cached record and redirect.
  3. On a miss, read the database.
  4. If a valid mapping exists, place it in cache with an expiration time.
  5. 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:

Code
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.

Checkpoint

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:

Code
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 requirementTargeted response
One service cannot handle peak requestsAdd stateless instances behind a load balancer
One service failure must not stop redirectsHealth-check multiple instances
Repeated reads overload storageCache popular immutable mappings
Storage process failure loses availabilityReplicate data and define failover
Data no longer fits or one write leader saturatesPartition by a stable key after measuring the bottleneck
Analytics slows redirectsMove 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:

Dimension012
Requirementsvague or missingbehaviors listedscope, scale, and priorities explicit
Estimatesabsentnumbers without consequencesunits, peaks, and design consequences shown
Data and APIsgenerichappy path onlyaccess patterns, errors, uniqueness, and retries covered
Architecturetechnology listpaths mostly connectedevery component has a requirement-backed responsibility
Tradeoffsone “best” answeralternatives namedrejected alternatives explained against priorities
Failuresignoredfailures listedbehavior 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:

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:

Code
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.