The Solutions-Consulting System-Design Interview¶
What this lesson gives you
A six-step framework that mirrors how real architecture decisions are made, with capacity estimation, NFR depth, common design templates, and the consulting differentiator that wins debrief rooms.
Estimated time: 42 min read · Part: Cracking the Tier-1 Interview
The system design interview is the closest thing to an actual client engagement compressed into 45 minutes. You are given an ambiguous requirement (“design Twitter”), a whiteboard or virtual canvas, and an interviewer who will probe until they find where your knowledge ends. The candidates who perform best do not have the deepest knowledge of any single system. They have a process — a structured way to move from requirements to trade-off-aware architecture — and they apply it every time regardless of the prompt.
The candidate who named Kafka before asking a single question
In a solutions engineering loop at a cloud data firm, one candidate opened with: “I’d use Kafka for the event bus, Redis for caching, and Postgres for the primary store.” The interviewer probed: “How many events per second are we expecting?” The candidate paused. “I’m not sure.” “What’s the consistency requirement?” Another pause. The candidate had excellent knowledge but no framework. She named technologies that might be perfectly right — but because she had anchored before understanding requirements, every subsequent discussion felt like a defence rather than a design. The hire decision was a borderline no. The debrief note: “Named tech before understanding the problem.”
The Six-Step Framework¶
flowchart LR
A["① Requirements<br/>(Functional + NFR)"] --> B["② Estimate<br/>(QPS, storage, BW)"]
B --> C["③ High-Level Design<br/>(boxes & arrows)"]
C --> D["④ Deep Dive<br/>(interviewer picks 1–2)"]
D --> E["⑤ Scale<br/>(bottlenecks + fixes)"]
E --> F["⑥ Wrap-Up<br/>(trade-offs + open Qs)"] Step 1 — Requirements (5 min). Gather functional requirements (what the system does) and non-functional requirements (how well it does it). Never skip this step. Functional: what actions does a user take? What does the system produce? Non-functional: scale, availability, latency, consistency, durability, compliance, geo distribution.
Step 2 — Estimate (3–5 min). Back-of-envelope numbers to size the system. You need QPS (queries per second), storage, and bandwidth. These numbers constrain every technology choice that follows.
Step 3 — High-Level Design (10 min). Draw boxes and arrows. Label every component. Draw data-flow arrows with a direction. Mark read vs write paths explicitly. Do not go deep on any single component yet.
Step 4 — Deep Dive (15–20 min). The interviewer will pick one or two components to explore. Follow their lead. Go deep on whatever they signal. Common deep dives: the database schema, the caching layer, the message queue fan-out strategy, the rate limiter algorithm.
Step 5 — Scale (5 min). Identify the top two or three bottlenecks and propose fixes. Read bottleneck → read replica or cache. Write bottleneck → sharding or async queue. Single-region → geo-distribution and CDN.
Step 6 — Wrap-Up (2 min). Summarise the design, name the trade-offs you made deliberately, and list open questions you would resolve with more time. This is where the consulting differentiator lands hardest.
Non-Functional Requirements Deep Dive¶
| NFR | Question to ask | Impact on design |
|---|---|---|
| Availability | “What is the acceptable downtime per year?” | 99.9% = 8.7 hr/yr (active-passive fine); 99.99% = 52 min/yr (active-active required) |
| Durability | “Can we lose any data, even under a server crash?” | 11-nines S3 durability vs ephemeral Redis — drives storage tier choice |
| RTO / RPO | “After a disaster, how fast must we recover, and how much data can we lose?” | Low RPO requires synchronous replication; low RTO requires hot standby |
| Consistency | “Is stale data acceptable? For how long?” | Strong → Postgres with sync replica; eventual → Cassandra or DynamoDB |
| Latency | “p50 / p99 latency target for reads?” | < 100 ms p99 → in-memory cache mandatory; < 10 ms → cache + CDN |
| Geo requirements | “Single region or global?” | Global → multi-region write strategy (conflict resolution required) |
| Compliance | “GDPR? HIPAA? Data residency?” | Determines which cloud regions are eligible; shapes the logging and deletion strategy |
Capacity Estimation: The Power-of-2 Cheat Sheet¶
Why estimates matter
A back-of-envelope calculation tells you whether a single database handles the write load or whether you need sharding. Whether your cache can fit in RAM or needs a distributed layer. Whether your storage bill is $50/month or $50,000/month. Interviewers are not checking for exactness — they are checking that you use numbers to constrain design decisions rather than choosing technologies by familiarity.
"""Back-of-envelope capacity estimator.
Run during interviews to derive concrete numbers quickly.
"""
def estimate(
daily_events: int,
avg_event_bytes: int,
retention_days: int,
read_write_ratio: float = 10.0, # reads per write
peak_multiplier: float = 3.0, # peak vs average traffic
replication_factor: int = 3,
) -> dict:
avg_write_qps = daily_events / 86_400
avg_read_qps = avg_write_qps * read_write_ratio
peak_write_qps = avg_write_qps * peak_multiplier
peak_read_qps = avg_read_qps * peak_multiplier
raw_storage_gb = daily_events * avg_event_bytes * retention_days / 1e9
total_storage_gb = raw_storage_gb * replication_factor
write_bw_mbps = peak_write_qps * avg_event_bytes * 8 / 1e6
read_bw_mbps = peak_read_qps * avg_event_bytes * 8 / 1e6
# Assume: 1 server handles ~5 000 light reads/s or ~500 heavy writes/s
read_servers = max(1, int(peak_read_qps / 5_000))
write_servers = max(1, int(peak_write_qps / 500))
return {
"avg_write_qps": round(avg_write_qps, 1),
"avg_read_qps": round(avg_read_qps, 1),
"peak_write_qps": round(peak_write_qps, 1),
"peak_read_qps": round(peak_read_qps, 1),
"storage_raw_gb": round(raw_storage_gb, 1),
"storage_total_gb": round(total_storage_gb, 1),
"write_bw_mbps": round(write_bw_mbps, 2),
"read_bw_mbps": round(read_bw_mbps, 2),
"read_servers": read_servers,
"write_servers": write_servers,
}
# Example: analytics ingestion service, 10 M events/day, 500 bytes each, 90 day retention
result = estimate(
daily_events=10_000_000,
avg_event_bytes=500,
retention_days=90,
)
for k, v in result.items():
print(f"{k:<22}: {v}")
output avg_write_qps : 115.7 | avg_read_qps : 1157.4 | peak_write_qps : 347.2 | storage_raw_gb : 450.0 | storage_total_gb : 1350.0 | read_servers : 1 | write_servers : 1
Common Design Prompts: Templates and Key Insights¶
| Prompt | Core data model | Key insight / gotcha | The bottleneck to mention |
|---|---|---|---|
| URL Shortener | hash(url) → code → long_url in KV store | Collision handling: base62 encode a counter vs random + retry | Read path is >99% of traffic; cache aggressively in Redis/CDN |
| Rate Limiter | Token bucket in Redis per (user_id, endpoint) | Distributed: use SETNX + Lua script for atomic increment; sliding window is fairer than fixed window | Redis latency adds to every request; use local in-process first, Redis as secondary |
| Notification System | event_queue → fan-out worker → device_table | Fan-out at write (push model) vs fan-out at read (pull model) — write is simpler but expensive for high-follower accounts | Delivery guarantees require at-least-once + idempotency tokens |
| Ride-Matching (Uber) | driver_location: geohash → driver_id in Redis sorted set | Geohash tiles partition space; quad-tree for dynamic density; drivers update location every 4 s | Matching loop: poll nearby geohash cells + adjacent cells; O(1) per cell lookup |
| Web Crawler | frontier queue + visited bloom filter + content store | Politeness: per-domain rate limiting; dedup via URL normalisation before content hashing | DNS resolution is slow; batch with a local DNS cache per crawler worker |
| Design Twitter Feed | tweet table + follower table + feed cache (Redis list) | Celebrity problem: pre-compute feeds for normal users (push); lazy-compute for users who follow celebrities (pull hybrid) | Fan-out writes for users with millions of followers; use async workers |
| Distributed Cache | Consistent hashing ring → node assignment | Virtual nodes smooth load; handle node addition/removal without full rehash | Cache stampede on hot keys: use probabilistic early expiry or a lock |
Full Walkthrough: URL Shortener¶
The Consulting Differentiator¶
Architecture as a client recommendation, not a tech showcase
A general software engineer presents the architecturally optimal solution. A solutions consultant frames every design decision in terms of the client’s actual constraints: budget, existing tech stack, compliance environment, team skill set, and timeline. The same underlying architecture delivered with “we chose Postgres over Cassandra because your team already operates it and your consistency requirement is strict, which means you can avoid the CAP trade-offs that Cassandra requires you to reason about explicitly” is worth more to a client than technically superior advice delivered without that framing.
Calibrate depth to time remaining
Check in with the interviewer at 15 minutes: “I’ve covered the high-level design. Would you like to deep-dive on the database sharding strategy, the caching layer, or the rate limiter? I want to make sure we cover what matters most to you.” This transfers the prioritisation to them, ensures you spend time where it counts, and demonstrates collaborative instinct.
Naming technology before stating requirements
The single most common failure mode in system design interviews: opening with “I’d use Kafka.” Before you say a technology name, you must have stated the requirement that technology serves. If you cannot complete the sentence “I need Kafka because …” with a specific, quantified requirement, you are not ready to name it yet. Premature technology naming signals that you are pattern-matching on buzzwords rather than reasoning from first principles.
Consulting lens: the wrap-up as the executive summary
In client engagements the executive summary is more important than the appendix — the CTO reads the summary, not the 80-page technical spec. The system design wrap-up is your executive summary: one paragraph that names the design, the two or three most consequential trade-offs, and the open questions you would resolve in Phase 2. Candidates who deliver a crisp, trade-off-aware wrap-up in two minutes score systematically higher on “could present this to a client VP” rubric items.
Key takeaways
- Never name a technology before stating the requirement it serves — requirement first, technology second, always.
- The six steps (requirements → estimate → high-level → deep-dive → scale → wrap-up) prevent scope drift and signal structured thinking.
- Back-of-envelope numbers (QPS, storage, bandwidth) constrain design choices; do them before drawing any boxes.
- Non-functional requirements — especially availability SLA and consistency model — are the biggest architecture drivers; ask for them explicitly.
- At 15 minutes, check in: “Which component would you like to deep-dive?” This mirrors client engagement management.
- The wrap-up should name trade-offs, not just describe the design — this is the consulting differentiator.
Knowledge check
InterviewWhen designing a URL shortener, the interviewer asks: “How do you handle two different long URLs that hash to the same short code?” What is the best answer?
- Use a longer hash to make collisions statistically impossible and ignore the edge case
- Use an auto-increment primary key as the unique source of truth; encode that ID in base62 — no collisions are possible by construction
- Accept the collision and redirect both URLs to the same code — this is acceptable for most use cases
- Hash the URL twice with different algorithms and XOR the results to eliminate collisions
Answer
Use an auto-increment primary key as the unique source of truth; encode that ID in base62 — no collisions are possible by construction
The auto-increment + base62 encoding approach eliminates collisions entirely by construction: each row gets a unique integer ID from the database, and id_to_code(id) is a deterministic bijection. No two IDs can produce the same code. The hash-based approach has mathematically non-zero collision probability regardless of hash length; making it production-safe requires a collision-retry loop with a unique index check. Hashing twice and XOR-ing does not eliminate collisions — it changes the collision distribution. Accepting collisions would break redirect correctness. The auto-increment approach is the correct, interviewable answer.
Exercise
Exercise 15.3 · Design a Token-Bucket Rate Limiter Design a rate limiter that allows each user at most capacity requests per window_seconds using the token bucket algorithm. Implement a single-server Python class first, then describe (in comments) how you would make it distributed using Redis. Handle the edge case where a burst of requests arrives simultaneously.
Show solution
import time
class TokenBucketRateLimiter:
"""Single-server token bucket. Each user has their own bucket.
Distributed extension (describe verbally):
- Store (tokens, last_refill_ts) per user in Redis as a hash.
- Use a Lua script for atomic read-modify-write:
local tokens, ts = redis.call('HMGET', key, 'tokens', 'ts')
-- refill proportionally to elapsed time
-- decrement if tokens >= 1
-- return allow/deny
- Lua script runs atomically on the Redis node: no race conditions.
- Use Redis cluster with consistent hashing so each user always
hits the same shard (no cross-shard coordination needed).
"""
def __init__(self, capacity: int, refill_rate: float) -> None:
"""
capacity: max tokens (burst size)
refill_rate: tokens per second added continuously
"""
self.capacity = capacity
self.refill_rate = refill_rate
self._buckets: dict[str, tuple[float, float]] = {}
# value: (current_tokens, last_refill_timestamp)
def _get_bucket(self, user_id: str) -> tuple[float, float]:
return self._buckets.get(user_id, (float(self.capacity), time.monotonic()))
def allow(self, user_id: str) -> bool:
tokens, last_ts = self._get_bucket(user_id)
now = time.monotonic()
elapsed = now - last_ts
# Refill: add tokens proportional to elapsed time
tokens = min(self.capacity, tokens + elapsed * self.refill_rate)
if tokens >= 1.0:
self._buckets[user_id] = (tokens - 1.0, now)
return True
else:
self._buckets[user_id] = (tokens, now)
return False
# Test: capacity 5, refill 1 token/sec
limiter = TokenBucketRateLimiter(capacity=5, refill_rate=1.0)
user = "alice"
# Burst: first 5 should be allowed, 6th denied
results = [limiter.allow(user) for _ in range(6)]
assert results == [True, True, True, True, True, False], results
print("Rate limiter test passed")