Prashant Labs DevLogs
Back to Tutorials
System Design Intermediate

The Ultimate System Design Masterclass: CAP Theorem, Caching & Microservices

A comprehensive deep dive into System Design principles: CAP & PACELC theorems, Cache-Aside strategies, Bloom filters, database sharding, and message queues.

P
Prashant Sharan
40 mins read

Architectural Blueprint for Large-Scale System Design

Building software systems capable of scaling reliably to millions of active users requires understanding core trade-offs. This masterclass covers the foundational principles, caching patterns, database partitioning strategies, and message queue patterns used by top tech engineering teams.


1. Fundamental Architectural Theorems

The CAP Theorem

In any distributed data store, you can only guarantee two out of three properties simultaneously during a network partition:

CAP THEOREM TRADE-OFF MATRIX
CP Systems (Consistency + Partition)

Prioritizes strict consistency over availability during network partitions

🛑 PostgreSQL / Spanner
  • Every read returns the latest write or an error.
  • Refuses non-consistent reads during network splits.
  • Ideal for financial & transactional ledger data.
AP Systems (Availability + Partition)

Prioritizes continuous availability over strict consistency

🛑 Cassandra / DynamoDB
  • Every non-failing node returns a response.
  • Reads may return stale data until sync completes.
  • Ideal for social feeds & high-volume logs.
⚠️ Crucial Insight

In distributed networks, network partitions (P) are unavoidable. Therefore, distributed system design boils down to choosing between CP (Consistency over Availability) or AP (Availability over Consistency).

PACELC Theorem Extension

PACELC extends CAP to address system behavior when there are no network partitions:

If there is a Partition (P), trade off Availability (A) vs Consistency (C); Else (E), trade off Latency (L) vs Consistency (C).


2. Caching Strategies & Preventing Cache Failure Modes

Caching stores pre-computed results or hot database entities in high-speed RAM (e.g., Redis or Memcached).

Code Implementation: Cache-Aside with Redis

import { createClient } from 'redis';

const redis = createClient();
await redis.connect();

export async function getCachedUserProfile(userId: string) {
  const cacheKey = `user:profile:${userId}`;
  
  // 1. Inspect Redis cache
  const cachedData = await redis.get(cacheKey);
  if (cachedData) {
    return JSON.parse(cachedData);
  }

  // 2. Fetch from primary database on Cache Miss
  const user = await db.user.findUnique({ where: { id: userId } });

  // 3. Populate Cache with TTL (Time To Live = 3600 seconds)
  if (user) {
    await redis.setEx(cacheKey, 3600, JSON.stringify(user));
  }

  return user;
}

Solving Deadly Caching Failure Modes

  1. Cache Stampede (Thundering Herd):

    • Problem: A hot cache key expires, causing 10,000 concurrent requests to hit the database at the exact same millisecond.
    • Solution: Use Distributed Locks (Redlock) or Probabilistic Early Expiration (XFetch algorithm).
  2. Cache Penetration:

    • Problem: Requests for non-existent IDs (e.g. user_999999) bypass the cache and repeatedly hit the database.
    • Solution: Cache null values with short TTLs or use a Bloom Filter upfront.
  3. Cache Avalanche:

    • Problem: Thousands of keys expire at the exact same second.
    • Solution: Add a Random Jitter (+/- 1-5 minutes) to all TTL values.

3. Database Scaling: Replication vs Sharding

PRIMARY-REPLICA ARCHITECTURE Architecture Flow
1
💻 Client / Application

Write Request -> Primary Leader DB

💡 All data mutations (INSERT, UPDATE, DELETE) hit the Primary Leader DB instance

2
🛢️ Database Cluster

Asynchronous WAL Replication Stream

💡 Primary Leader streams Write-Ahead Logs (WAL) asynchronously to Read Replicas

3
💻 Client / Application

Read Request -> Distributed Read Replicas

💡 Load balances heavy SELECT queries across multiple Read-Only Replica instances


4. Message Queues & Event-Driven Architecture

Decouple synchronous HTTP request-response cycles by publishing heavy tasks to background worker queues.

Step 1: Synchronous API Request

Client submits a long-running job (e.g., video transcode or generating invoice PDF):

import { Queue } from 'bullmq';

const videoQueue = new Queue('video-processing', {
  connection: { host: '127.0.0.1', port: 6379 }
});

export async function handleUploadRoute(req, res) {
  const { videoId, fileUrl } = req.body;

  // Enqueue job asynchronously
  await videoQueue.add('transcode', { videoId, fileUrl });

  // Return immediate 202 Accepted response to client
  return res.status(202).json({
    message: 'Video processing started',
    statusUrl: `/api/videos/${videoId}/status`
  });
}

Step 2: Asynchronous Background Worker

Independent worker nodes consume jobs from the queue at their own throughput capacity:

import { Worker } from 'bullmq';

const worker = new Worker('video-processing', async (job) => {
  console.log(`Processing video ${job.data.videoId}...`);
  await transcodeVideo(job.data.fileUrl);
  console.log(`Finished transcoding video ${job.data.videoId}`);
}, {
  connection: { host: '127.0.0.1', port: 6379 },
  concurrency: 5 // Process 5 jobs concurrently per worker instance
});
💡 Architecture Rule of Thumb

Default to asynchronous processing for any operation taking longer than 200 milliseconds to complete.

Discussions & Comments

🐙 Powered by GitHub Discussions