🌙
☀️ Dark
PART 32

System Design

Scalability, CAP, sharding, microservices.

Advanced 45 min read

Part 32: System Design

Learning Objectives

You will learn how to take a vague requirement like "design Twitter" and convert it into a concrete, defensible architecture. You will understand scalability constraints, the CAP theorem, caching strategies, database choices, and the engineering trade-offs between monoliths and microservices.

Why Does This Exist?

Junior engineers write code. Senior engineers design systems. The difference is that senior engineers think about: What happens when this gets 100x more users? What fails first? How do we recover? System design is the discipline of making these decisions deliberately before they are forced on you by production incidents.

The System Design Framework

Every system design starts with the same process:

1. REQUIREMENTS
   ├── Functional: What does the system do?
   └── Non-functional: How fast? How available? How consistent?

2. SCALE ESTIMATION
   ├── Daily Active Users (DAU)
   ├── Requests per second (RPS)
   ├── Data volume (GB/day)
   └── Read:Write ratio

3. HIGH-LEVEL DESIGN
   ├── Identify core components
   ├── Data flow between components
   └── Initial API design

4. DEEP DIVE
   ├── Database schema
   ├── Critical algorithms
   └── Bottleneck identification

5. TRADE-OFFS
   └── Every decision has a cost. State it explicitly.

The CAP Theorem

In a distributed system that experiences a network partition, you must choose between Consistency and Availability. You cannot have both.

CAP Theorem:
┌─────────────────────────────────────────────────────┐
│                    Consistency                       │
│          Every read sees the latest write            │
│                        ↑                            │
│              Must choose one side                    │
│             when partition occurs                    │
│                        │                            │
│  Availability ─────────┼──────────── Partition      │
│  Every request         │             Tolerance      │
│  gets a response       │             (always needed) │
└─────────────────────────────────────────────────────┘

CP Systems (PostgreSQL, Zookeeper):
→ Stay consistent but may reject requests during partition

AP Systems (Cassandra, DynamoDB):
→ Stay available but may return stale data during partition

Scalability Patterns

VERTICAL SCALING
└── Bigger machine (more CPU, RAM)
    ✓ Simple, no code changes
    ✗ Has a ceiling, single point of failure, expensive

HORIZONTAL SCALING
└── More machines behind a load balancer
    ✓ Linear capacity growth
    ✗ State management complexity

The hardest part of horizontal scaling: STATE
┌─────────────────────────────────────────────────────┐
│ Stateless services scale horizontally easily        │
│ Stateful services (sessions, cache) need extra work │
└─────────────────────────────────────────────────────┘

Solution: Move state outside the application
  Sessions → Redis
  Files → Object storage (S3)
  Database → Dedicated cluster with replication

Caching Architecture

Cache levels from fastest to slowest:
┌─────────────────────────────────────────────┐
│ L1: In-process memory (Map/object)          │
│ L2: Redis / Memcached (shared, networked)   │
│ L3: CDN edge cache (geographic proximity)   │
│ L4: Database query cache                    │
└─────────────────────────────────────────────┘

Cache strategies:
READ-THROUGH:  App reads cache → miss → load from DB → cache
WRITE-THROUGH: Write to cache AND DB simultaneously
WRITE-BEHIND:  Write to cache only → async flush to DB
CACHE-ASIDE:   App manages cache explicitly (most common)

Cache invalidation strategies:
TTL: Cache expires after N seconds
Event-driven: Invalidate cache on write events
Versioning: Cache key includes version (cache-v2:user:123)

Load Balancing

Load balancing algorithms:
┌──────────────────┬────────────────────────────────────┐
│ Round Robin      │ Rotate requests equally             │
│ Least Connections│ Route to server with fewest active  │
│ IP Hash          │ Same client → same server (sticky)  │
│ Weighted         │ Route more to powerful servers      │
└──────────────────┴────────────────────────────────────┘

Health checks: Load balancer probes each server every N seconds.
Removes unhealthy servers from rotation automatically.

Database Scaling

PRIMARY → REPLICA REPLICATION
  Primary: Handles all writes
  Replicas: Handle reads (eventual consistency)
  Use case: Read-heavy workloads (most web apps)

SHARDING (Horizontal Partitioning)
  User 1-1M    → Shard A
  User 1M-2M   → Shard B
  User 2M-3M   → Shard C
  Trade-offs: Cross-shard queries become complex, resharding is painful

WHEN TO SHARD:
  - Single server cannot hold all data
  - Single server cannot handle write throughput
  - Data has natural partition key (userId, regionId)

Monolith vs Microservices

MONOLITH
  All features in one deployable unit.
  ✓ Simple deployment, easy debugging, low latency (in-process calls)
  ✓ Easy refactoring (single codebase)
  ✗ Scales as one unit (can't scale auth independently of payments)
  ✗ Large teams collide on same codebase

MODULAR MONOLITH (The underrated middle ground)
  Single deployable unit but with strict internal module boundaries.
  └── /src/modules/auth/   (owns its own data layer)
  └── /src/modules/payment/ (never calls auth directly)
  └── /src/modules/users/   (communicates through interfaces)
  ✓ Refactoring is possible, deployment is simple
  ✓ Can split into microservices later when you actually need to

MICROSERVICES
  Each bounded domain is a separate deployable service.
  ✓ Independent deployment and scaling
  ✓ Technology choice per service
  ✗ Network latency between every call
  ✗ Distributed transactions are genuinely hard
  ✗ Massive operational overhead (N services × complexity)
  
RULE: Start with a monolith. Extract services when you have a 
      proven reason. Conway's Law: Your architecture mirrors your org.

Real Engineering Challenge: Design a URL Shortener

You need to design bit.ly. 100M URLs created per day. 10B redirects per day. URLs must redirect in under 10ms.

View Architecture
Scale estimates:
  - 100M writes/day = ~1,200 writes/sec
  - 10B reads/day  = ~115,000 reads/sec (read-heavy!)
  - Read:Write = ~95:1

Short code generation:
  - Base62 (a-z, A-Z, 0-9) = 62 chars
  - 7 chars = 62^7 = 3.5 trillion unique codes

Architecture:
  Browser → CDN Edge Cache → Load Balancer → Read Service
                                           ↓
                                      Redis Cache (hot URLs)
                                           ↓ cache miss
                                      PostgreSQL (all URLs)

Write flow:
  Browser → API → Generate code → Write to DB → Return short URL

Read flow (the critical path):
  Browser → CDN checks cache (hit? → 301 redirect)
          → Cache miss → Redis (hit? → redirect + refresh cache)
          → DB miss → PostgreSQL → return 301 → cache in Redis

Database schema:
  urls (
    id          BIGSERIAL PRIMARY KEY,
    short_code  VARCHAR(10) UNIQUE NOT NULL,
    long_url    TEXT NOT NULL,
    user_id     BIGINT,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    expires_at  TIMESTAMPTZ,
    click_count BIGINT DEFAULT 0
  )
  CREATE INDEX idx_short_code ON urls(short_code); ← critical

Mini Project (20-30 min)

Design a basic Rate Limiter using a token bucket algorithm.

▶ View Solution
typescript
class TokenBucket {
  tokens: number;
  lastRefill: number;
  
  constructor(public capacity: number, public refillRate: number) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }
  
  allowRequest(): boolean {
    const now = Date.now();
    const refill = Math.floor((now - this.lastRefill) / 1000) * this.refillRate;
    this.tokens = Math.min(this.capacity, this.tokens + refill);
    this.lastRefill = now;
    
    if (this.tokens > 0) {
      this.tokens--;
      return true;
    }
    return false;
  }
}

Bigger Project (1-2 hours)

Design and implement a scalable URL Shortener service. Include capacity planning (storage, bandwidth), a distributed ID generator (like Snowflake), and a caching layer (Redis) to handle heavy read loads while maintaining high availability.

\n
▶ View Solution
plaintext
Client -> Load Balancer -> Web Tier -> App Tier -> Primary DB
                      |-> Cache (Redis)
                      |-> Object Storage (S3) -> CDN

Interview Questions

\n
Core: How do you choose between a relational and non-relational database?

Relational databases (SQL) are best for structured data, complex joins, and strong ACID guarantees (e.g., financial transactions). Non-relational databases (NoSQL) are better for flexible schemas, rapid iteration, and horizontal scaling for high write loads or massive datasets.

Hard: How do you handle database replication lag in a read-heavy system where read-after-write consistency is required?

You can route reads to the primary database immediately after a write for a short window (e.g., 500ms) or for that specific user's session. Alternatively, version the data and if a replica returns a stale version, fallback to reading from the primary. Another approach is synchronous replication, though it trades off write availability and latency.

Senior: Design a system to prevent cache stampede (thundering herd) when a highly requested item expires.

I would use a combination of techniques: 1) Probabilistic Early Expiration (PER) where a background process updates the cache just before it expires. 2) Mutex Locks: When the cache misses, the first thread acquires a lock to query the DB and update the cache, while other threads wait or return stale data. 3) Stale-While-Revalidate: Serve stale data immediately while asynchronously refreshing it in the background.

Revision Sheet

  • Requirements first: Functional + Non-functional (scale, latency, availability)
  • CAP: CP or AP during partition — choose based on your domain
  • Scale state out: Sessions → Redis, Files → S3, DB → replicas
  • Monolith first: Extract microservices only when you have proven need
  • Cache aggressively: Most web reads can be cached at some layer
🏠 Curriculum NextVolume 2