Skip to content

    System Design

    Design Any System: A Repeatable Method With Three New Problems

    Learn an eight-step method for any system design problem, then apply it to a shared rate limiter, a flash-sale ticket booking system and a live leaderboard.

    By Published 26 min read

    • system-design
    • system-design-project
    • design-method
    • rate-limiting
    • leaderboard
    Share

    This lesson is part of Backend System Design Project: Build QuickCart. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to (1) run the same eight-step method on a problem you have never seen, producing a named artifact at each step, (2) apply it end to end to a rate limiter, a flash-sale ticket booking system and a real-time leaderboard, (3) use the twelve shared practices (trade-off matrix, API contract, authentication flow, correlation ids, STRIDE, ADRs and more) as short, reusable tools, and (4) review any design with a one-page checklist and a list of anti-patterns.

    A pilot flies a different route every day but never improvises the checklist. The route changes; the order of checks does not. System design is the same craft. The ten QuickCart stages gave you one route in depth; this lesson gives you the checklist.

    This last lesson is not a QuickCart stage. It extends System Design: A Repeatable Approach, which introduced the eight steps on a small paste-bin service. Here each step gets its questions, its artifact and its QuickCart lesson, and the method meets three problems QuickCart never faced.

    Where QuickCart is now

    After ten stages, QuickCart is an online grocery marketplace with a load balancer, caches and a CDN (Stage 2), a deliberate mix of data stores (Stage 3), sharded orders (Stage 4), an asynchronous order pipeline (Stage 5), a ledger (Stage 6), search, notifications and media (Stage 7), services (Stage 8), resilience (Stage 9) and security and operations (Stage 10). Its targets, all illustrative assumptions, are 2 million daily users and 500,000 orders a day.

    The pain now is different: a new team asks you to design something unrelated to groceries. QuickCart's answers do not transfer; the questions that produced them do.

    The core idea

    A design is a sequence of decisions, and a method makes the sequence explicit so you can inspect it. Each step produces an artifact (a list, a table, a figure) that the next step consumes. If an artifact is missing, the next step is a guess. If a later step contradicts an earlier artifact, change the artifact, not the story.

    Figure 1. The eight steps and the artifact each one produces.

    flowchart LR accTitle: The eight-step design method accDescr: Eight steps from requirements to trade-offs, each with its artifact, with a loop back to requirements when a later step changes an earlier assumption. S1["1 Requirements
    scope list"] --> S2["2 Estimate
    numbers table"] S2 --> S3["3 API
    operations list"] S3 --> S4["4 Data
    entities and queries"] S4 --> S5["5 High-level
    one figure"] S5 --> S6["6 Deep dive
    mechanism"] S6 --> S7["7 Failures
    failure table"] S7 --> S8["8 Trade-offs
    decision record"] S8 -.->|"new fact"| S1

    How to read this: the solid arrows are the normal order; the dotted arrow is the rule that any new fact (an estimate that breaks a requirement, a failure that needs a new component) sends you back to the artifact it changes.

    The method in eight steps

    For each step: what to ask, what to produce, and where QuickCart taught it.

    Step 1: Clarify requirements and scope

    • Ask: who uses it, what are the three key actions, what is out of scope, and what do "fast", "available" and "correct" mean in numbers?
    • Produce: an in and out scope list, non-functional targets with numbers, written assumptions.
    • Taught in Stage 1: functional versus non-functional requirements, SLOs.

    Step 2: Estimate

    • Ask: how many users, requests per second at average and peak, what read-to-write ratio, how fast does data grow, which resource runs out first?
    • Produce: a numbers table with arithmetic, labelled as assumptions, ending "the hard part is ...".
    • Taught in Stage 1: 40 million page views a day became about 463 per second, about 2,300 at peak.

    Step 3: Define the API

    • Ask: what are the operations, who calls them, which writes must be idempotent, how are lists paged, how are errors reported?
    • Produce: an operation list with the idempotency rule for each write.
    • Taught in Stage 8 and Stage 6.

    Step 4: Model the data

    • Ask: what are the entities, the top three queries, what must be strongly consistent, how big is each record?
    • Produce: entities, keys, access patterns and a store per entity with a reason.
    • Taught in Stage 3 and Stage 4.

    Step 5: Draw the high-level design

    • Ask: what is the smallest design that meets the numbers, and which requirement justifies each box?
    • Produce: one figure tracing the main read path and the main write path.
    • Taught in Stage 2: each stage added only what a number demanded.

    Step 6: Deep-dive the riskiest parts

    • Ask: where would a mistake cost the most money or trust?
    • Produce: the mechanism step by step, a worked example and the invariant it protects.
    • Taught in Stage 5 and Stage 6.

    Step 7: Walk the failures

    • Ask: what breaks at ten times the load, and what happens when each box dies, slows down or returns wrong answers?
    • Produce: a failure table: failure, effect, defence, how you will notice.
    • Taught in Stage 9.

    Step 8: State the trade-offs

    • Ask: what did each choice buy, what did it cost, and what would make it wrong?
    • Produce: a trade-off table and, for the choices that matter, a decision record with a measurable "revisit when".
    • Taught in every stage's decisions table, and Stage 10 for cost.

    All figures in the three problems below are illustrative assumptions chosen to keep the arithmetic easy. They do not measure any real system.

    Problem 1: a rate limiter shared by many APIs

    A platform team runs 20 internal and public APIs. Today each API limits traffic in its own way. The team wants one shared service that any API can ask, "may this caller do this now?" For the algorithms themselves, see Rate Limiting; here the job is to design the service.

    Requirements

    • In scope: answer allow or deny for a (subject, rule) pair; per-tenant, per-plan and per-route rules; return remaining quota and a retry delay; change rules without a deploy.
    • Out of scope: monthly billing quotas, bot detection, DDoS protection at the network edge.
    • Targets: the check adds under 5 ms at the 99th percentile; the limiter is more available than the APIs it protects; limits may be exceeded by a few percent, never by a multiple.

    Estimates

    Quantity Assumption Result
    Peak traffic across all APIs 50,000 requests per second 50,000 checks per second
    Active keys 2 million (tenant by route combinations) 2,000,000 keys
    State per key about 100 bytes with overhead 200 MB in total
    Check traffic about 200 bytes per call about 10 MB per second

    Conclusion: the state fits in memory on one machine; the pressure is request rate and tail latency, so availability and the failure policy matter more than storage.

    API

    POST /v1/check
    { "rule": "api-default", "subject": "tenant_42", "cost": 1 }
    
    200 { "allowed": true,  "remaining": 19, "retry_after_ms": 0 }
    200 { "allowed": false, "remaining": 0,  "retry_after_ms": 200 }

    A denial is a normal answer, so the call returns 200; the calling API turns it into 429 Too Many Requests (RFC 6585) with a Retry-After header (RFC 9110, section 10.2.3). Callers use a small client library that caches rules and enforces the 5 ms timeout.

    Data model

    Rules are small and read often: a table with rule, algorithm, capacity, refill_per_second and fail_mode. Counters are tiny, hot and disposable: one Redis hash per key holding tokens and a timestamp, with an expiry. A counter lost in a crash costs a short period of extra generosity, not data.

    High-level design

    Figure 2. Rate limiter service: callers, limiter, counter shards and rules.

    Shared rate limiter serviceAPIs and gateways to Limiter client (in process); Limiter client to Limiter service (check); Limiter service to Redis shard 1 (script); Limiter service to Redis shard 2; Limiter service to Redis shard 3; Rules store to Limiter service (rule updates); Limiter service to Metrics and alerts (decisions)CALLER PROCESSAPIs and gateways: 20 callersAPIs andgateways20 callersLimiter client: rules cache, 5 ms timeoutLimiter clientrules cache, 5 mstimeoutLimiter service: statelessLimiterservicestatelessRedis shard 1: primary and replicaRedis shard 1primary andreplicaRedis shard 2: primary and replicaRedis shard 2primary andreplicaRedis shard 3: primary and replicaRedis shard 3primary andreplicaRules store: source of truthRules storesource of truthMetrics and alertsMetrics andalertsin processcheckscriptrule updatesdecisions

    How to read this: each key hashes to one shard, so a check touches exactly one counter; rules flow to the service as data, so changing a limit never needs a deploy; the client library, not the service, decides what to do when the limiter is slow.

    Why a service, not each API talking to Redis? One place to change algorithms and observe decisions. The cost is one more hop and one more thing to keep up.

    Deep dive: one atomic check

    Two service instances can read the same counter at the same moment; if both see 1 token and both allow, one request is never counted. Read, refill, decision and write must be one indivisible step. Redis states that while a script runs, all other server activity is blocked, so its effects either have not happened or already happened. The script below (Lua, shown as plain text) is a token bucket. It uses the server's clock through TIME, avoiding skew between instances; Redis replicates a script's write commands rather than the script (the default since Redis 5.0), so calling TIME before a write is allowed.

    -- KEYS[1] = bucket key; ARGV = capacity, refill per second, cost
    local t = redis.call('TIME')
    local now_ms = t[1] * 1000 + math.floor(t[2] / 1000)
    local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
    local capacity = tonumber(ARGV[1])
    local rate = tonumber(ARGV[2])
    local cost = tonumber(ARGV[3])
    local tokens = tonumber(data[1])
    local ts = tonumber(data[2])
    if tokens == nil then
      tokens = capacity
      ts = now_ms
    end
    tokens = math.min(capacity, tokens + (now_ms - ts) * rate / 1000)
    local allowed = 0
    local retry_ms = 0
    if tokens >= cost then
      tokens = tokens - cost
      allowed = 1
    else
      retry_ms = math.ceil((cost - tokens) * 1000 / rate)
    end
    redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now_ms)
    redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) * 2)
    return { allowed, math.floor(tokens), retry_ms }

    Worked example: capacity 20, refill 5 per second, cost 1. A burst of 20 checks in the same instant is allowed and the 21st returns {0, 0, about 200}: tokens arrive every 200 ms. The expiry is twice the time to refill from empty, so an idle caller's key disappears, which is equivalent to a fresh full bucket. Scripts are not persisted by the server, so clients call EVALSHA and reload the script if it is missing. Each script touches one key, so it works across cluster shards.

    Figure 3. A check that fails open.

    sequenceDiagram accTitle: Rate limit check with a fail-open timeout accDescr: An API asks the limiter client, which asks the limiter service and Redis. On timeout the client applies the rule's failure mode. participant A as API participant C as Limiter client participant S as Limiter service participant R as Redis shard A->>C: may tenant_42 call? C->>S: check with 5 ms timeout S->>R: run token bucket script R-->>S: allowed, remaining 19 S-->>C: allowed C-->>A: proceed A->>C: may tenant_42 call? C->>S: check with 5 ms timeout S--xC: timeout Note over C: rule says fail open, allow and count the miss C-->>A: proceed

    How to read this: the second request shows the policy that matters most; a login rule would instead fail closed and return a denial.

    Failure modes

    Failure Effect Defence
    A Redis shard dies Keys on it lose state; checks time out Replica takes over; clients apply fail_mode per rule (open for general traffic, closed for login and password reset); alert on the fail-open rate
    One tenant is a hot key One shard takes disproportionate load Split its limit across sub-keys; a local deny cache in the client for callers far over limit
    Bad rule pushed A whole API throttled Rules validated, rolled out per rule with a shadow mode that counts but does not block
    Limiter service slow Every API pays latency 5 ms client timeout, small connection pool, bulkhead per caller

    Trade-offs

    Decision Chosen Cost Revisit when
    Central counters instead of local limits Accurate per-key limits A network hop and a shared dependency Check rate makes the store the bottleneck: then batch token reservations
    Token bucket Bursts allowed, cost per request Two numbers to tune per rule A rule needs exact counts in a window (a login limit)

    Problem 2: ticket booking with a flash sale

    An event has 50,000 seats and tickets go on sale at a fixed time. Far more people want seats than exist, they arrive in the same minute, and no seat may be sold twice. It resembles QuickCart's inventory step in Stage 5, with the contention concentrated on a few thousand rows.

    Requirements

    • In scope: view the seat map; hold up to 4 seats for 5 minutes; pay; release on expiry or cancel; a waiting room for the crowd.
    • Out of scope: resale and dynamic pricing.
    • Targets: a seat is never sold twice; a payment that succeeds always ends in a ticket or a refund; the seat map is at most a few seconds stale; the site survives a crowd far larger than the capacity.

    Estimates

    Quantity Assumption Result
    Seats 50,000, about 200 bytes per row about 10 MB of data
    Fans arriving in the first minute 400,000 400,000 / 60, about 6,700 arrivals per second
    Seat-map polling, if everyone polls every 5 s 400,000 / 5 80,000 requests per second
    Orders at 2 seats each 50,000 / 2 at most 25,000 orders
    Users in seat selection at once cap of 2,000, average 120 s stay admission rate 2,000 / 120, about 17 per second
    Time to sell out if all admitted users buy 25,000 / 17 about 1,470 s, roughly 25 minutes

    The admission figure uses Little's law: users in a stage equal arrival rate times time spent there, so a cap of 2,000 and a 120 s stay admits about 17 per second. Conclusion: the data is tiny; the hard parts are row contention and crowd control, and the waiting room is what keeps the database inside its limits.

    API

    POST /v1/queue/join                      -> 202 { position, token }
    GET  /v1/queue/status                    -> 200 { state: "waiting" | "admitted", admit_token }
    GET  /v1/events/{id}/seats               -> 200 seat map, cacheable for about 1 s
    POST /v1/holds     (Idempotency-Key)     -> 201 { hold_id, expires_at } | 409 { unavailable: [...] }
    POST /v1/holds/{id}/checkout             -> 202 payment started | 410 hold expired
    DELETE /v1/holds/{id}                    -> 204

    Only a caller holding an admission token can create holds. POST /v1/holds takes an idempotency key because mobile clients retry; a repeat returns the original hold.

    Data model

    Seats are the one table that must be correct. A relational database with row locking fits: the data is small and the invariant strict. One event's seats live on one shard.

    CREATE TABLE seats (
      event_id        bigint NOT NULL,
      seat_id         int    NOT NULL,
      status          text   NOT NULL DEFAULT 'available'
                      CHECK (status IN ('available', 'held', 'sold')),
      hold_id         uuid,
      hold_expires_at timestamptz,
      order_id        bigint,
      PRIMARY KEY (event_id, seat_id)
    );

    Orders sit in the same database, so confirming a sale is one transaction. The seat map and queue positions live in Redis and can be rebuilt.

    High-level design

    Figure 4. Flash-sale ticket booking.

    Flash-sale ticket bookingFans to CDN (static); Fans to Waiting room (join); Waiting room to Booking API (admitted); Waiting room to Redis (positions); Booking API to PostgreSQL (holds); Booking API to Rate limiter; Booking API to Payment provider (charge); Hold sweeper to PostgreSQL (expire holds)Fans: 400,000 in a minuteFans400,000 in aminuteCDN: page, cached seat mapCDNpage, cached seatmapWaiting room: admission tokensWaiting roomadmission tokensBooking API: needs a tokenBooking APIneeds a tokenRate limiter: per account and IPRate limiterper account andIPRedis: queue, seat-map cacheRedisqueue, seat-mapcachePostgreSQL: seats, ordersPostgreSQLseats, ordersPayment provider: third partyPaymentproviderthird partyHold sweeper: jobHold sweeperjobstaticjoinadmittedpositionsholdschargeexpire holds

    How to read this: the crowd meets the CDN and the waiting room, never the database; only admitted users reach the booking API, and PostgreSQL is the only place that decides who owns a seat. The rate limiter is Problem 1's service, reused.

    Deep dive: a hold that cannot double-book

    A hold is a conditional update. It succeeds only if the seat is available, or its previous hold has expired:

    BEGIN;
    UPDATE seats
    SET status = 'held', hold_id = $1, hold_expires_at = now() + interval '5 minutes'
    WHERE event_id = $2
      AND seat_id IN (101, 102)
      AND (status = 'available'
           OR (status = 'held' AND hold_expires_at < now()));
    -- The application reads the row count. UPDATE 2 means both seats are held: COMMIT.
    -- Any lower count means a seat was taken: ROLLBACK and return 409.

    Under PostgreSQL's default Read Committed level, two concurrent holds on one seat cannot both win. The documentation says the second updater waits for the first to finish, then re-evaluates the WHERE clause on the new row version; the seat is now held and unexpired, so zero rows change. The invariant lives in the database, not in application code that could race.

    Expiry is lazy, as in the paste-bin example: an expired hold counts as available inside the WHERE clause, so correctness never depends on the sweeper, which only tidies the seat map. Two details complete it. Overlapping holds can lock rows in different orders, so the service locks seats in seat_id order first and retries on a deadlock error. And the server offers "best available", so fans do not all fight for one front-row seat.

    Figure 5. The life of a seat.

    stateDiagram-v2 accTitle: Seat states in a ticket booking system accDescr: A seat is available, becomes held for a limited time, and either becomes sold on payment or returns to available on expiry or cancel. [*] --> Available Available --> Held: hold succeeds Held --> Sold: payment confirmed before expiry Held --> Available: expiry or cancel Sold --> Available: refund and release

    How to read this: only one arrow leads into Sold, and it is guarded by the hold's identity and expiry; every other path returns the seat to the pool.

    The dangerous gap is payment: it can finish after the hold expired and another fan took the seat. Extend the hold when checkout starts (two minutes, illustrative), confirm with UPDATE ... WHERE hold_id = $h AND status = 'held', and if that changes zero rows, refund and tell the fan.

    Figure 6. Hold, pay, confirm.

    sequenceDiagram accTitle: Hold and pay sequence for a ticket booking system accDescr: A fan holds seats, starts checkout, the payment provider charges, and the booking service confirms the sale or refunds. participant F as Fan participant B as Booking API participant D as PostgreSQL participant P as Payment provider F->>B: hold seats 101 and 102 with idempotency key B->>D: conditional update to held D-->>B: 2 rows changed B-->>F: hold id, expires in 5 minutes F->>B: checkout B->>D: extend hold by 2 minutes B->>P: charge with idempotency key P-->>B: success B->>D: update to sold where hold id matches alt 2 rows changed B-->>F: tickets issued else 0 rows changed B->>P: refund B-->>F: sorry, seats were released end

    How to read this: the payment call sits between two database writes, so the confirm step must check the hold again; the idempotency key makes a retried charge safe.

    Failure modes

    Failure Effect Defence
    Payment succeeds after expiry Paid, no seat Extend on checkout, conditional confirm, automatic refund
    Payment provider times out Unknown outcome Idempotency key, a reconciliation job (as in Stage 6)
    Database primary fails mid-sale Sales paused, risk of lost confirms Synchronous replication for the sold state, or accept a brief pause; failover rehearsed before the sale
    Bots flood the queue Real fans pushed back One queue entry per account, signed admission tokens, the rate limiter, challenges at join time

    Trade-offs

    Decision Chosen Cost Revisit when
    Row locks in PostgreSQL instead of a distributed lock One source of truth, simple invariant One shard per event is a ceiling A single event exceeds what one primary handles in a load test
    5-minute holds Time to pay Seats look sold while idle Abandoned holds exceed an agreed share
    First-come queue Easy to explain Rewards fast connections and bots Fairness complaints: consider a lottery inside a time window

    Problem 3: a real-time leaderboard

    A multiplayer game wants a ranked list for each three-month season: the top 100, "your rank", and "players around you". Updates should appear within seconds.

    Requirements

    • In scope: record a score; top N; rank of a player; neighbours of a player; a new board each season.
    • Out of scope: friends-only boards, anti-cheat detection (scores are validated before they reach this system).
    • Targets: a score appears within 2 s (illustrative); ties break deterministically (the player who reached the score first ranks higher); exact ranks for the top 1,000, an approximation is acceptable below that if it is labelled "about".

    Estimates

    Quantity Assumption Result
    Players per season 5 million 5,000,000 members
    Concurrent players 200,000, a match every 10 minutes 200,000 / 600, about 333 updates per second; 5 times peak, about 1,700
    Leaderboard views each concurrent player every 2 minutes 200,000 / 120, about 1,700 per second; 5 times peak, about 8,300
    Memory about 100 bytes per member 500 MB; measure it

    Conclusion: a modest write rate, a read-heavy shape, and a data set that fits in memory on one node. Sharding is not needed yet; keeping rank queries cheap is.

    API

    POST /v1/seasons/{season}/scores        { player, match_id, points }  -> 202
    GET  /v1/seasons/{season}/top?limit=100                              -> 200 [{ rank, player, points }]
    GET  /v1/seasons/{season}/players/{id}/rank                          -> 200 { rank, points, exact }
    GET  /v1/seasons/{season}/players/{id}/around?n=5                    -> 200 [...]

    match_id makes a write idempotent: the same match reported twice counts once.

    Data model

    The source of truth is a durable table of each player's running total, fed from an event log. The ranking is derived: one Redis sorted set per season, member = player id, score = a number encoding points and tie-break. Redis documents sorted sets as unique members ordered by score (equal scores ordered lexicographically), with add, rank lookup and score change at O(log N) and a range query at O(log N + M). Scores are doubles, which hold integers exactly up to 2^53, so the composite is points * 10^7 + (10^7 - 1 - seconds_since_season_start); a 90-day season is 7,776,000 seconds, under 10^7, so reaching a total earlier ranks higher. Example: 4,250 points at 1,000,000 s gives 42,508,999,999; the same total at 2,000,000 s gives 42,507,999,999, which ranks below the first.

    High-level design

    Figure 7. Real-time leaderboard.

    Real-time leaderboardGame servers to Score API (score); Score API to Score log (append); Score log to Leaderboard writer; Leaderboard writer to Score store (totals); Leaderboard writer to Redis ranking (set score); Redis ranking to Season archive (season end); Player apps to Ranking API (view); Ranking API to Redis ranking (rank, range)Game servers: validated scoresGame serversvalidated scoresScore APIScore APIScore log: durableScore logdurableLeaderboard writer: idempotentLeaderboardwriteridempotentScore store: totals per playerScore storetotals per playerRedis ranking: key per seasonRedis rankingkey per seasonSeason archive: frozen boardsSeason archivefrozen boardsPlayer appsPlayer appsRanking API: 1 s cacheRanking API1 s cachescoreappendtotalsset scoreseason endviewrank, range

    How to read this: writes flow through a durable log so the sorted set can always be rebuilt; reads never touch the log, only the sorted set behind a one-second cache.

    Deep dive: idempotent writes and exact versus approximate rank

    Delivery is at least once, so a match event can arrive twice, and ZINCRBY would double-count it. Write the player's absolute total instead: update the score store, then ZADD key GT composite member (GT, added in Redis 6.2, applies the write only if the new score is greater). A replayed event then changes nothing. Reads use ZRANGE key 0 99 REV WITHSCORES for the top 100, ZREVRANK for a player's rank, and ZRANGE key r-5 r+5 REV for neighbours, where r is that rank (clip at 0).

    Exact rank is cheap at 5 million members. Approximation earns its place only when one sorted set stops fitting. The technique is a histogram: an exact set for the top 1,000, and for everyone else a count of players per score bucket (100 points wide, illustrative). Worked example: a player has 4,250 points; 410,000 players sit in higher buckets; the player's bucket (4,200 to 4,299) holds 60,000 players. Rank is about 410,000 + (4,300 - 4,250) / 100 x 60,000 = 440,000. The error is at most one bucket, 60,000 of 5,000,000, which is 1.2 percent, and the API returns exact: false so the app prints "about 440,000th".

    Figure 8. When to leave the exact rank behind.

    flowchart TD accTitle: Choosing exact or approximate rank accDescr: A decision tree. Exact ranking in one sorted set is kept while the set fits one node and rank queries are cheap. Past that, exact ranks for the top and bucketed approximate ranks below. A["Rank query needed"] --> B{"Does one sorted set fit
    memory on one node?"} B -->|yes| C["Exact rank with ZREVRANK"] B -->|no| D{"Does the player sit in
    the top 1,000?"} D -->|yes| E["Exact rank from the top set"] D -->|no| F["Approximate rank from
    bucket counts, label it about"]

    How to read this: exactness is bought with memory and a single hot key; the lesson is to start at the top branch and move down only on measured pressure.

    Failure modes

    Failure Effect Defence
    Redis restarts or fails over Recent writes lost, because replication is asynchronous Rebuild from the score store and event log; replay is safe because ZADD GT is idempotent
    Duplicate or late events Wrong totals match_id deduplication, absolute totals
    Season rollover Late scores land in the old board Events carry a season; the old key becomes read-only at a stated cut-off

    Trade-offs

    Decision Chosen Cost Revisit when
    Sorted set instead of SQL ORDER BY O(log N) rank lookups A second copy of the data to keep in sync The board is small enough that an indexed query is fast enough
    Exact rank first Simple, honest numbers Memory grows with players Members pass what one node holds with headroom
    One key per season Clean rollover and archive A single large key during the season One key becomes a hotspot: split by score range

    The shared practices

    Twelve practices recur in every design. Each is a short tool with a pointer to the lesson that teaches it in full.

    Requirements gathering and scoping

    Ask five questions before drawing: who are the users, what are the three key actions, what is out of scope, what numbers define "good", and what is the worst outcome? Write the answers as an in / out / assumptions page others can edit. See Requirements and Stage 1.

    Trade-off analysis

    When options are close, make the judgement visible with a weighted matrix: weights (1 to 5) say how much a criterion matters, scores (1 to 5) say how well an option does, and the total is the sum of weight times score. This one compares three ways to prevent double-booking in Problem 2.

    Criterion Weight A: conditional update in PostgreSQL B: Redis lock then database C: one writer per event
    Correctness of the invariant 5 5 3 5
    Operational simplicity 3 5 2 2
    Latency under contention 3 3 4 4
    Headroom to scale 2 3 4 3
    Weighted total 55 41 49

    A is 5x5 + 3x5 + 3x3 + 2x3 = 55. The scores are judgements, so treat the matrix as a conversation tool: change a weight and see. With headroom weighted 5 instead of 2 the totals become 64, 53 and 58 and the order holds, so the choice is robust; if the order flipped, the disagreement would be about priorities. See Architecture Styles.

    API contract design

    A contract is what callers can rely on without reading your code. Check each operation for who may call it, idempotency for writes, paging (cursors scale better than offsets), one error format with machine-readable codes, and a versioning rule. Design failures as carefully as successes: the limiter returns 200 with allowed: false, the booking API returns 409 with the seats that failed. See API Design and Stage 8.

    Authentication flows end to end

    Draw the whole flow once, from the login page to the database check. The browser-app flow below is OAuth 2.0 authorization code with PKCE (RFC 6749 and RFC 7636) and OpenID Connect for identity.

    Figure 9. Sign-in and an authorised call.

    sequenceDiagram accTitle: Authorization code flow with PKCE and an authorised API call accDescr: The app sends the user to the authorization server with a code challenge. After login the app receives a code and exchanges it with the code verifier for tokens. It then calls the API with an access token, and the API checks the token and the user's rights on the resource. participant U as User participant A as App participant I as Identity provider participant P as API A->>A: create code verifier and challenge A->>I: redirect with code challenge U->>I: sign in I-->>A: authorization code A->>I: code plus code verifier I-->>A: access token and ID token A->>P: request with access token P->>P: check signature, issuer, audience, expiry, scope P->>P: check this user may touch this resource P-->>A: response

    How to read this: the verifier proves the app that started the flow is the one finishing it; the API then makes two separate checks, one for the token and one for the specific resource.

    The second check is the one most often forgotten: a valid token says who the caller is, not that hold 7 is theirs. Keep tokens short-lived. See Security and Stage 10.

    Caching across layers

    Caching is a stack, and each layer has a different owner and invalidation story.

    Layer Example here Invalidation
    Browser Static assets, versioned URLs New URL
    CDN Seat map for 1 s, images Short time to live, purge
    Application memory Leaderboard top 100 for 1 s, limiter rules Time to live, push
    Distributed cache Queue positions, rank cache Explicit delete, time to live

    Prefer the layer closest to the caller that the staleness budget allows, and write the budget next to each cache. See Caching Advanced.

    Observability and correlation ids

    Give every request one id at the edge and carry it through every hop, log line and event. The W3C Trace Context standard defines the traceparent header as version-trace-id-parent-id-trace-flags, for example:

    traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

    Log the trace id on every line and put it in event headers so asynchronous steps join the same trace. Define two or three indicators in user terms: limiter decision latency and fail-open rate; hold success rate and queue wait; leaderboard staleness. Alert on what a user feels, not on CPU. See Observability and SLOs.

    Failure handling

    For every call decide the timeout, whether a retry is safe (only if the operation is idempotent), the backoff with jitter, what happens when it still fails (fail open, fail closed, degrade, queue), and what the user sees. Writing these next to each arrow is faster than discovering them in production. See Resilience Patterns and Stage 9.

    Security threat modelling

    STRIDE is a mnemonic for six threat types, from Microsoft's threat modelling guidance: spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege. Method: draw the data flow, mark trust boundaries, walk each arrow across a boundary through the six letters, rank by likelihood and impact, assign a defence. Applied to the ticketing design:

    Threat Example Defence
    Spoofing A bot logs in with stolen credentials Strong sign-in, account-bound queue tokens
    Tampering Client edits seat ids or price Price and ownership decided server-side
    Repudiation "I never bought these" Order and payment audit trail with the trace id
    Information disclosure A user reads another's hold Resource-level authorisation check
    Denial of service Queue flooded Rate limiter, one entry per account
    Elevation of privilege Admin endpoint reachable Separate admin network and role checks

    See Security.

    CI/CD and release strategy

    A pipeline is a series of gates: does it build, do tests pass, does it deploy to a test environment, does it survive a canary. Release in small steps: ship behind a flag, send 1 to 5 percent of traffic first, compare error rate and latency with the old version, then widen. Schema changes follow expand, migrate, contract. Limiter rules are data, so release them the same way: shadow mode, then enforce. See Stage 10.

    Architecture decision records

    An ADR is a short record of one decision. Michael Nygard's original form has a title, status, context, decision and consequences. Template:

    ADR-NNN: <short noun phrase>
    Status: proposed | accepted | superseded by ADR-MMM
    Context: the forces at play (value-neutral)
    Decision: "We will ..." in full sentences
    Consequences: good and bad, all of them
    Revisit when: a measurable trigger

    A filled example:

    ADR-007: Seat holds are conditional updates in PostgreSQL
    Status: accepted
    Context: 50,000 seats per event (illustrative). A seat must never be sold
      twice. The data set is small; contention is high for a few minutes.
    Decision: We will hold seats with a conditional UPDATE inside the seats
      table and let row locking arbitrate. Expired holds count as available
      in the WHERE clause. We will not use a distributed lock.
    Consequences: One source of truth and a simple invariant. One shard per
      event caps a single event's throughput. Holds need a sweeper for tidy
      counts only.
    Revisit when: a single event's load test exceeds what one primary handles.

    Keep ADRs next to the code, and supersede rather than edit.

    Cost and performance trade-offs

    More headroom, replicas and cache all cost money and add things to run, so name the dominant cost driver first. The limiter's is the always-on Redis shards; the ticketing system's is the burst, which a waiting room beats over-provisioning all year; the leaderboard's is memory per member and read volume, which the one-second cache cuts from about 8,300 reads a second to roughly one per API instance per second (illustrative). See Capacity and Cost.

    Accessibility and compliance

    Accessibility is a design input. A waiting room must announce queue position to a screen reader, and a countdown is a time limit: WCAG 2.2 criterion 2.2.1, Timing Adjustable (level A), requires that users can turn off, adjust or extend a time limit, with an exception for essential limits. A hold countdown may qualify, but offering one extension is the safe design; a specialist should confirm. Compliance starts with data: collect only what the feature needs, say how long you keep it, and keep card numbers with the payment provider. Ask the compliance owner at step 1; it changes the data model at step 4.

    Decisions and trade-offs

    How much of the method to apply is itself a decision.

    Option Gain Cost When it is the wrong choice
    Full written design with all eight artifacts Shared understanding, reviewable Hours of writing A throwaway prototype
    One-page sketch: scope, numbers, figure, risks Fast, catches most errors Detail left implicit A system that handles money or safety
    Whiteboard only Fastest conversation Nothing survives the meeting When a new team must inherit the design
    Skip to code and see Real feedback Wrong foundations found late Anything hard to change: data model, public API

    In practice: QuickCart on one page

    The situation

    A new engineering lead asks for a one-page view of QuickCart before a design review. The team uses the eight steps as the frame and assembles one figure and one table.

    Starting architecture

    Figure 10. QuickCart on one page, all ten stages.

    QuickCart on one pageAll client apps to CDN; All client apps to API gateway (HTTPS); API gateway to Catalogue and search; API gateway to Cart and checkout; Catalogue and search to Cache (reads); Cart and checkout to Orders and inventory (checkout); Orders and inventory to Orders DB (orders); Orders and inventory to Event stream (outbox); Event stream to Payments (saga); Event stream to Delivery, notifications; Payments to Ledger (entries); Event stream to Warehouse (analytics); API gateway to Traces, metrics, logs (telemetry)SERVICESAll client appsAll clientappsCDN: imagesCDNimagesAPI gateway: auth, rate limitsAPI gatewayauth, rate limitsTraces, metrics, logsTraces,metrics, logsCatalogue and searchCatalogue andsearchCart and checkoutCart andcheckoutOrders and inventoryOrders andinventoryPayments: idempotency keysPaymentsidempotency keysEvent stream: order eventsEvent streamorder eventsDelivery, notificationsDelivery,notificationsCacheCacheOrders DB: shardedOrders DBshardedLedger: double entryLedgerdouble entryWarehouse: analyticsWarehouseanalyticsHTTPSreadscheckoutordersoutboxsagaentriesanalyticstelemetry

    How to read this: left to right is the path of a request: edge, gateway, services, events, data. The dashed arrows are the asynchronous order pipeline from Stage 5; the ledger is only reached through the payment service.

    Component Why it exists What it costs
    Service split Teams own bounded contexts Network calls and distributed failure
    Sharded orders About 58 checkouts per second at peak, with room to grow Cross-shard queries are hard
    Event stream and saga Checkout survives slow steps Eventual consistency, DLQs
    Ledger Money is explainable Double entry discipline, reconciliation

    Options considered

    Option A: draw every box from the ten stage figures. Option B: draw the request path and group neighbours (Orders with Inventory, Delivery with Notifications). Option C: show services only, not data stores.

    The decision

    Option B. Grouped boxes are still separate services, as in Stage 8; grouping only saves space. A buries the request path, and C hides the data decisions.

    Rollout and verification

    Run the checklist below against the figure: each service gets an owner and an SLO, each arrow a timeout and failure policy, each store a restore drill date.

    The 2 a.m. failure

    A shopper reports a charge with no order. Support has the trace id from the receipt. The trace shows the payment step ran twice with the same idempotency key, the second returning the stored result, because the first response was lost to a timeout. The order service had treated that timeout as a failure and its compensation fired.

    The fix is a state rule: a timeout means unknown, never failed; the saga waits for reconciliation before compensating. The trace id found it in minutes, which is the case for step 7 and for correlation ids. This is a realistic scenario, not a case history.

    One-page checklist

    Use it in an interview or a design review. Every "no" is a question for the author.

    • Requirements: Is scope written as in and out, every goal given a number, and every assumption listed?
    • Estimates: Are requests per second, storage and bandwidth worked out with arithmetic and labels, and is the hard part named?
    • API: Are operations, errors and idempotency for writes defined? Are lists paged?
    • Data: Is every entity placed in a store for a stated access pattern? Which data must be strongly consistent? What is the partition key?
    • High-level: Does every box answer to a requirement or a number? Is one read path and one write path traced?
    • Deep dive: Is the riskiest invariant protected by a mechanism, with a worked example?
    • Failures: For each box: dead, slow, or wrong, what happens? Fail open or closed? Timeouts, retries with jitter, DLQs?
    • Security and privacy: Are trust boundaries drawn and STRIDE walked? Where does authorisation happen? What personal data is kept, and for how long?
    • Operations: Alerts in user terms, correlation ids, canary release, rollback, a tested restore?
    • Cost and trade-offs: Dominant cost driver named? Each major decision has its cost and a "revisit when"?
    • Accessibility: Do time limits, queues and errors work with a keyboard and a screen reader?

    Common mistakes and anti-patterns

    Anti-pattern Why it hurts Better move
    Technology first ("use Kafka") Solves an undefined problem Finish steps 1 and 2 before naming products
    No estimate Choices float free of reality Two-minute arithmetic, labelled
    Only the happy path Production is mostly exceptions Failure table for every box
    Distributed lock for a database-sized problem Two sources of truth and clock risk Let the database arbitrate (Problem 2)
    Cache without a staleness budget Bugs nobody can reproduce Write the budget beside the cache
    Retry without idempotency Duplicate charges and counts Idempotency keys; absolute writes (Problem 3)
    Fail-closed on everything, or fail-open on everything Outage or no protection Decide per rule and document it
    Treating a timeout as failure Compensation on a call that succeeded Timeout means unknown; reconcile

    Architect's notes

    • Start with the numbers page. It ends arguments early and shows the hard part.
    • Put the invariant in the data store. Code can race; a constraint or conditional update cannot.
    • Defer sharding and multi-region until a number demands them, but write the trigger down.
    • Rehearse failure before the event. A flash sale is a one-shot; a rehearsal at twice the crowd is cheap.
    • Review the method, not just the design. If a step has no artifact, ask for it.

    Key takeaways

    • Order matters. Cheap-to-change decisions first, expensive-to-change ones later.
    • Every step makes an artifact, and a missing artifact means the next step is a guess.
    • Estimates name the hard part: throughput for the limiter, contention for tickets, read shape for the leaderboard.
    • Put invariants where they cannot be bypassed: the database for seats, an atomic script for counters, absolute writes for scores.
    • Every dependency needs a failure policy: a timeout, a retry rule and an open-or-closed choice.
    • Trade-offs are a table with a cost and a "revisit when".

    Practice exercises

    Exercise 1 (Starter, 30 min): Take the limiter's failure table and add two rows of your own: one about clock behaviour and one about a deploy gone wrong. For each, name the defence and the metric that would show it.

    Exercise 2 (Intermediate, 2 hours): Redo Problem 2's estimates for 120,000 seats and 1 million arrivals in the first minute, keeping the 2,000 concurrency cap and the 120 s stay. Compute the admission rate and the sell-out time, and say which assumption moves the result most.

    Exercise 3 (Advanced, half day): Choose a system you use daily (for example a food-delivery tracker or a video call scheduler). Run all eight steps in two pages, add a decision matrix, one STRIDE table and one ADR, then walk the checklist and list three gaps.

    Official documentation

    Enroll to keep reading

    Lesson 11 of 11 · Backend System Design Project: Build QuickCart

    Learn an eight-step method for any system design problem, then apply it to a shared rate limiter, a flash-sale ticket booking system and a live leaderboard.

    In the rest of this lesson

    • Problem 3: a real-time leaderboard
    • The shared practices
    • Decisions and trade-offs
    • In practice: QuickCart on one page
    • One-page checklist
    • Common mistakes and anti-patterns

    Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments