← Tutorials 🤖 AI 📓 Architect's Notebook ☁ AWS 📋 Cheatsheet 🗃 Databases 🚀 DevOps 🧩 DSA ❓ FAQ 🖥 Frontend ☕ Java 🍏 MongoDB 🐍 Python 🌿 Spring Boot 🏗 System Design 🏛 TOGAF
Tutorials › System Design

What is System Design?

🏗️ System Design · Foundations · 5 min read

System design is the process of defining the architecture, components, and data flow of a large-scale software system to meet functional and non-functional requirements.

Why It Matters

  • Core topic in senior/staff engineer interviews (L5+)
  • Teaches you to think about trade-offs, not just code
  • Required to build systems that handle millions of users

Interview Framework

  1. Clarify requirements — functional (what it does) and non-functional (scale, latency)
  2. Estimate scale — QPS, storage, bandwidth
  3. High-level design — components and their connections
  4. Detailed design — deep dive into 2-3 key components
  5. Identify bottlenecks and how to address them

Scalability

🏗️ System Design · Foundations · 6 min read

Vertical Scaling (Scale Up)

Add more CPU/RAM to a single server. Simple but has an upper limit and single point of failure.

Horizontal Scaling (Scale Out)

Add more servers. Needs a load balancer. No theoretical limit. Standard approach for web apps.

Stateless Services

Keep no session state on the server — store it in Redis or a DB. Required for horizontal scaling.

Database Sharding

Split data across multiple DB servers by a shard key (e.g. user_id % N). Enables massive scale.

CAP Theorem

🏗️ System Design · Foundations · 5 min read

In a distributed system, you can only guarantee two out of three:

Consistency (C)

Every read gets the most recent write. All nodes see the same data at the same time.

Availability (A)

Every request gets a response (not guaranteed to be the latest). The system stays up.

Partition Tolerance (P)

The system continues operating even if some network messages are lost between nodes.

In practice, network partitions happen — so you must choose between CP (consistent but may be unavailable during a partition) or AP (available but may serve stale data).

  • CP — ZooKeeper, HBase, traditional SQL
  • AP — Cassandra, DynamoDB, CouchDB

Load Balancers

🏗️ System Design · Core Components · 5 min read

A load balancer distributes incoming traffic across multiple servers to prevent any one server from being overwhelmed.

Algorithms

  • Round Robin — requests distributed in rotation
  • Least Connections — route to the server with fewest active connections
  • IP Hash — same client always goes to same server (sticky sessions)
  • Weighted Round Robin — more powerful servers get more traffic

Layer 4 vs Layer 7

  • L4 (Transport) — routes by IP/TCP without looking at content; faster
  • L7 (Application) — can route by URL, headers, cookies; enables path-based routing
💡 AWS ALB (Application Load Balancer) is L7. AWS NLB (Network Load Balancer) is L4. Use ALB for most web apps.

Caching

🏗️ System Design · Core Components · 6 min read

Caching stores frequently accessed data in fast memory (RAM) to reduce DB load and response latency.

Cache Strategies

  • Cache-Aside (Lazy Loading) — app checks cache first; on miss, loads from DB and writes to cache. Most common.
  • Write-Through — write to cache and DB simultaneously. Consistent but slower writes.
  • Write-Back — write to cache only; flush to DB asynchronously. Fast writes, risk of data loss.

Eviction Policies

  • LRU — Least Recently Used (most common)
  • LFU — Least Frequently Used
  • TTL — expire after a time period

Tools

  • Redis — in-memory data store; supports strings, hashes, lists, sets. Use for session, leaderboards, pub/sub.
  • Memcached — simple key-value cache; faster for pure caching use cases.

Databases — SQL vs NoSQL

🏗️ System Design · Core Components · 7 min read

SQL (Relational)

Structured schema, ACID transactions, complex joins. MySQL, PostgreSQL. Good for financial, inventory, user data.

NoSQL (Document)

Flexible schema, horizontal scaling. MongoDB, DynamoDB. Good for catalogues, user profiles, content.

NoSQL (Wide-Column)

Cassandra, HBase. Excellent write throughput. Good for time-series, IoT, activity logs.

NoSQL (Graph)

Neo4j. Models relationships natively. Good for social graphs, recommendation engines.

Database Replication

  • Master-Slave — writes go to master, reads from replicas. Most common.
  • Multi-Master — writes to any node; complex conflict resolution.

Message Queues

🏗️ System Design · Core Components · 5 min read

Message queues decouple producers from consumers, enabling async processing and absorbing traffic spikes.

Use Cases

  • Sending emails/notifications asynchronously after a user action
  • Processing uploads (video encoding, image resizing) in the background
  • Distributing work across multiple worker instances
  • Event-driven microservices communication

Tools

  • Kafka — high-throughput event streaming; replay messages; used at Netflix, LinkedIn
  • RabbitMQ — traditional message broker; flexible routing; easier to set up than Kafka
  • AWS SQS — fully managed queue; great for AWS-native apps

CDN (Content Delivery Network)

🏗️ System Design · Core Components · 4 min read

A CDN caches static assets (images, CSS, JS, videos) on servers geographically close to users, reducing latency and origin server load.

How It Works

  1. User requests cdn.example.com/logo.png
  2. CDN routes to the nearest edge server (PoP)
  3. If cached → serve immediately (cache hit)
  4. If not → fetch from origin, cache, then serve (cache miss)

Tools

  • CloudFront — AWS CDN, integrates with S3 and EC2
  • Cloudflare — CDN + DDoS protection + DNS
  • Fastly — programmable CDN, used by GitHub, Stripe

Microservices Architecture

🏗️ System Design · Patterns · 8 min read

Microservices is an architectural style that structures an application as a collection of small, independently deployable services. Each service owns a specific business capability, its own database, and communicates with others over well-defined APIs (REST, gRPC, or message queues).

Monolith vs Microservices

Monolith

Single deployable unit. Simple to develop and debug initially. Scales as one block. Tech stack locked. One team bottleneck. Becomes fragile at large scale.

Microservices

Independent deployment per service. Each team owns their service end-to-end. Mixed tech stacks. Scales per service. Complex ops: service discovery, tracing, distributed data.

When Microservices Make Sense

  • Teams are large and need to deploy independently without stepping on each other
  • Different parts of the system have radically different scaling needs (e.g. order processing vs. recommendation engine)
  • Parts of the system need different reliability SLAs (payments vs. notifications)
  • You need to mix technologies — ML in Python, APIs in Java, real-time in Go
💡 Start with a monolith. Extract microservices only when you have clear pain points — premature decomposition is the most common microservices mistake. Even Netflix and Amazon started as monoliths.

22 Patterns — 7 Groups

Decomposition (3)

Business Capability · Subdomain · Strangler Fig

API & Communication (3)

API Gateway · Aggregator · Client-Side UI Composition

Data Management (5)

DB per Service · Shared DB · Event Sourcing · CQRS · Saga

Resilience (2)

Circuit Breaker · Bulkhead

Infrastructure (3)

Service Discovery · Sidecar · Externalized Config

Observability (4)

Health Check · Distributed Tracing · Metrics · Log Aggregation

Deployment (1)

Blue-Green Deployments

Starting point

Use the left nav to explore each group. Patterns within a group are related and best read together.

Decomposition Patterns

🏗️ System Design · Microservices · 10 min read
Decomposition

1. Decompose by Business Capability

Split the monolith along business capabilities — vertically sliced areas of what the business does, not technical layers. Each capability (e.g. Orders, Inventory, Payments, Shipping) becomes one or more microservices.

Visual

Monolith Orders Inventory Payments Shipping DB DB DB DB

Problems It Solves

  • Monolith too large to deploy safely — one bug can take down everything
  • Team dependencies — teams blocked waiting for one shared release cycle
  • Different parts need different scaling (payments needs 5-nines; notifications can tolerate downtime)

Spring Boot Example

// order-service/src/main/java/com/example/order/OrderServiceApplication.java
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    @Autowired private OrderRepository orderRepository;

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderRepository.findById(id).orElseThrow();
    }
}
// order-service/src/main/resources/application.yml
// Each service has its OWN database — no sharing
spring:
  datasource:
    url: jdbc:postgresql://orders-db:5432/orders_db
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

Real-World Examples

  • Amazon — Catalog, Cart, Checkout, Fulfillment are separate services with separate teams
  • Netflix — Streaming, Billing, Recommendations each independently deployed
  • Uber — Rider, Driver, Pricing, Maps services all separated by business capability

Pros

  • Independent deployment per team
  • Team autonomy and ownership
  • Scale each capability independently

Cons

  • Requires deep domain knowledge to slice correctly
  • Distributed system complexity added immediately

Decomposition

2. Decompose by Subdomain (DDD)

Use Domain-Driven Design (DDD) bounded contexts to identify service boundaries. Each bounded context — a self-contained model of a domain concept with its own ubiquitous language — maps to one or more microservices.

Visual

Domain
  │
  ├── Core Domain (competitive advantage — invest most here)
  │     └── Identity Service  ←  Auth Service + Profile Service
  │
  ├── Supporting Subdomain (needed but not differentiating)
  │     └── Catalog Service  ←  Product Service + Search Service
  │
  └── Generic Subdomain (commodity — buy or outsource)
        └── Shipping  ←  delegated to 3rd party (FedEx API)

Problems It Solves

  • Ambiguous service boundaries causing cross-service data coupling — services reach into each other's DBs
  • Shared database anti-pattern that makes schema changes risky
  • Services that are too fine-grained (a service per DB table) or too coarse-grained (one service for all user concerns)

Spring Boot Example

// Package structure reflects bounded context — not technical layers
com.example
  ├── identity/          // Core domain — auth + profile (same bounded context)
  │     ├── auth/
  │     │     ├── AuthService.java
  │     │     └── TokenRepository.java
  │     └── profile/
  │           ├── ProfileService.java
  │           └── ProfileRepository.java
  │
  ├── catalog/           // Supporting subdomain
  │     ├── product/
  │     └── search/
  │
  └── shipping/          // Generic — thin adapter over 3rd party API
        └── ShippingGateway.java

Real-World Examples

  • E-commerce Identity (core) maps to Auth Service + Profile Service
  • Catalog (supporting) maps to Product Service + Search Service
  • Shipping (generic) delegated to FedEx/DHL via an adapter service

Pros

  • Natural service boundaries aligned with business language
  • Avoids chatty microservices that call each other constantly
  • Teams share a ubiquitous language with the business

Cons

  • Requires strong DDD expertise to identify bounded contexts
  • Significant upfront design time investment

Decomposition

3. Strangler Fig Pattern

Incrementally replace a monolith by routing specific requests to new microservices while keeping the monolith running for everything else. Named after the strangler fig tree that grows around a host tree and eventually replaces it.

Visual

Load Balancer / Proxy Legacy Monolith Route /* → Monolith New Microservices Route /orders → Order Service Route /payments → Payment Service Route /users → User Service More routes migrated over time...

Problems It Solves

  • Cannot do a big-bang rewrite — too risky for a live production system
  • Need to keep the business running while migrating to microservices
  • Large legacy codebase with no clear ownership or tests

Spring Boot Example

// Spring Cloud Gateway — route specific paths to new services,
// everything else falls through to the monolith
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: http://order-service:8081
          predicates:
            - Path=/api/orders/**
        - id: payment-service
          uri: http://payment-service:8082
          predicates:
            - Path=/api/payments/**
        - id: monolith-fallback
          uri: http://legacy-monolith:8080
          predicates:
            - Path=/**          # catch-all — everything else still goes to monolith

Real-World Examples

  • Netflix — migrated from Oracle monolith to microservices over 7 years using this approach
  • eBay — strangled its Perl monolith route by route over several years

Pros

  • Zero downtime migration — monolith stays live throughout
  • Each route migration is independently testable and rollback-able
  • Business continues operating while the rewrite happens

Cons

  • Dual maintenance burden during migration (monolith + new services)
  • Proxy adds a small latency hop
  • Data synchronization needed between monolith DB and new service DBs during transition

API & Communication Patterns

🏗️ System Design · Microservices · 10 min read
API & Communication

1. API Gateway Pattern

A single entry point for all client requests. Routes requests to the appropriate microservice and handles cross-cutting concerns like auth, rate limiting, logging, and SSL termination so individual services don't have to.

Visual

Mobile Client Web Client Third-Party API Gateway Auth · Rate Limit · Route · Log · SSL Order Service User Service Payment Service

Problems It Solves

  • Multiple client apps calling many services directly creates N×M connection complexity
  • Duplicate auth/JWT validation logic reimplemented in every service
  • CORS headers, SSL certs, and rate limiting managed in multiple places

Spring Boot Example

// build.gradle — Spring Cloud Gateway dependency
// implementation 'org.springframework.cloud:spring-cloud-starter-gateway'

// application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service          # lb:// uses service discovery
          predicates:
            - Path=/api/orders/**
          filters:
            - AddRequestHeader=X-Internal-Auth, ${INTERNAL_SECRET}
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/users/**

Real-World Examples

  • AWS API Gateway, Kong, Netflix Zuul, Spring Cloud Gateway, Nginx as reverse proxy

Pros

  • Cross-cutting concerns in one place — auth, logging, rate limiting
  • Simplified client — one endpoint, one SSL cert
  • Easy API versioning via routing rules

Cons

  • Potential single point of failure — mitigate with replicas
  • Extra network hop for every request
  • Must not become a business logic dumping ground

API & Communication

2. Aggregator Pattern

A service (or the API Gateway) that calls multiple downstream microservices, aggregates their responses into a single unified response, and returns it to the client. Prevents clients from making multiple round trips.

Visual

Client
  │
  ▼
Aggregator Service
  │   (calls all 3 in parallel)
  ├──▶  Order Service    → { orderId, status, items }
  ├──▶  User Service     → { name, email, address }
  └──▶  Inventory Service → { stockLevel, warehouse }
  │
  ▼  (merges responses)
{ order: {...}, user: {...}, inventory: {...} }
  │
  ▼
Client  ←  single response, one round trip

Problems It Solves

  • Client making 5–10 calls to get one screen's data (chatty APIs)
  • Mobile clients on slow networks hit by sequential waterfall requests
  • Data for a single view scattered across multiple services

Spring Boot Example

@Service
public class OrderDetailAggregator {
    private final WebClient webClient;

    public Mono<OrderDetailResponse> aggregate(Long orderId, Long userId) {
        Mono<OrderDto> order = webClient.get()
            .uri("http://order-service/api/orders/{id}", orderId)
            .retrieve().bodyToMono(OrderDto.class);

        Mono<UserDto> user = webClient.get()
            .uri("http://user-service/api/users/{id}", userId)
            .retrieve().bodyToMono(UserDto.class);

        Mono<InventoryDto> inventory = webClient.get()
            .uri("http://inventory-service/api/stock/{id}", orderId)
            .retrieve().bodyToMono(InventoryDto.class);

        // Parallel calls — waits for the slowest, not the sum of all
        return Mono.zip(order, user, inventory)
            .map(tuple -> new OrderDetailResponse(
                tuple.getT1(), tuple.getT2(), tuple.getT3()));
    }
}

Real-World Examples

  • Product detail page aggregating product info + seller info + reviews + inventory in one call
  • Dashboard aggregating metrics from multiple services into one API response

Pros

  • Fewer client-server round trips
  • Client-friendly API that hides service complexity
  • Parallel calls keep latency to the slowest service, not the sum

Cons

  • Aggregator service can become a bottleneck
  • Fault tolerance needed — what if one downstream service is down?
  • Total latency = slowest downstream service

API & Communication

3. Client-Side UI Composition

The frontend is split into fragments (micro-frontends), each owned by a different team and corresponding to a microservice. The browser assembles the page by loading fragments independently, often using iframes, Web Components, or module federation.

Visual

Browser assembles page from independent fragments:

┌──────────────────────────────────────────────────┐
│  [ Header Fragment ]       ← nav team            │
├────────────────────────┬─────────────────────────┤
│  [ Product Detail ]    │  [ Cart Widget ]        │
│  ← catalog team        │  ← cart team            │
├────────────────────────┴─────────────────────────┤
│  [ Review Section Fragment ]  ← review team      │
└──────────────────────────────────────────────────┘

Each fragment fetches its own data from its own service.

Problems It Solves

  • Backend microservices moving fast but one monolithic frontend creates a deployment bottleneck
  • Multiple teams contributing to one large frontend repo with merge conflicts
  • Different UI sections need different release cadences

Spring Boot Example

// Each team exposes a REST endpoint for their fragment's data

// catalog-service — Product Detail fragment data
@GetMapping("/api/fragments/product/{id}")
public ProductFragmentDto getProductFragment(@PathVariable Long id) {
    return productService.getFragmentData(id);
}

// cart-service — Cart Widget fragment data
@GetMapping("/api/fragments/cart/{userId}")
public CartFragmentDto getCartFragment(@PathVariable Long userId) {
    return cartService.getFragmentData(userId);
}

// Thymeleaf server-side composition alternative:
// th:replace="~{fragments/cart :: cart-widget}"

Real-World Examples

  • IKEA uses micro-frontends for product pages with independent team deployments
  • Zalando's Project Mosaic — pioneered micro-frontend architecture
  • Spotify's squad model — each squad owns their backend + frontend slice

Pros

  • Team autonomy end-to-end — own backend and frontend slice
  • Independent deployment of each UI section
  • Different tech stacks per fragment if needed

Cons

  • Complex browser integration and consistent UX requires governance
  • Performance concerns — multiple JS bundles downloaded
  • Sharing state across fragments is hard

Data Management Patterns

🏗️ System Design · Microservices · 14 min read
Data Management

1. Database per Service

Each microservice owns its own database schema and ideally its own database server. No other service can access it directly — all data access goes through the service's API. This is the golden rule of microservices data isolation.

Visual

Order Service User Service Inventory Service Orders DB Users DB Inventory DB NO direct DB access

Problems It Solves

  • Shared DB creates a coupling point — one service's schema change breaks other services
  • Cannot scale individual service databases independently
  • Shared DB becomes a coordination and deployment bottleneck

Spring Boot Example

// order-service/application.yml — its OWN datasource
spring:
  datasource:
    url: jdbc:postgresql://orders-db:5432/orders_db
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate

// OrderRepository — only Order Service can touch this
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
}

// If User Service needs order data, it calls the API:
// GET http://order-service/api/orders?userId=123
// NEVER: SELECT * FROM orders_db.orders WHERE user_id = 123

Real-World Examples

  • Amazon — each team owns their microservice's DB (Cassandra, DynamoDB, or MySQL based on access patterns)
  • Uber — trip data in Cassandra, user data in MySQL, pricing in Redis

Pros

  • Loose coupling — schema changes don't ripple to other services
  • Polyglot persistence — use the right DB type per service
  • Independent schema evolution and scaling

Cons

  • Cross-service queries require API calls or events — no JOINs
  • Distributed transactions become sagas (see Saga pattern)
  • Data consistency is eventual, not immediate

Data Management

2. Shared Database (Anti-Pattern)

⚠️ Anti-Pattern: Multiple microservices sharing one database defeats the purpose of microservices by creating tight coupling at the data layer. This section covers it so you recognize and avoid it.

Visual

Order Service ──┐
                 │
User Service  ───┼──▶  ⚠️  SHARED DATABASE  ⚠️
                 │         (single schema, single connection pool)
Inventory Svc ──┘

Problems:
- Order Service changes orders table → breaks User Service JOIN
- Inventory Service slow query → exhausts connection pool → all services hang
- Cannot deploy services independently (schema migrations are global)

Problems It Creates (Not Solves)

  • Schema changes by one team break other services that depend on the same tables
  • One slow query by one service can exhaust the shared connection pool and bring down all services
  • Cannot scale services independently since they share the same database connection pool and storage

When It Is Acceptable

  • Legacy migration phase — interim state during Strangler Fig migration before databases are split
  • Read-only reporting sidecar that only reads aggregated data for analytics
  • Very small teams where simplicity genuinely beats purity at this stage

Spring Boot Example — The Problem and Fix

// ANTI-PATTERN: Two services pointing to same datasource URL
// order-service/application.yml
spring.datasource.url: jdbc:postgresql://shared-db:5432/app_db

// user-service/application.yml  ← same URL — BAD
spring.datasource.url: jdbc:postgresql://shared-db:5432/app_db

// FIX: Each service gets its own datasource
// order-service/application.yml
spring.datasource.url: jdbc:postgresql://orders-db:5432/orders_db

// user-service/application.yml
spring.datasource.url: jdbc:postgresql://users-db:5432/users_db

Apparent Pros

  • Simple joins across service data in one query
  • Single transaction boundary for multi-entity writes
  • Easier to start with — no data distribution complexity

Cons

  • Tight schema coupling blocks independent deployment
  • Shared connection pool — one service's load affects all
  • Cannot use polyglot persistence

Data Management

3. Event Sourcing

Instead of storing the current state of an entity, store the sequence of events (state changes) that led to it. The current state is derived by replaying the event log. Think of it like a bank ledger — you don't store the balance, you store every debit and credit; the balance is computed by summing them.

Visual

OrderCreated ItemAdded ItemAdded PaymentReceived OrderShipped Event Log (append-only) Replay all events → current state: { status: SHIPPED, items: 2, total: 4500 }

Problems It Solves

  • Audit trail required for regulatory or financial compliance
  • Temporal queries needed — "what was the order state on Jan 3rd at 14:00?"
  • Event-driven architectures need a reliable, replayable event log

Spring Boot Example

@Entity
@Table(name = "event_store")
public class DomainEvent {
    @Id @GeneratedValue
    private Long id;
    private String aggregateId;
    private String eventType;
    @Column(columnDefinition = "jsonb")
    private String payload;
    private Instant occurredAt;
}

public class OrderAggregate {
    private String status;
    private List<OrderItem> items;
    private BigDecimal total;

    // Rebuild current state by replaying stored events
    public static OrderAggregate reconstituteFrom(List<DomainEvent> events) {
        OrderAggregate order = new OrderAggregate();
        for (DomainEvent event : events) {
            order.apply(event);
        }
        return order;
    }

    private void apply(DomainEvent event) {
        switch (event.getEventType()) {
            case "OrderCreated"    -> this.status = "PENDING";
            case "ItemAdded"       -> this.items.add(parseItem(event.getPayload()));
            case "PaymentReceived" -> this.status = "PAID";
            case "OrderShipped"    -> this.status = "SHIPPED";
        }
    }
}

Real-World Examples

  • Banking ledgers — balance is derived from transaction history, never stored directly
  • Amazon order timeline — every status change is an event stored permanently
  • Git itself is event sourcing — commits are events; HEAD is the result of replaying them

Pros

  • Complete audit trail by design
  • Temporal queries and time-travel debugging
  • Natural fit for event-driven architectures

Cons

  • Complex to implement and query
  • Event schema evolution is hard once events are stored
  • High storage volume over time — mitigate with periodic snapshots

Data Management

4. CQRS — Command Query Responsibility Segregation

Separate the model that handles writes (Commands — create, update, delete) from the model that handles reads (Queries). The write side maintains a normalized, consistent model; the read side maintains denormalized projections optimized for specific query patterns.

Visual

Client
  │
  ├──▶  Command Side (writes)
  │       OrderCommandHandler
  │         └──▶  Write DB (normalized, ACID)
  │                 │
  │                 └──▶ publishes OrderCreatedEvent
  │                           │
  │                           ▼
  └──▶  Query Side (reads)   EventListener updates Read Model
          OrderQueryHandler
            └──▶  Read DB (denormalized, pre-joined, fast)
                    e.g. order_summary view with user name already embedded

Problems It Solves

  • A single data model trying to serve both complex writes and complex reads becomes a mess of compromises
  • Write-optimized schemas (normalized, 3NF) and read-optimized schemas (denormalized, pre-joined) are fundamentally different
  • Read scale requirements are often 100x write scale — they need to scale independently

Spring Boot Example

// Command side — write to normalized DB
@Service
public class OrderCommandHandler {
    @Autowired private OrderWriteRepository writeRepo;
    @Autowired private ApplicationEventPublisher events;

    @Transactional
    public void handle(CreateOrderCommand cmd) {
        Order order = new Order(cmd.getUserId(), cmd.getItems());
        writeRepo.save(order);
        events.publishEvent(new OrderCreatedEvent(order.getId(), order));
    }
}

// Read side — query from denormalized read model
@Service
public class OrderQueryHandler {
    @Autowired private OrderReadRepository readRepo;

    public OrderSummaryDto getOrderSummary(Long orderId) {
        return readRepo.findOrderSummaryById(orderId);
        // returns pre-joined view: order + user name + item count
    }
}

// Bridge — EventListener keeps read model in sync
@Component
public class OrderReadModelUpdater {
    @EventListener
    public void on(OrderCreatedEvent event) {
        // update the denormalized read model table
        readRepo.upsertOrderSummary(buildSummary(event));
    }
}

Real-World Examples

  • E-commerce: order creation uses normalized tables; order history page uses pre-computed denormalized views
  • Most real CQRS implementations combine it with Event Sourcing

Pros

  • Read and write sides scale independently
  • Optimized schemas for each concern
  • Clean separation — commands never return data, queries never mutate

Cons

  • Eventual consistency between write and read models
  • Significantly more code and infrastructure
  • Overkill for simple CRUD applications

Data Management

5. Saga Pattern

Manages distributed transactions across multiple microservices using a sequence of local transactions. Each local transaction publishes an event or message that triggers the next step. If a step fails, compensating transactions undo the previous steps — achieving eventual consistency without distributed ACID transactions.

Visual

Happy Path (Orchestration) Order Service CREATE_ORDER Payment Service RESERVE_PAYMENT Inventory Service RESERVE_INVENTORY Shipping Service SCHEDULE_DELIVERY Compensation Flow (if Inventory step fails) Inventory FAIL Payment Service RELEASE_PAYMENT Order Service CANCEL_ORDER Each step is a local DB transaction. No distributed lock. Compensations undo previous steps.

Problems It Solves

  • You cannot use a distributed ACID transaction across microservices with separate databases
  • Need data consistency across services without distributed locks (2PC is a distributed systems anti-pattern at scale)

Two Approaches

  • Choreography — each service listens for events and reacts by performing its step and publishing the next event. No central coordinator. Simple but hard to track overall state.
  • Orchestration — a saga orchestrator service explicitly tells each service what to do in sequence. Central visibility but orchestrator can become complex.

Spring Boot Example (Orchestration)

@Service
public class CheckoutSagaOrchestrator {
    @Autowired private OrderServiceClient orderClient;
    @Autowired private PaymentServiceClient paymentClient;
    @Autowired private InventoryServiceClient inventoryClient;

    public void execute(CheckoutRequest req) {
        String orderId = null;
        String paymentId = null;
        try {
            orderId = orderClient.createOrder(req);
            paymentId = paymentClient.reservePayment(req.getUserId(), req.getAmount());
            inventoryClient.reserveItems(req.getItems());
            // All steps succeeded
        } catch (InventoryException e) {
            // Compensate in reverse order
            if (paymentId != null) paymentClient.releasePayment(paymentId);
            if (orderId != null)   orderClient.cancelOrder(orderId);
            throw new CheckoutFailedException("Out of stock", e);
        }
    }
}

Real-World Examples

  • E-commerce checkout: create order → charge card → reserve stock → schedule delivery
  • Flight booking: reserve seat → charge card → issue ticket — each step can be compensated

Pros

  • Maintains data consistency without distributed transactions
  • Each service remains autonomous with its own local transaction
  • No distributed locking or 2PC bottleneck

Cons

  • Complex to implement, test, and debug
  • Only eventual consistency — intermediate states are visible
  • Compensating transactions can also fail — need idempotency

Resilience Patterns

🏗️ System Design · Microservices · 8 min read
Resilience

1. Circuit Breaker Pattern

Wraps a remote call with a "circuit breaker" that monitors failures. After a threshold of failures, the circuit "opens" and immediately returns an error or fallback without attempting the call. After a timeout, it goes "half-open" and allows one test request through to check if the service recovered.

Visual

CLOSED Normal — all calls pass OPEN Failing — reject fast HALF-OPEN Testing — 1 call allowed failures > threshold timeout expires timeout test succeeds → CLOSED test fails → OPEN

Problems It Solves

  • Cascading failures — one slow service makes all callers queue up and exhaust their thread pools
  • Continuous hammering of an already-failing service prevents it from recovering
  • Latency spikes due to timeout accumulation across the call chain

Spring Boot Example (Resilience4j)

// build.gradle
// implementation 'io.github.resilience4j:resilience4j-spring-boot3'

// application.yml
resilience4j:
  circuitbreaker:
    instances:
      paymentService:
        slidingWindowSize: 10
        failureRateThreshold: 50        # open if 50% of 10 calls fail
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 2

// Service
@Service
public class OrderService {
    @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
    public PaymentResult chargePayment(PaymentRequest request) {
        return paymentClient.charge(request);
    }

    public PaymentResult paymentFallback(PaymentRequest request, Exception ex) {
        // Return a graceful fallback — queue for retry or notify user
        return PaymentResult.pending("Payment service temporarily unavailable");
    }
}

Real-World Examples

  • Netflix Hystrix — original implementation, now in maintenance; Resilience4j is the modern replacement
  • AWS SDK has built-in circuit breakers and adaptive retry logic for service calls

Pros

  • Prevents cascading failures across the service graph
  • Fast failure — fail immediately rather than waiting for timeout
  • Gives downstream service time and space to recover

Cons

  • False positives — circuit may open during transient load spikes
  • Adds complexity to service layer
  • Fallback behavior needs careful design for each use case

Resilience

2. Bulkhead Pattern

Isolate resources (thread pools, connection pools, semaphores) for different services or operations, so that a failure or resource exhaustion in one area doesn't drain resources from other areas. Named after ship hull compartments — one compartment flooding doesn't sink the ship.

Visual

ANTI-PATTERN — Shared thread pool:
┌─────────────────────────────────────────────┐
│  Thread Pool: 200 threads (shared by all)   │
│  Order calls + Payment calls + User calls   │
│  → Payment service slows → eats all 200     │
│  → Order calls and User calls also starve   │
└─────────────────────────────────────────────┘

BULKHEAD — Isolated pools:
┌─────────────────┐  ┌──────────────────┐  ┌────────────────┐
│ Order Service   │  │ Payment Service  │  │ User Service   │
│ Thread Pool: 50 │  │ Thread Pool: 50  │  │ Thread Pool:100│
└─────────────────┘  └──────────────────┘  └────────────────┘
Payment pool exhausted → Order and User pools unaffected

Problems It Solves

  • One slow downstream service exhausts the shared thread pool, bringing down all other API calls on the same service
  • A traffic spike for one feature degrades all other features sharing the same resources
  • Need to ensure critical paths like payments always have resources available

Spring Boot Example (Resilience4j)

// application.yml
resilience4j:
  bulkhead:
    instances:
      paymentService:
        maxConcurrentCalls: 50
        maxWaitDuration: 100ms
  thread-pool-bulkhead:
    instances:
      inventoryService:
        maxThreadPoolSize: 20
        coreThreadPoolSize: 10
        queueCapacity: 50

// Service
@Service
public class CheckoutService {
    @Bulkhead(name = "paymentService", type = Bulkhead.Type.THREADPOOL)
    public CompletableFuture<PaymentResult> processPayment(PaymentRequest req) {
        return CompletableFuture.supplyAsync(() -> paymentClient.charge(req));
    }
}

Real-World Examples

  • Netflix separates streaming, billing, and recommendation thread pools so a recommendation service slowdown never blocks streaming
  • Payment gateways isolate payment processing thread pools from reporting and admin API pools

Pros

  • Fault isolation — one pool exhausted doesn't affect others
  • Critical paths like payments always have dedicated resources
  • Predictable resource usage per service

Cons

  • Requires capacity planning to size pools correctly
  • More configuration overhead per service interaction
  • Thread pool per service increases total memory footprint

Infrastructure Patterns

🏗️ System Design · Microservices · 10 min read
Infrastructure

1. Service Discovery Pattern

In a microservices environment, service instances start and stop dynamically as containers scale. Service Discovery allows services to find each other by name rather than hardcoded IP:port. Two approaches exist: Client-Side Discovery (client queries a registry) and Server-Side Discovery (load balancer queries the registry and routes).

Visual

  Order Service ─┐
                  ├──▶ register on startup
  User Service  ─┤                          ┌─────────────────────┐
                  ├──▶ register on startup   │   Service Registry  │
  Payment Svc   ─┘                          │  (Eureka / Consul)  │
                                             │                     │
                                             │ order-service:      │
Client-Side Discovery:                       │   10.0.0.1:8081     │
  Client ──▶ Registry: "where is order-svc?"│   10.0.0.2:8081     │
  Registry ──▶ Client: [10.0.0.1, 10.0.0.2] └─────────────────────┘
  Client picks one, calls directly

Server-Side Discovery:
  Client ──▶ Load Balancer ──▶ Registry: "which order-svc instance?"
  LB picks an instance, routes request (client doesn't know IPs)

Problems It Solves

  • Hardcoded IPs break when containers restart with new dynamically assigned IPs
  • Cannot scale service instances up or down without manually updating config files everywhere
  • No visibility into which service instances are healthy and should receive traffic

Spring Boot Example

// Eureka Server — discovery-server/application.yml
spring.application.name: discovery-server
eureka.client.register-with-eureka: false
eureka.client.fetch-registry: false

// Eureka Server main class
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication { ... }

// Each microservice — application.yml
spring.application.name: order-service
eureka.client.service-url.defaultZone: http://discovery-server:8761/eureka/

// Each microservice — main class
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication { ... }

// Calling another service by name — no hardcoded IP
@Configuration
public class WebClientConfig {
    @Bean
    @LoadBalanced                   // resolves service names via Eureka
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }
}

// Usage: http://order-service resolves to a healthy instance
webClient.get().uri("http://order-service/api/orders/{id}", id)

Real-World Examples

  • Netflix Eureka, HashiCorp Consul, Kubernetes internal DNS (kube-dns), AWS Cloud Map

Pros

  • Dynamic service registration — instances appear and disappear automatically
  • Automatic load balancing across healthy instances
  • Health check integration — unhealthy instances removed from registry

Cons

  • Discovery registry itself must be highly available — it's now a critical dependency
  • Extra network hop for registry lookup on each call
  • Registry state is eventually consistent — stale entries possible briefly

Infrastructure

2. Sidecar Pattern

Attach a helper process (the "sidecar") to each service instance that handles infrastructure concerns on its behalf — logging, metrics, service mesh proxy, config management. The sidecar runs as a separate process in the same pod and shares its network namespace with the main application container.

Visual

Kubernetes Pod
┌────────────────────────────────────────────────┐
│  ┌──────────────────────┐  ┌─────────────────┐ │
│  │  Main App Container  │  │ Sidecar (Envoy) │ │
│  │  (Spring Boot)       │  │                 │ │
│  │  Business logic only │  │ mTLS            │ │
│  │  No TLS, no metrics  │  │ Rate limiting   │ │
│  │  No service mesh     │  │ Metrics export  │ │
│  └──────────────────────┘  │ Access logging  │ │
│          ▲                 └───────┬─────────┘ │
│          │ localhost traffic       │            │
└──────────┼─────────────────────────┼────────────┘
           │                         │
  Inbound requests              Outbound to
  via sidecar proxy             Prometheus, Jaeger

Problems It Solves

  • Don't want every service to implement logging, metrics, and mTLS separately in each language
  • Cannot change the main service (legacy Java) but need new infrastructure features like a service mesh
  • Infrastructure concerns leak into business code and create language-specific dependencies

Spring Boot Example

# kubernetes/order-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: order-service          # Main app — pure business logic
          image: order-service:1.2.0
          ports:
            - containerPort: 8080

        - name: envoy-proxy            # Sidecar — all infrastructure concerns
          image: envoyproxy/envoy:v1.28
          ports:
            - containerPort: 9901      # admin port
          volumeMounts:
            - name: envoy-config
              mountPath: /etc/envoy

# Spring Boot app does NOT need to know the sidecar exists.
# Istio can inject Envoy automatically via admission webhook.

Real-World Examples

  • Istio service mesh uses Envoy as a sidecar — injected automatically into every pod
  • AWS AppMesh, Consul Connect, Kubernetes logging sidecars using Fluentd

Pros

  • Infrastructure concerns completely decoupled from business logic
  • Any language or framework benefits — the sidecar is language-agnostic
  • Infrastructure can be upgraded centrally without touching service code

Cons

  • Added memory and CPU overhead per pod for the sidecar process
  • Debugging cross-container issues adds complexity
  • Network latency added by proxy interception on every request

Infrastructure

3. Externalized Configuration

Store all environment-specific configuration — DB URLs, feature flags, timeouts, credentials — outside the application code in a centralized config store. Services pull their config at startup or live-reload it. This is Factor #3 of the 12-Factor App methodology.

Visual

                 ┌──────────────────────────────────┐
                 │  Config Store                    │
                 │  (Spring Cloud Config / Vault /  │
                 │   Kubernetes ConfigMap + Secrets) │
                 └───────┬──────────┬───────────────┘
                         │          │
            ┌────────────┘          └───────────────┐
            ▼                                       ▼
  Order Service                           Payment Service
  pulls: db.url, timeout.ms               pulls: stripe.key, db.url

  Dev env  → db.url=jdbc:postgresql://localhost
  Staging  → db.url=jdbc:postgresql://staging-db
  Prod     → db.url=jdbc:postgresql://prod-db-cluster
  (same config key, different values per environment)

Problems It Solves

  • Config baked into container images means a full redeploy just to change a timeout value
  • Secrets stored in environment variables or worse, in source code
  • Different config values across dev, staging, and prod managed inconsistently across dozens of services

Spring Boot Example

// Config Server — config-server/application.yml
spring:
  application.name: config-server
  cloud.config.server.git:
    uri: https://github.com/myorg/config-repo
    default-label: main

// Each microservice — bootstrap.yml
spring:
  application.name: order-service
  config:
    import: "configserver:http://config-server:8888"

// Config repo: order-service.yml (loaded per service name)
order:
  timeout-ms: 5000
  max-retry: 3
spring.datasource.url: jdbc:postgresql://orders-db:5432/orders_db

// @RefreshScope enables live reload without restart
@RestController
@RefreshScope
public class OrderController {
    @Value("${order.timeout-ms}")
    private int timeoutMs;
}

Real-World Examples

  • Spring Cloud Config Server backed by Git, AWS Parameter Store + Secrets Manager, Kubernetes ConfigMaps + Secrets, HashiCorp Vault

Pros

  • Config changes without redeployment using @RefreshScope
  • Secrets managed securely via Vault — never in source control
  • Consistent config management across all service instances

Cons

  • Config service is a new critical dependency that must be highly available
  • Bootstrap chicken-and-egg: services need config server to start, but config server may not be ready yet
  • Live reload needs careful implementation to avoid inconsistent state mid-request

Observability Patterns

🏗️ System Design · Microservices · 12 min read
Observability

1. Health Check

Each microservice exposes a standardized endpoint (e.g. GET /actuator/health) that reports whether the service is healthy, including its dependencies such as DB, cache, and downstream services. Load balancers and orchestrators use this to route traffic only to healthy instances.

Visual

Load Balancer pings /actuator/health on all instances:

  Instance 1 → UP   { db: UP, redis: UP, downstream: UP }   ✓ receives traffic
  Instance 2 → DOWN { db: DOWN, redis: UP }                  ✗ removed from rotation
  Instance 3 → UP   { db: UP, redis: UP, downstream: UP }   ✓ receives traffic

Kubernetes:
  livenessProbe  → if fails, container is restarted
  readinessProbe → if fails, pod removed from Service endpoints (no traffic)

Problems It Solves

  • Service is running (process alive) but cannot connect to its DB — should not receive traffic
  • Kubernetes or ECS doesn't know if the application is actually ready to serve requests after startup
  • No visibility into partial failures — service is up but a critical dependency is degraded

Spring Boot Example

// build.gradle
// implementation 'org.springframework.boot:spring-boot-starter-actuator'

// application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always

// Custom health indicator — checks downstream payment service
@Component
public class PaymentServiceHealthIndicator implements HealthIndicator {
    @Autowired private PaymentServiceClient paymentClient;

    @Override
    public Health health() {
        try {
            paymentClient.ping();
            return Health.up().withDetail("payment-service", "reachable").build();
        } catch (Exception ex) {
            return Health.down().withDetail("payment-service", ex.getMessage()).build();
        }
    }
}

# kubernetes/deployment.yaml — liveness and readiness probes
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5

Real-World Examples

  • AWS ELB health checks, Kubernetes liveness/readiness probes, Consul health checks, Spring Boot Actuator /health

Pros

  • Automatic removal of unhealthy instances from the load balancer
  • Proactive failure detection before users notice
  • Dependency health visible from one endpoint

Cons

  • Health check endpoint must be lightweight — don't run heavy work in it
  • False positives can remove healthy services if thresholds are tuned too aggressively
  • Cold-start issues: liveness probe fires too early and kills a service that is still initializing

Observability

2. Distributed Tracing

Assigns a unique trace ID to each incoming request that is propagated across all microservices involved in fulfilling it. Each service records a "span" (start time, end time, service name, operation). All spans with the same trace ID are assembled into a visual timeline showing the full request path.

Visual

Trace ID: abc-123-xyz — GET /checkout

  Order Service     |████████████████████████████| 0–50ms
    Payment Service     |████████████████| 10–40ms
    Inventory Service     |████████████| 15–35ms
      DB Query                |████| 20–30ms

Each block = one span. Indentation = call hierarchy.
Trace assembled from spans reported by each service independently.

Span metadata: traceId, spanId, parentSpanId, service, operation, startMs, durationMs, tags

Problems It Solves

  • A request fails but you don't know which of 12 microservices caused it
  • A request is slow — impossible to pinpoint the bottleneck across multiple services without a trace
  • Correlating logs across 6 services manually is painful and error-prone

Spring Boot Example

// build.gradle — Spring Boot 3 + Micrometer Tracing + Zipkin
// implementation 'io.micrometer:micrometer-tracing-bridge-brave'
// implementation 'io.zipkin.reporter2:zipkin-reporter-brave'

// application.yml
management:
  tracing:
    sampling:
      probability: 1.0         # 100% in dev; use 0.1 (10%) in prod
spring:
  zipkin:
    base-url: http://zipkin:9411

// Trace ID is automatically propagated via HTTP headers:
//   traceparent: 00-abc123xyz-def456-01  (W3C standard)
//   X-B3-TraceId: abc123xyz  (Zipkin B3 format)

// WebClient and RestTemplate propagate headers automatically.
// Custom span for a specific operation:
@Service
public class InventoryService {
    @Autowired private Tracer tracer;

    public StockResult checkStock(Long itemId) {
        Span span = tracer.nextSpan().name("inventory.checkStock").start();
        try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
            return inventoryRepository.findStock(itemId);
        } finally {
            span.end();
        }
    }
}

Real-World Examples

  • Zipkin, Jaeger, AWS X-Ray, Datadog APM; OpenTelemetry is the vendor-neutral standard

Pros

  • End-to-end request visibility across all services
  • Bottleneck identification with precise timing per span
  • Trace IDs auto-propagated — mostly zero code changes needed

Cons

  • 100% sampling adds overhead — use sampling rate < 10% in production
  • Storage cost for trace data can be significant at high QPS
  • Requires additional infrastructure (Zipkin, Jaeger, or a managed APM service)

Observability

3. Performance Metrics

Each service emits numerical measurements of its behavior — request rate, error rate, latency percentiles (p50/p95/p99), JVM memory, DB pool usage. Metrics are collected into a time-series database and visualized in dashboards. Alerts fire when metrics breach thresholds.

Visual

Spring Boot Actuator
  /actuator/prometheus  ←  Prometheus scrapes every 15s
                                    │
                                    ▼
                            Prometheus (time-series DB)
                                    │
                                    ▼
                             Grafana Dashboard

  Request Rate:    120 req/s
  Error Rate:        0.2%
  P99 Latency:     245 ms
  DB Pool Active:   15 / 20 connections

Alerting: if error_rate > 1% for 5 minutes → PagerDuty alert

Problems It Solves

  • Something is wrong but you don't know what or since when — metrics give you a timeline
  • SLA breaches go undetected until users complain — alerts catch them first
  • Capacity planning without historical data is guesswork

Spring Boot Example

// build.gradle
// implementation 'io.micrometer:micrometer-registry-prometheus'

// application.yml
management:
  endpoints.web.exposure.include: prometheus,health,info
  metrics.export.prometheus.enabled: true

// Custom metrics in service code
@Service
public class OrderService {
    private final Counter orderCounter;
    private final Timer orderTimer;

    public OrderService(MeterRegistry registry) {
        this.orderCounter = Counter.builder("orders.created")
            .tag("region", "india")
            .description("Total orders created")
            .register(registry);
        this.orderTimer = Timer.builder("orders.processing.time")
            .description("Order processing latency")
            .register(registry);
    }

    public Order createOrder(OrderRequest req) {
        return orderTimer.record(() -> {
            Order order = processOrder(req);
            orderCounter.increment();
            return order;
        });
    }
}

Real-World Examples

  • Prometheus + Grafana (most common open-source stack), AWS CloudWatch, Datadog, Dynatrace
  • The Four Golden Signals from Google SRE: Latency, Traffic, Errors, Saturation

Pros

  • Proactive alerting before users notice degradation
  • Capacity planning and trend analysis with historical data
  • SLA tracking with precise p95/p99 percentile data

Cons

  • High-cardinality labels (e.g. one label per user) can explode Prometheus storage
  • Alert fatigue if thresholds are poorly tuned — engineers start ignoring alerts
  • Metrics alone don't tell you WHY — need traces and logs too (the observability triad)

Observability

4. Log Aggregation

Each microservice writes structured logs (JSON format) to stdout. A log shipping agent (Fluentd, Filebeat, or Logstash) collects logs from all service instances across all hosts and forwards them to a centralized log store. Engineers search and analyze all logs from one place.

Visual

Order Service  ─┐  JSON logs to stdout
User Service   ─┤  JSON logs to stdout    Fluentd agent
Payment Svc    ─┘  JSON logs to stdout ──▶ (on each node) ──▶ Elasticsearch
                                                                      │
                                                               Kibana Dashboard
                                                            (search, filter, alert)

Each log line includes:
{
  "timestamp": "2025-01-15T10:23:44Z",
  "level": "INFO",
  "service": "order-service",
  "traceId": "abc-123",      ← correlates with distributed trace
  "userId": "u-456",
  "message": "Order created",
  "orderId": "ord-789"
}

Problems It Solves

  • Cannot SSH into containers to read logs — they are ephemeral and may have restarted
  • A request touches 6 services — correlating logs across 6 different machines without central collection is impossible at scale
  • Logs lost when a container crashes before they are shipped off the host

Spring Boot Example

// build.gradle — Logstash encoder for structured JSON logs
// implementation 'net.logstash.logback:logstash-logback-encoder:7.4'

// src/main/resources/logback-spring.xml
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <customFields>{"service":"order-service"}</customFields>
    </encoder>
  </appender>
  <root level="INFO"><appender-ref ref="STDOUT"/></root>
</configuration>

// MDC injects traceId and userId into every log line automatically
@Component
public class MdcFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpReq = (HttpServletRequest) req;
        MDC.put("traceId", httpReq.getHeader("X-Trace-Id"));
        MDC.put("userId",  httpReq.getHeader("X-User-Id"));
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}

Real-World Examples

  • ELK Stack (Elasticsearch + Logstash + Kibana), EFK Stack (Elasticsearch + Fluentd + Kibana)
  • AWS CloudWatch Logs + Insights, Grafana Loki + Promtail (lighter weight alternative to ELK)

Pros

  • Centralized search across all services from one UI
  • Correlate logs across services using traceId field
  • Logs survive container restarts — shipped off-host before crash

Cons

  • ELK cluster is expensive and operationally complex to run at scale
  • Log volume can be enormous — avoid DEBUG level in production
  • PII in logs (emails, addresses) is a compliance and GDPR risk

Deployment Patterns

🏗️ System Design · Microservices · 6 min read
Deployment

1. Blue-Green Deployments

Maintain two identical production environments — Blue (currently live) and Green (idle). Deploy the new version to Green, run tests, then switch the load balancer to route traffic to Green. Blue becomes idle and is your instant rollback target. After confidence, decommission or reuse Blue for the next deployment.

Visual

Before switch: Blue is live Load Balancer Blue (v1.0) LIVE — 100% traffic Green (v1.1) IDLE — deploy + test here After switch: flip load balancer to Green LB now routes to Green Blue becomes idle → instant rollback Green is now live with v1.1

Problems It Solves

  • Downtime during deployments — users see errors while old version shuts down and new version starts
  • No instant rollback — a bad deploy requires another full deploy cycle to fix
  • Cannot test the new version in a production-identical environment before switching live traffic

Spring Boot Example

# docker-compose.yml — two service instances
services:
  order-service-blue:
    image: order-service:1.0.0
    ports: ["8080:8080"]
    environment:
      - SPRING_PROFILES_ACTIVE=prod

  order-service-green:
    image: order-service:1.1.0
    ports: ["8081:8080"]
    environment:
      - SPRING_PROFILES_ACTIVE=prod

# nginx.conf — toggle upstream to switch blue↔green
upstream order_backend {
    server order-service-blue:8080;   # comment out to switch to green
    # server order-service-green:8081;
}

# Kubernetes — blue-green via Service selector switch
# Blue deployment has label: version=blue
# Green deployment has label: version=green
# Service selector: { version: blue }  ← change to green to switch
kubectl patch service order-service \
  -p '{"spec":{"selector":{"version":"green"}}}'

Real-World Examples

  • Netflix, Amazon, and Google all use blue-green or canary (a traffic-split variant) for zero-downtime deploys
  • AWS Elastic Beanstalk has built-in blue-green support; Kubernetes supports this natively

Pros

  • Zero-downtime deployments — traffic switches atomically
  • Instant rollback — just switch the load balancer back to Blue
  • Test new version in a production-identical environment before full switch

Cons

  • Requires 2x infrastructure cost during the deployment window
  • Database schema changes are hard — both Blue and Green must support the same schema simultaneously
  • Long-lived user sessions may be dropped when traffic switches environments

Rate Limiting

🏗️ System Design · Patterns · 5 min read

Rate limiting controls how many requests a client can make in a time window, protecting APIs from abuse and DDoS attacks.

Algorithms

  • Token Bucket — tokens refill at a fixed rate; burst allowed up to bucket capacity. Most common.
  • Leaky Bucket — requests processed at a constant rate regardless of burst; smooths traffic.
  • Fixed Window Counter — count requests per time window; simple but vulnerable to edge spikes.
  • Sliding Window Log — accurate but memory-intensive.

Implementation

  • Store counters in Redis (fast, shared across server instances)
  • Return 429 Too Many Requests with a Retry-After header
  • Use Nginx limit_req module for simple cases

Authentication & Authorization

🏗️ System Design · Security & Best Practices · 9 min read

Two foundational, often-confused concerns in any distributed system:

  • Authentication (AuthN)who are you? Verifying identity (login, tokens, biometrics).
  • Authorization (AuthZ)what are you allowed to do? Granting/denying access to resources.

At scale these are usually delegated to a dedicated Identity Provider using open standards — OAuth 2.0 and OpenID Connect — so individual services never handle passwords directly.

OAuth 2.0 & OpenID Connect (OIDC)

OAuth 2.0 is a delegated authorization framework — it lets an app access a user's data in another service without sharing passwords. OIDC is a thin authentication (identity) layer built on top of OAuth 2.0.

The 4 Key Roles

  • Resource Owner — the user who owns the data
  • Client — the app requesting access (your Spring Boot app)
  • Authorization Server — validates identity and issues tokens (Google, Okta, Keycloak)
  • Resource Server — the API being accessed (e.g., Google Calendar API)

Authorization Code Flow (8 Steps)

// Most secure OAuth flow — used by server-side apps 1. User clicks "Login with Google" in your app 2. App redirects to Google with: clientId, redirectUri, responseType=code, scope 3. Google authenticates the user (login page) 4. User sees consent screen: "Allow App X to read your contacts?" 5. User approves 6. Google redirects back to app with short-lived Authorization Code 7. App server exchanges Code + clientSecret → Access Token (server-to-server, never exposed to browser) 8. App uses Access Token to call Google APIs on user's behalf

Token Types

TokenPurposeLifetime
Authorization CodeExchanged for Access Token (one-time use)~60 seconds
Access TokenCredential to call Resource Server APIsShort (minutes–hours)
Refresh TokenGet new Access Tokens without re-loginLong (days–months)
ID Token (OIDC)JWT with identity claims (name, email, sub)Typically same as Access Token

OAuth 2.0 vs OIDC

AspectOAuth 2.0OIDC
Answers"Can this app access X?""Who is this user?"
PurposeAuthorization (access control)Authentication (identity)
Token issuedAccess Token onlyAccess Token + ID Token (JWT)
TriggerAny scopescope includes openid
Use case"Let app post to Twitter""Single Sign-On across apps"
💡 Key insight: OAuth solves authorization ("what can you do"), OIDC solves authentication ("who are you"). They're not the same thing. OIDC = OAuth 2.0 + identity.

12-Factor App with Spring Boot

🏗️ System Design · Security & Best Practices · 7 min read

The 12-Factor methodology is a set of best practices for building scalable, maintainable, cloud-native applications. Spring Boot is built to align with these principles.

#FactorPrincipleSpring Boot Implementation
1CodebaseOne repo, many deploysGit, one repo per service
2DependenciesDeclare all deps explicitlypom.xml with spring-boot-starter-*
3ConfigConfig in environment, not code${DB_URL} in application.properties; Spring Cloud Config
4Backing ServicesDB/queue = attached resourceSwap DB via config — no code change (@Repository)
5Build/Release/RunStrict stage separationmvn package → release JAR → java -jar
6ProcessesStateless, share-nothingREST endpoints hold no session state; sessions in Redis
7Port BindingSelf-contained, export via portEmbedded Tomcat: java -jar app.jar → runs on port 8080
8ConcurrencyScale out via process modelRun multiple instances behind Kubernetes / load balancer
9DisposabilityFast startup, graceful shutdownIdempotent endpoints; server.shutdown=graceful
10Dev/Prod ParityKeep environments similarDocker containers ensure identical runtime
11LogsTreat logs as event streamsSLF4J → stdout → ELK (Elasticsearch, Logstash, Kibana)
12Admin ProcessesOne-off tasks in same environmentFlyway/Liquibase DB migrations; Spring Batch jobs

Factor 3 (Config) — Most Important in Practice

# application.properties — never hardcode env-specific values spring.datasource.url = jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} spring.datasource.username = ${DB_USER} spring.datasource.password = ${DB_PASS} jwt.secret = ${JWT_SECRET} # Set via environment variables, Docker secrets, or Kubernetes ConfigMaps # This same code runs in dev, staging, and production unchanged
💡 Why it matters: Following 12-Factor makes your app cloud-native — easy to containerise, scale horizontally, deploy to Kubernetes, and operate in CI/CD pipelines. It's the foundation of modern DevOps.

Case Study: Design a URL Shortener

🏗️ System Design · Case Studies · 15 min read

Design a system like bit.ly or TinyURL — convert an arbitrarily long URL into a short 6-7 character code, and redirect anyone who visits that code back to the original URL instantly. Deceptively simple on the surface, this problem covers ID generation, caching strategy, and a read-heavy scale challenge.

Step 1 — Functional Requirements

  • User submits a long URL → system returns a unique short code (e.g. 7ds.in/aB3kR)
  • User visits a short URL → system redirects to the original URL
  • Links can have an optional custom alias (e.g. 7ds.in/summer-sale)
  • Links can have an optional expiry date — after which they return 410 Gone
  • Basic click analytics: total clicks and last click timestamp per short URL

Out of scope: user accounts, teams, spam/phishing detection, QR code generation, A/B redirect, paid tiers.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

100M new URLs/day (1,160 creates/sec) · 10B redirects/day (115,700 reads/sec peak ~300K QPS) — 100:1 read/write ratio

Availability

99.99% uptime. A redirect failure is a broken link — unacceptable in production. Reads must survive any single-component failure.

Latency

Redirect <10ms p99 — users must not notice the hop. URL creation <200ms. Analytics writes are best-effort; a few seconds lag is fine.

Storage

100M URLs/day × 500B per row = 50GB/day ≈ 18TB/year. Very manageable — the challenge is read throughput, not storage volume.

Step 3 — System's API & Sequence of Events

API Design

POST   /v1/urls                           → { shortCode, shortUrl, expiresAt }   # create
GET    /{shortCode}                        → 302 Redirect to longUrl              # redirect
GET    /v1/urls/{shortCode}                → { longUrl, createdAt, expiresAt }    # lookup
GET    /v1/urls/{shortCode}/stats          → { clicks, lastClickAt, createdAt }   # analytics
DELETE /v1/urls/{shortCode}                → 204 No Content                       # owner delete

Create Short URL — Sequence of Events

── Sync: shorten and store ──
Client       →  API Gateway   : POST /v1/urls { longUrl, alias?, expiresAt? }
API Gateway  →  URL Service   : forward (rate-limit · validate URL format)
URL Service  →  ID Generator  : get uniqueId (Twitter Snowflake — 64-bit, distributed)
ID Generator →  URL Service   : uniqueId → Base62 encode → shortCode  (e.g. "aB3kR", 6 chars)
URL Service  →  MySQL         : INSERT (shortCode, longUrl, createdAt, expiresAt)
URL Service  →  Redis         : SET url:aB3kR longUrl EX 86400   # warm the cache on write
URL Service  →  Client        : { shortCode: "aB3kR", shortUrl: "7ds.in/aB3kR" }

Redirect — Sequence of Events

── Cache HIT (fast path, ~3ms) ──
Client       →  API Gateway   : GET /aB3kR
API Gateway  →  URL Service   : forward
URL Service  →  Redis         : GET url:aB3kR
Redis        →  URL Service   : ✓ HIT → longUrl
URL Service  →  Kafka         : publish CLICK_EVENT { shortCode, ts, ip, userAgent }   # fire-and-forget
URL Service  →  Client        : 302 Redirect → longUrl

── Cache MISS (cold path — first hit or cache eviction) ──
URL Service  →  MySQL         : SELECT longUrl, expiresAt WHERE shortCode='aB3kR'
MySQL        →  URL Service   : longUrl (or 404 if not found / 410 if expired)
URL Service  →  Redis         : SET url:aB3kR longUrl EX 86400   # re-warm cache
URL Service  →  Client        : 302 Redirect → longUrl

── Async: analytics aggregation ──
Analytics    ←  Kafka         : consume CLICK_EVENT
Analytics    →  MySQL         : UPSERT url_stats (shortCode, clicks += 1, lastClickAt)

Visual Sequence Diagram

① CREATE — Shorten a URL (sync) ② REDIRECT — Cache HIT (fast path ~3ms) ③ REDIRECT — Cache MISS → DB fallback + async analytics Client API Gateway URL Service ID Generator MySQL (URL DB) Redis Cache Kafka Analytics Worker POST /v1/urls { longUrl } forward (rate-limit · validate) get uniqueId (Snowflake) uniqueId → Base62 → "aB3kR" INSERT (shortCode, longUrl, expiresAt) SET url:aB3kR longUrl EX 86400 { shortCode: "aB3kR", shortUrl } GET /aB3kR forward GET url:aB3kR ✓ HIT → longUrl (<1ms) 302 Redirect → longUrl SELECT longUrl WHERE shortCode='aB3kR' longUrl (or 404 / 410 if expired) SET url:aB3kR EX 86400 (re-warm) publish CLICK_EVENT { shortCode, ts, ip } 302 Redirect → longUrl consume CLICK_EVENT UPSERT url_stats (shortCode, clicks += 1)

Step 4 — Functional Architecture

API Gateway

Single entry point. Handles rate limiting (e.g. 100 shortens/hour per IP), JWT auth for owner operations, routing to URL Service or direct to Redirect Service.

URL Service

Stateless. Handles URL creation, validation (rejects malformed URLs, checks against a phishing blocklist), and the redirect lookup. Scales horizontally behind the load balancer.

ID Generator (Snowflake)

Generates globally unique 64-bit IDs across distributed nodes without coordination. Each ID encodes timestamp + datacenter + machine + sequence. Base62 encoding gives a 7-char shortCode with zero collision risk.

MySQL (URL DB)

Stores (shortCode PK, longUrl, userId, createdAt, expiresAt). Uniqueness index on shortCode. Read replicas handle redirect lookups; the primary handles writes only.

Redis Cache

Key: url:{shortCode}, value: longUrl, TTL: 24h. Absorbs 99% of redirect reads — hot URLs like campaign links and viral posts are served entirely from memory. Cache-aside pattern.

Kafka + Analytics Worker

CLICK_EVENT published on every redirect (fire-and-forget). Analytics Worker consumes events and UPSERTs aggregated stats to MySQL. Decouples analytics from the hot redirect path.

CDN (Edge Caching)

For extremely hot URLs, the 302 redirect itself can be cached at CDN edge nodes. A viral link resolved at a CDN PoP doesn't touch origin servers at all.

Cleanup Worker

Background cron. Scans MySQL for rows where expiresAt < NOW(), deletes them, and evicts their Redis keys. Keeps storage bounded without on-read expiry checks.

Step 5 — Addressing Non-Functional Requirements

High Availability (99.99%)

  • URL Service is stateless → N replicas across 3 AZs behind a load balancer
  • MySQL: primary (writes) + 2 read replicas across AZs; automatic failover via RDS Multi-AZ
  • Redis: ElastiCache cluster mode with Multi-AZ automatic failover (<60s)
  • If Redis is completely down, URL Service falls back to MySQL — latency degrades but redirects keep working

Redirect Latency <10ms

  • Redis cache hit — sub-millisecond lookup; 302 returned in <3ms end-to-end for cached URLs
  • Cache warming on write — every newly created URL is immediately written to Redis, so the very first redirect is always a cache hit
  • CDN edge caching — viral links cached at 200+ PoPs globally; zero origin hops for repeated accesses in the same region
  • Analytics is async — CLICK_EVENT published to Kafka after the redirect; never blocks the response

Short Code Uniqueness — Why Not Hash?

  • Hash (MD5 + truncate) — fast but collision-prone; two URLs can produce the same 6-char prefix. Requires retry loop.
  • Auto-increment + Base62 — simple, zero collisions, but requires a central counter (single point of failure).
  • Snowflake ID + Base62 — distributed, collision-free, time-sortable, no coordination. Best for production. Base62 with 6 chars = 62⁶ = 56.8B unique codes.
  • 301 vs 302: Use 302 (Temporary) so every redirect hits our server and can be counted for analytics. 301 would be cached by browsers, bypassing analytics.
── Final Architecture Summary ──
Client
  │
  ▼
CDN (CloudFront — caches hot 302 redirects at edge)
  │
Load Balancer (Multi-AZ)
  │
  ├──▶ API Gateway (rate-limit · auth · routing)
  │         │
  │    URL Service (stateless, N replicas)
  │    │      │
  │    │    Redis Cache  (url:{shortCode} → longUrl, TTL 24h)
  │    │      │  (cache miss only)
  │    │    MySQL Read Replica   (SELECT longUrl)
  │    │
  │    ├──▶ MySQL Primary  (INSERT on create)
  │    └──▶ Kafka → Analytics Worker → MySQL (url_stats)
  │
  └──── Cleanup Worker  (cron: DELETE WHERE expiresAt < NOW())
💡 Key insight: URL shortening is a read-heavy caching problem disguised as a storage problem. The storage is trivial (18TB/year). The real challenge is serving 115K redirects/second at under 10ms — solved almost entirely by Redis. The Snowflake + Base62 ID strategy and the 301-vs-302 choice are the two classic interview differentiators.

Case Study: Design a Scalable News Feed

🏗️ System Design · Case Studies · 15 min read

Design a Facebook/Twitter-style home feed — show each user a personalised, paginated list of posts from people they follow. This is one of the most common and nuanced system design interview questions because it forces you to reason about fan-out trade-offs at massive scale.

Step 1 — Functional Requirements

  • Users can create posts (text, image links, URLs)
  • Users can follow / unfollow other users
  • Users see a home feed — paginated, reverse-chronological list of posts from people they follow
  • Users can like posts
  • Users can comment on posts

Out of scope: DMs, Stories, Reels, ads, sponsored content, full-text search, trending topics.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

500M users, 10M DAU, 5M posts/day (~58 posts/sec write), 50M feed loads/day (~580 reads/sec, peak 2K/sec)

Availability

99.99% uptime. Feed reads must degrade gracefully under load. Post creation must never silently fail.

Latency

Feed load <200ms p99. Post creation <500ms. Like/comment <100ms. Notifications delivered within 30 seconds.

Storage

Posts: ~500B × 5M/day = 2.5GB/day. Feed cache: Redis sorted sets per user (active users only). Media: S3 + CDN (separate from post metadata).

Step 3 — System's API & Sequence of Events

API Design

POST /v1/posts                        → { postId, createdAt, mediaUrl? }
GET  /v1/feed?userId=&after=&limit=20  → { posts:[], nextCursor }
POST /v1/likes                         → { postId, userId, totalLikes }
POST /v1/follows                       → { followerId, followeeId }
DELETE /v1/follows/{followeeId}        → 204 No Content

Create Post — Sequence of Events

── Sync: create post and notify followers ──
Client       →  API Gateway   : POST /v1/posts { text, mediaUrl?, userId }
API Gateway  →  Post Service  : forward (auth validated, rate-limited)
Post Service →  Post DB       : INSERT post (postId, userId, text, mediaUrl, createdAt)
Post Service →  Kafka         : publish POST_CREATED { postId, userId, createdAt }
Post Service →  Client        : { postId, createdAt }

── Async: fan-out to follower feeds ──
Feed Worker  ←  Kafka         : consume POST_CREATED
Feed Worker  →  Follow DB     : GET followers of userId (paginated, up to 10K)
Feed Worker  →  Redis         : ZADD feed:{followerId} createdAt postId  (per follower)
# Celebrity users (>10K followers): skip fan-out; pull-merge on feed load

Load Feed — Sequence of Events

── User opens their home feed ──
Client       →  API Gateway   : GET /v1/feed?userId=U123&after=&limit=20
API Gateway  →  Feed Service  : forward
Feed Service →  Redis         : ZREVRANGE feed:U123 0 19  → [postId1, postId2, ...]
# For celebrity followees: pull-merge their latest posts into result
Feed Service →  Post DB       : MGET post:{postId1}, post:{postId2}, ...  (batch fetch)
Feed Service →  Client        : { posts: [20 hydrated post objects], nextCursor }

── Async: notification delivery ──
Notify Worker ← Kafka         : consume POST_CREATED
Notify Worker →  Push Service : SEND push notification to active followers
Push Service  →  FCM/APNs     : deliver "New post from @username"

Visual Sequence Diagram

① CREATE POST (sync) ② LOAD FEED (sync — Redis + Post DB) ③ ASYNC FAN-OUT + NOTIFICATIONS Client API Gateway Post / Feed Service Post DB (PostgreSQL) Kafka Feed Worker (Kafka consumer) Redis Feed Cache Notify Worker + Push Svc Follow DB (pgSQL) POST /v1/posts { text, userId } forward (auth · rate-limit) INSERT post (postId, userId, text) publish POST_CREATED { postId, userId } { postId, createdAt } GET /v1/feed?userId=U123 forward ZREVRANGE feed:U123 0 19 [postId1, postId2, ...postId20] MGET post:ids (batch fetch metadata) { posts:[20 hydrated objects], nextCursor } consume POST_CREATED GET followers (paginated) ZADD feed:{followerId} score postId consume POST_CREATED → push notification FCM/APNs: "New post from @username"

Step 4 — Functional Architecture

API Gateway

Rate limiting (post creation: 10/min per user), auth token validation, routing to Post Service or Feed Service. SSL termination.

Post Service

Creates posts; validates content; stores to PostgreSQL; publishes POST_CREATED event to Kafka. Returns immediately — fan-out is async.

Feed Service

Reads feed:{userId} sorted set from Redis (ZREVRANGE); batch-fetches post metadata from Post DB (MGET). Hybrid pull for celebrity followees.

Post DB (PostgreSQL)

Stores post content, likes count, comment count. Read replicas for feed hydration (MGET). Write path: Post Service → primary only.

Redis Feed Cache

feed:{userId} as a Redis sorted set scored by timestamp. ZADD on new post; ZREVRANGE on feed load. Only maintained for active users (TTL = 7 days).

Kafka + Feed Worker

Feed Worker consumes POST_CREATED; fetches follower list; ZADDs postId into each follower's Redis feed. Skips celebrity users (>10K followers).

Follow DB (PostgreSQL)

Stores (followerId, followeeId) pairs. Feed Worker queries this to get the follower list for fan-out. Indexed on followeeId for fast lookup.

Notification Worker + Push Service

Consumes POST_CREATED from Kafka. Sends push notifications (FCM for Android, APNs for iOS) to followers who are not currently viewing their feed.

Step 5 — Addressing Non-Functional Requirements

High Availability

  • Post Service and Feed Service are stateless — N replicas across multiple AZs behind a load balancer
  • PostgreSQL: primary + read replicas across AZs; automatic failover
  • Redis cluster with Multi-AZ replication; if Redis is unavailable, Feed Service falls back to pulling posts directly from PostgreSQL

Feed Load Latency <200ms

  • Redis ZREVRANGE is O(log N + K) — returns top 20 posts in under 1ms for any user
  • Post metadata batch-fetched with a single MGET (one Redis round-trip for all 20 posts)
  • Fan-out happens asynchronously — feed is pre-built before the user requests it

Celebrity Fan-Out Problem

  • A celebrity with 10M followers posting = 10M Redis ZADD operations — blocks the Feed Worker for minutes
  • Hybrid approach: for users with >10K followers, skip fan-out on write. On feed load, the Feed Service pulls the celebrity's latest posts from Post DB and merges them into the sorted result
  • This trades a tiny bit of feed-load latency for a massive reduction in fan-out write amplification
── Final Architecture Summary ──
Client
  │
Load Balancer (Multi-AZ)
  │
  ├──▶ API Gateway (rate-limit · auth)
  │         │
  │    ┌────┴──────────────┐
  │    ▼                   ▼
  │  Post Service      Feed Service
  │    │      │             │
  │    │    Kafka        Redis Feeds
  │    │      │          (ZREVRANGE)
  │    │    ┌─┴───────────────┐
  │    │    ▼                 ▼
  │    │  Feed Worker    Notify Worker
  │    │  (ZADD feeds)   (FCM/APNs)
  │    │       │
  │    │    Follow DB (pgSQL)
  │    │
  │    └──▶ Post DB (pgSQL — read replicas for hydration)
💡 Key insight: News feed design is a fan-out problem. Writing is fast (one INSERT to Post DB) but the fan-out to N followers is expensive. Push-on-write works for normal users; pull-on-read is used for celebrities. The Redis sorted set (ZADD feed:{userId} timestamp postId) is the backbone — it gives you a pre-ranked, O(1)-to-access feed per user. The celebrity threshold (e.g. 10K followers) is a tuneable parameter based on your write budget.

Software Architect's Mindset

🏗️ System Design · Architect's Mindset · 8 min read

Before diving into components and diagrams, the most important thing is how you think. Senior engineers and architects approach system design with three foundational mindsets that separate good designs from great ones.

1. Abstraction

Abstraction means thinking in black boxes — defining what a component does (its inputs, outputs, and contract) without worrying about how it does it. This lets you reason about a system at the right altitude.

Design altitude matters

An "Image Storage Service" is a useful abstraction. Whether it's S3, GCS, or a custom NFS cluster is an implementation detail — decide that later once requirements are clear.

Define contracts first

Define what a service accepts and returns before deciding how it works internally. This lets teams work in parallel and change implementations without breaking integrations.

Avoid premature detail

Drawing TCP packet flows when the interviewer (or stakeholder) wants a high-level architecture is a red flag. Abstract until the level of detail serves the conversation.

Compose abstractions

Complex systems are just well-composed abstractions. A "Feed Service" hides the fan-out logic, caching, and ranking — callers only need to know: give me a userId, get back a feed.

💡 Practical rule: If you can name a component in 2-3 words and describe it in one sentence, you've found the right abstraction level. If it takes a paragraph, split it further.

2. Every System is Unique

There is no universal template. Two companies building "an image sharing app" may have radically different requirements, constraints, and trade-offs.

  • Scale differs — a startup with 1,000 users has different needs than Instagram with 2 billion. Designing for billion-user scale on day one wastes money and complexity.
  • Consistency vs. availability trade-offs differ — a banking app needs strong consistency (ACID). A social media feed is fine with eventual consistency (AP system).
  • Budget and team size differ — a 5-person team cannot operate a 30-microservice Kubernetes cluster. Operational complexity is a real cost.
  • Latency requirements differ — a trading system needs sub-millisecond latency. A batch reporting system can tolerate minutes.
  • Read/write ratios differ — a read-heavy news feed optimises for read throughput. A write-heavy IoT time-series system optimises for write throughput.
💡 Takeaway: Always start by understanding this system's requirements before reaching for patterns. Cargo-culting Netflix's architecture into a 10k-user app creates complexity without benefit.

3. System Design Doesn't Have One Correct Solution

Unlike algorithm problems with optimal solutions, system design has many valid answers, each with different trade-offs. Your job is to make informed decisions and justify them clearly.

SQL vs NoSQL

Both can work for user profiles. SQL gives you ACID and complex queries. NoSQL gives you easier horizontal scale. The "right" choice depends on your team's expertise and query patterns.

Fan-out on Write vs Read

Pre-computing feeds (write) makes reads fast but writes expensive. Computing on read saves writes but makes reads slow. Both are used in production — choose based on your read/write ratio.

Microservices vs Monolith

Microservices scale well and isolate failures. Monoliths are simpler to build and debug. Many successful products run on well-structured monoliths — it's a trade-off, not a hierarchy.

Sync vs Async

Synchronous calls are simple and easy to reason about. Async message queues decouple services and absorb spikes. Use async when the caller doesn't need an immediate response.

💡 In interviews: Saying "I'd use X because of trade-off Y, though Z would be an alternative if the constraint were different" is far stronger than presenting one answer as the only option.

System Design — Step-by-step Process

🏗️ System Design · Design Process · 10 min read

A consistent, structured process prevents you from jumping straight to architecture before you understand what you're building. Use this five-step framework for every system design problem.

Step 1 — Gathering Functional Requirements

Functional requirements define what the system does — the features and use cases it must support. Never start designing until these are clear.

What to ask / clarify

  • Who are the users and what actions can they take?
  • Which features are in scope for this design? (Explicitly out-of-scope items save time)
  • What is the core user journey (happy path)?
  • Are there different user roles? (e.g., uploader vs. viewer, rider vs. driver)
Example — Image Sharing Platform:
✅ In scope: upload image, view image, follow users, view home feed
❌ Out of scope: comments, DMs, Stories, payments, ads

Step 2 — Gathering Non-Functional Requirements

Non-functional requirements define how well the system must perform. These drive your architectural choices far more than functional requirements do.

Key dimensions

  • Scale — How many total users? How many DAU? What is the peak QPS (queries per second)?
  • Availability — 99.9% (8.7 hrs downtime/year)? 99.99% (52 min/year)?
  • Latency — p99 read latency target? Write latency? (e.g., feed loads in < 200ms)
  • Consistency — Is eventual consistency acceptable, or do you need strong/linearisable reads?
  • Durability — Can any data ever be lost? (especially critical for user-generated content)
  • Storage — How much data is written per day? Growth rate?
Scale estimation — Image Sharing Platform:
Users: 500M total, 10M DAU
Uploads: 2M images/day  →  ~23 uploads/sec
Reads:   200M/day       →  ~2,300 reads/sec  (100:1 read/write ratio)
Storage: 2M × 3MB avg   =   6 TB/day new data
Feed:    10M users × 5 loads/day = 50M feed requests/day

Step 3 — Defining System's API & Sequence of Events

Before drawing boxes and arrows, define the API contracts and draw a sequence diagram for the core flows. This forces you to think about data ownership and service responsibilities before you commit to an architecture.

API definition

REST API — Image Sharing Platform:
POST   /v1/images                     → Upload image (multipart)
GET    /v1/images/{imageId}           → Image metadata
GET    /v1/images/{imageId}/file      → Image binary (usually via CDN)
POST   /v1/users/{userId}/follow      → Follow a user
DELETE /v1/users/{userId}/follow      → Unfollow a user
GET    /v1/users/{userId}/feed        → Paginated home feed
POST   /v1/images/{imageId}/likes     → Like an image

Sequence of events

Upload flow:
Client  →  API Gateway   : POST /v1/images
API GW  →  Image Service : forward request
Image Service  →  S3            : PUT image bytes → returns s3Key
Image Service  →  Metadata DB   : INSERT (imageId, userId, s3Key, caption, createdAt)
Image Service  →  Kafka         : publish { event: IMAGE_UPLOADED, imageId, userId }
Image Service  →  Client        : 201 { imageId, imageUrl }

[Async] Feed Worker     ← Kafka : consume IMAGE_UPLOADED
        Feed Worker  →  User Service : GET followers(userId)
        Feed Worker  →  Redis        : ZADD feed:{followerId} score=timestamp member=imageId

[Async] Thumb Worker    ← Kafka : consume IMAGE_UPLOADED
        Thumb Worker →  S3      : store resized thumbnails (320px, 640px, 1080px)
View feed flow:
Client  →  API Gateway  : GET /v1/users/{userId}/feed?limit=20
API GW  →  Feed Service : forward
Feed Service  →  Redis         : ZREVRANGE feed:{userId} 0 19
Feed Service  →  Metadata DB   : batch-fetch metadata for returned imageIds
Feed Service  →  Client        : [{ imageId, caption, thumbnailUrl, createdAt, likesCount }]

Step 4 — Designing for Functional Requirements

Now draw the functional architecture — the components required to fulfil the use cases, without worrying about scale yet. Keep it simple; every box must justify its existence.

API Gateway

Single entry point. Handles routing, JWT validation, and rate limiting. Routes to the correct downstream service based on URL path.

Image Upload Service

Accepts multipart uploads, stores image to Object Storage, writes metadata to DB, publishes event to Kafka. Stateless — horizontally scalable.

Feed Service

Serves paginated home feeds from a Redis cache. Reads pre-built feed lists written by the Feed Worker. Falls back to DB on cache miss.

User Service

Manages user accounts, profiles, and follower graphs. Provides a getFollowers(userId) endpoint consumed by the Feed Worker on each upload.

Object Storage (S3)

Stores raw image bytes. Immutable once written. Accessed via CDN for reads — the Image Service writes; clients never read directly from S3.

Metadata DB (PostgreSQL)

Stores image metadata: imageId, userId, s3Key, caption, createdAt, likesCount. Indexed on userId + createdAt for profile grids.

Feed Cache (Redis)

Sorted sets: key = feed:{userId}, score = Unix timestamp, member = imageId. ZREVRANGE for O(log n + k) feed reads. Holds ~1,000 entries per user.

Kafka + Workers

Decouples upload from fan-out and thumbnail generation. Feed Worker and Thumbnail Worker consume IMAGE_UPLOADED events independently and at their own pace.

Step 5 — Addressing Non-Functional Requirements

Take the functional architecture and layer on the components that meet your scale, availability, and latency targets. This is where you earn your NFR SLAs.

  • Availability — Deploy every service in multiple AZs. Add health checks and circuit breakers. S3 and managed Redis/Kafka handle AZ failures transparently.
  • Read latency — Put CloudFront CDN in front of S3. Cache images at edge nodes globally. Users in Mumbai get images from Mumbai PoP, not US-East.
  • Write throughput — Use pre-signed S3 URLs: Image Service generates a URL, client uploads directly to S3 — bypasses your app servers for large payloads entirely.
  • DB read scale — Add read replicas to PostgreSQL. Feed and profile reads hit replicas; writes go to primary.
  • Feed fan-out for celebrities — Users with >1M followers skip write fan-out (too expensive). Their posts are fetched on read and merged with the pre-built feed (hybrid fan-out).
  • Storage growth — 6 TB/day → use S3 Intelligent-Tiering to move infrequently accessed images to cheaper storage classes automatically.
💡 Tip: In an interview, explicitly call out which NFR each component addresses. "I'm adding CloudFront here to meet our <100ms image load requirement for global users" shows end-to-end thinking.

Case Study: Design a Highly Scalable Image Sharing Platform

🏗️ System Design · Case Studies · 15 min read

Design an Instagram-like platform where users can upload photos, follow other users, and see a personalised home feed. We'll follow the five-step process end-to-end.

Step 1 — Functional Requirements

  • Users can upload images with a caption
  • Users can view images (full-size and thumbnail)
  • Users can follow / unfollow other users
  • Users see a home feed — a ranked list of recent images from people they follow
  • Users can like images

Out of scope: comments, Stories, Reels, DMs, ads, search.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

500M registered users · 10M DAU · 2M image uploads/day · 200M image reads/day (100:1 read/write ratio)

Availability

99.99% uptime (~52 min downtime/year). Images must be readable even during partial outages.

Latency

Feed load < 200ms p99. Image served from CDN < 100ms globally. Upload acknowledgement < 500ms.

Durability & Storage

Zero image loss. 2M × 3MB = 6 TB/day. 3 thumbnail sizes per image ≈ 10 TB/day total. 3.6 PB/year.

Step 3 — System's API & Sequence Diagram

API Design

POST   /v1/images/upload-url            → { uploadUrl, imageId }  # request pre-signed S3 URL
PUT    {uploadUrl}                      → 200 OK                  # client uploads directly to S3
POST   /v1/images/{imageId}/confirm     → { imageId, imageUrl }   # notify backend upload complete
GET    /v1/images/{imageId}             → ImageMetadata           # image detail
POST   /v1/users/{userId}/follow        → 200 OK
DELETE /v1/users/{userId}/follow        → 200 OK
GET    /v1/users/{userId}/feed          → [ImageCard]             # paginated, cursor-based
POST   /v1/images/{imageId}/likes       → { likesCount }

Upload — Sequence of Events

── Phase 1: Get upload URL (fast, <50ms) ──
Client         →  API Gateway       : POST /v1/images/upload-url
API Gateway    →  Image Service     : forward (auth validated)
Image Service  →  S3                : generate pre-signed PUT URL (TTL: 5 min)
Image Service  →  Metadata DB       : INSERT image (status=PENDING)
Image Service  →  Client            : { uploadUrl, imageId }

── Phase 2: Direct upload to S3 (bypasses app servers) ──
Client         →  S3                : PUT {image bytes} to pre-signed URL
S3             →  Client            : 200 OK

── Phase 3: Confirm & trigger async processing ──
Client         →  Image Service     : POST /v1/images/{imageId}/confirm
Image Service  →  Metadata DB       : UPDATE image (status=READY, s3Key, caption)
Image Service  →  Kafka             : publish IMAGE_UPLOADED { imageId, userId, timestamp }
Image Service  →  Client            : { imageId, imageUrl }

── Async: Fan-out to followers' feeds ──
Feed Worker    ←  Kafka             : consume IMAGE_UPLOADED
Feed Worker    →  User Service      : GET followers(userId)     # paginated, may be millions
Feed Worker    →  Redis Cluster     : ZADD feed:{followerId} score=timestamp member=imageId
                                                                # done for each follower batch

── Async: Generate thumbnails ──
Thumb Worker   ←  Kafka             : consume IMAGE_UPLOADED
Thumb Worker   →  S3                : GET original image
Thumb Worker   →  S3                : PUT thumbnails (320px · 640px · 1080px)
Thumb Worker   →  Metadata DB       : UPDATE image (thumbnails=READY)

Upload — Visual Sequence Diagram

① PHASE 1 — GET UPLOAD URL (<50ms) ② PHASE 2 — DIRECT S3 UPLOAD (bypasses app servers) ③ PHASE 3 — CONFIRM & TRIGGER ASYNC ⚡ ASYNC — FAN-OUT TO FOLLOWERS' FEEDS ⚡ ASYNC — GENERATE THUMBNAILS Client API Gateway Image Service S3 Metadata DB Kafka Feed Worker User Service Redis Cluster Thumb Worker POST /images/upload-url forward (auth validated) gen pre-signed URL (TTL: 5min) INSERT image (status=PENDING) { uploadUrl, imageId } PUT {image bytes} — direct upload to S3 200 OK POST /images/{id}/confirm UPDATE image (status=READY) publish IMAGE_UPLOADED { imageId, userId } { imageId, imageUrl } consume IMAGE_UPLOADED GET followers(userId) ZADD feed:{followerId} (per follower) consume IMAGE_UPLOADED GET original image PUT thumbnails (320px · 640px · 1080px) UPDATE image (thumbnails=READY)

View Feed — Sequence of Events

Client      →  API Gateway  : GET /v1/users/{userId}/feed?cursor=&limit=20
API Gateway →  Feed Service : forward
Feed Service →  Redis       : ZREVRANGE feed:{userId} 0 19          # O(log n + 20)
Feed Service →  Metadata DB : batch SELECT WHERE imageId IN (...)   # one query, 20 rows
Feed Service →  Client      : [{ imageId, caption, thumbnailUrl, createdAt, likesCount }]

── Image display (independent of feed API) ──
Client       →  CloudFront CDN : GET /images/{imageId}/640px
CDN edge     →  Client         : serve from cache (cache hit, <20ms)
CDN edge     →  S3             : fetch & cache on miss

Step 4 — Functional Architecture

API Gateway

Routes requests to the correct service. Validates JWT tokens, enforces rate limits (e.g., 50 uploads/hour per user), handles SSL termination.

Image Service

Generates pre-signed S3 URLs, confirms uploads, writes metadata to DB, publishes IMAGE_UPLOADED to Kafka. Completely stateless.

Feed Service

Reads pre-built feeds from Redis sorted sets. Batch-fetches metadata from DB. Returns paginated feed to client. Read-only, stateless.

User Service

Manages user profiles and follower relationships. Key query: getFollowers(userId) — used by Feed Worker on every upload to fan-out to followers.

Object Storage (S3)

Stores original images and all thumbnail variants. Immutable after upload. 11 nines durability (99.999999999%). Multi-AZ replicated.

Metadata DB (PostgreSQL)

Tables: images(imageId, userId, s3Key, caption, status, createdAt, likesCount), follows(followerId, followeeId). Index: (userId, createdAt DESC) for profile grids.

Feed Cache (Redis)

Sorted sets: key=feed:{userId}, score=Unix timestamp, member=imageId. ZREVRANGE returns feed in reverse chronological order. Holds ~1,000 entries per active user.

Kafka + Workers

MESSAGE_UPLOADED event consumed independently by Feed Worker (fan-out) and Thumbnail Worker (image processing). Workers scale horizontally per partition.

Step 5 — Addressing Non-Functional Requirements

High Availability (99.99%)

  • All services deployed across 3 Availability Zones behind a load balancer
  • S3, RDS (Multi-AZ), ElastiCache Redis (Multi-AZ) handle AZ failures automatically
  • Kafka with replication factor 3 — survives one broker failure with no data loss
  • Circuit breakers on all service-to-service calls (fail fast, not cascade)

Low Read Latency (<100ms images, <200ms feed)

  • CloudFront CDN — serves images from 450+ edge locations globally. Cache-Control: max-age=31536000 (images are immutable). Mumbai users get images from Mumbai PoP.
  • Redis feed cache — pre-built feed means feed reads never hit the DB. ZREVRANGE is O(log n + k) → microseconds.
  • PostgreSQL read replicas — Feed Service and Image Service reads go to replicas; only writes hit primary.

High Write Throughput (23 uploads/sec peak)

  • Pre-signed S3 URLs — clients upload directly to S3, bypassing application servers. Image Service only handles metadata and URL generation, not bytes.
  • Image Service is stateless → horizontal autoscaling (target: <70% CPU triggers scale-out)
  • Kafka absorbs write spikes — workers process at their own rate without back-pressure on upload path

Celebrity Problem (Fan-out Bottleneck)

  • A celebrity with 50M followers uploading a photo triggers 50M Redis writes in Feed Worker — too slow
  • Hybrid fan-out: users with > 1M followers use fan-out on read — their posts are fetched at feed-load time and merged with the pre-built feed. Only normal users get push fan-out.

Storage Cost (3.6 PB/year)

  • S3 Intelligent-Tiering — automatically moves images not accessed in 30 days to infrequent-access storage (40-60% cost reduction)
  • Images older than 2 years move to S3 Glacier (archive tier, ~$0.004/GB/month)
── Final Architecture Summary ──
Client (Mobile/Web)
  │
  ▼
CloudFront CDN  ──── S3 (images + thumbnails)
  │
  ▼
Load Balancer (Multi-AZ)
  │
  ├──▶  API Gateway  (auth · rate limit · routing)
  │           │
  │    ┌──────┼──────────┬─────────────┐
  │    ▼      ▼          ▼             ▼
  │  Image  Feed      User          Likes
  │  Svc    Svc       Svc           Svc
  │    │      │          │
  │    │    Redis      PostgreSQL
  │    │    Cluster    (primary + 2 replicas)
  │    │
  │    ▼
  │  Kafka (3 brokers, RF=3)
  │    │
  │    ├──▶  Feed Worker (fan-out to Redis)
  │    └──▶  Thumbnail Worker (S3 processing)
  │
  └──── PostgreSQL (Metadata DB)
💡 Key insight: The pre-signed S3 URL pattern is the single most impactful architectural decision here. It eliminates the app-server bottleneck for large binary uploads and offloads bandwidth cost entirely to S3 — the cheapest path for bytes to flow from client to storage.

Case Study: Design Reddit

🏗️ System Design · Case Studies · 15 min read

Reddit is a social news and discussion platform where users submit posts to topic-based communities (subreddits), vote them up/down, and see a ranked feed. Core challenges: vote aggregation at scale, hot-score feed fan-out, nested comment threads.

Step 1 — Functional Requirements

  • Users create posts (text/link/image) in subreddits
  • Users upvote/downvote posts and comments
  • Home feed shows top/hot/new posts from subscribed subreddits
  • Users comment on posts; comments are nested (threaded)
  • Posts ranked by hot algorithm: score × time-decay
  • Moderators can remove posts/comments

Out of scope: Ads, Reddit Premium, Chat DMs, live streaming.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

2B users · 50M posts/day · 500M votes/day · 2B feed loads/day (23K/sec, peak 80K/sec).

Availability

99.99% uptime. Feed reads must survive any single-node failure.

Latency

Feed load < 200ms p99. Vote record < 50ms. Post create < 500ms.

Storage

Post DB ~500B/post × 50M/day = 25GB/day. Vote counters in Redis (integer, ~20B/entry). Comment tree in PostgreSQL.

Step 3 — System's API & Sequence Diagram

API Design

POST /v1/posts                           → { postId, subredditId, title, score:0 }
GET  /v1/feed?sub=&sort=hot&after=       → { posts:[], nextCursor }
POST /v1/votes                           → { postId, direction:+1|-1, newScore }
POST /v1/posts/{postId}/comments         → { commentId, parentCommentId?, body }
GET  /v1/posts/{postId}/comments         → { comments:[] }

Post Submission & Feed Fan-Out — Sequence of Events

Client        →  API Gateway   : POST /v1/posts { subredditId, title, content }
API Gateway   →  Post Service  : forward (auth validated, rate-limited)
Post Service  →  Post DB       : INSERT post (id, subredditId, userId, score=0, createdAt)
Post Service  →  Kafka         : publish POST_CREATED { postId, subredditId }
Post Service  →  Client        : { postId, shortUrl }
Feed Worker   ←  Kafka         : consume POST_CREATED
Feed Worker   →  Sub Service   : GET subscriber list (subredditId, paginated)
Feed Worker   →  Redis         : ZADD feed:{userId} hotScore postId  # per subscriber

Vote & Score Update — Sequence of Events

Client        →  API Gateway   : POST /v1/votes { postId, direction:+1 }
Vote Service  →  Redis         : INCR vote_up:{postId}   # atomic counter
Vote Service  →  Kafka         : publish VOTE_EVENT { postId, direction }
Vote Service  →  Client        : { newScore }
Score Worker  ←  Kafka         : consume VOTE_EVENT
Score Worker  :  hotScore = (ups-downs) / (age_hours+2)^1.8
Score Worker  →  Post DB       : UPDATE posts SET score=hotScore
Score Worker  →  Redis         : ZADD feed:{userId} hotScore postId  # active users

Visual Sequence Diagram

① POST SUBMISSION ② VOTE PROCESSING ⚡ ASYNC FAN-OUT Client API Gateway PostService PostDB Redis Kafka FeedWorker SubDB POST /v1/posts forward (auth) INSERT post (score=0) publish POST_CREATED { postId, shortUrl } POST /v1/votes → INCR vote_up:{postId} VOTE_EVENT { newScore } consume POST_CREATED GET subscribers ZADD feed:{userId} hotScore (per user)

Step 4 — Functional Architecture

API Gateway

Auth, rate-limit (5 posts/min, 100 votes/min per user), routing.

Post Service

Creates posts, generates shortUrl, publishes to Kafka.

Vote Service

Atomic Redis INCR/DECR, idempotent (prevents double-vote via Redis SET), publishes to Kafka.

Feed Service

Redis sorted sets feed:{userId} scored by hotScore; hybrid push (small subs) / pull-merge (mega subs like r/AskReddit with 40M subscribers).

Comment Service

Adjacency list in PostgreSQL; materialised paths for nested display; paginated.

PostgreSQL

Posts, comments, users, subreddits; read replicas for feed queries.

Redis

Vote counters, hot feeds (ZADD), session tokens; cluster mode.

Kafka + Workers

Async fan-out; vote → score update; avoids blocking the write path.

Step 5 — Addressing Non-Functional Requirements

Availability

  • Multi-AZ PostgreSQL + read replicas; Redis cluster; stateless services.

Vote Latency (<50ms)

  • Redis INCR is O(1) sub-ms; DB write deferred to Kafka consumer.

Celebrity Subreddit Fan-out

  • r/AskReddit has 40M subscribers — pushing to all feeds on every post = 40M Redis writes. Fix: hybrid fan-out — push to feeds of normal subs, pull-merge from mega-subreddits on feed load.
── Final Architecture Summary ──
Client
  │
  ▼
CDN (static assets, media via S3+CloudFront)
  │
Load Balancer → API Gateway (auth · rate-limit)
                      │
          ┌───────────┼──────────────┐
          ▼           ▼              ▼
      Post Svc    Vote Svc      Comment Svc
          │           │              │
       PostDB      Redis          PostgreSQL
      (pgSQL)    (counters,       (threads)
                  hot feeds)
          │           │
          └───── Kafka ──────┐
                        ┌────┴──────────┐
                        ▼               ▼
                   Feed Worker     Score Worker
                        │               │
                  Redis Feeds       PostDB
                  (ZADD hotScore)   (UPDATE score)
💡 Key insight: Reddit's design challenge is the hot-score feed fan-out. 500M votes/day means feed caches go stale constantly. Redis sorted sets with ZADD atomically update a post's position in every subscriber's feed — but this only works for normal-sized subreddits. Mega-subreddits switch to pull-on-read with a merge, trading freshness for write scalability.

Case Study: Design WhatsApp

🏗️ System Design · Case Studies · 15 min read

WhatsApp delivers 100B messages/day to 2B users with real-time delivery, offline queuing, E2E encryption, and delivery receipts (sent ✓, delivered ✓✓, read ✓✓ blue). The core challenge is maintaining 500M persistent WebSocket connections and routing messages between them in under 100ms.

Step 1 — Functional Requirements

  • 1-on-1 and group chat (up to 1024 members)
  • Real-time delivery when recipient is online; stored for offline delivery
  • Delivery receipts: Sent ✓, Delivered ✓✓, Read ✓✓
  • Media sharing (images, video, voice notes, documents)
  • Online/offline presence and last-seen timestamps
  • End-to-end encrypted (Signal Protocol — only sender/recipient can read)

Out of scope: WhatsApp Pay, Business API, Status stories, voice/video calls.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

2B users · 500M DAU · 100B messages/day (~1.16M msg/sec) · 200M group chats.

Availability

99.99%. Message loss is unacceptable — stored until ACKed.

Latency

Online delivery < 100ms. Offline push notification within 5 seconds of send.

Storage

Cassandra for messages (time-series, write-heavy); media on S3 (200M files/day avg 1MB = 200TB/day).

Step 3 — System's API & Sequence Diagram

API Design (WebSocket for real-time; REST for setup)

POST /v1/sessions                              → { sessionToken }
WebSocket: wss://msg.7ds.in/v1/connect         # bidirectional channel
POST /v1/chats/{chatId}/messages               → { messageId, status:SENT }
GET  /v1/chats/{chatId}/messages?after=        → { messages:[] }
GET  /v1/users/{id}/presence                   → { online:bool, lastSeen }

Real-Time Message Delivery — Sequence of Events

Sender App    →  WS Server     : SEND { chatId, recipientId, ciphertext, msgId }
WS Server     →  Msg Service   : forward
Msg Service   →  Cassandra     : INSERT (chatId, msgId, ciphertext, sentAt, status=SENT)
Msg Service   →  Redis         : GET presence:{recipientId}  → ONLINE, server_id=ws-07
Msg Service   →  WS Server ws-07: ROUTE { msgId, ciphertext }
WS Server     →  Recipient App : PUSH message
Recipient App →  WS Server     : ACK { msgId, status:DELIVERED }
WS Server     →  Msg Service   : update delivery receipt
Msg Service   →  Cassandra     : UPDATE status=DELIVERED WHERE msgId=?
Msg Service   →  Sender App    : NOTIFY { msgId, status:DELIVERED }   # ✓✓ appears

Offline Delivery & Reconnect — Sequence of Events

── Recipient offline ──
Msg Service   →  Redis         : GET presence:{recipientId}  → OFFLINE
Msg Service   →  Cassandra     : INSERT offline_queue (recipientId, msgId, sentAt)
Msg Service   →  Push Service  : NOTIFY { recipientId, preview:"You have a message" }
Push Service  →  FCM/APNs      : deliver push notification
── Recipient comes online ──
Recipient App →  WS Server     : CONNECT + AUTH
WS Server     →  Msg Service   : user online { recipientId, serverId }
Msg Service   →  Redis         : SET presence:{recipientId} ONLINE serverId
Msg Service   →  Cassandra     : SELECT FROM offline_queue WHERE recipientId=? ORDER BY sentAt
Msg Service   →  WS Server     : BATCH_DELIVER { messages:[] }
Recipient App →  WS Server     : BATCH_ACK
Msg Service   →  Cassandra     : DELETE offline_queue WHERE recipientId=? AND msgIds IN (?)

Visual Sequence Diagram

① ONLINE DELIVERY ⚡ OFFLINE QUEUE + PUSH ② RECONNECT DRAIN SenderApp WSServer MsgService Cassandra RedisPresence FCMAPNs RecipientApp SEND { ciphertext, msgId } forward INSERT status=SENT GET presence → ONLINE ws-07 ROUTE PUSH message ACK status=DELIVERED → ✓✓ GET presence → OFFLINE INSERT offline_queue NOTIFY → FCM/APNs push CONNECT + AUTH SELECT offline_queue BATCH_DELIVER → BATCH_ACK → DELETE queue

Step 4 — Functional Architecture

WebSocket Servers

Async I/O, 1 server handles 1M concurrent connections; user assigned to server via consistent hashing; Redis stores ws:{userId}→serverId.

Message Service

Receives from WS, persists to Cassandra, routes to recipient's server; stateless.

Cassandra

messages(chatId, msgId, ciphertext, status, sentAt) PRIMARY KEY (chatId, msgId); time-series optimised; handles 1M writes/sec; multi-DC replication.

Redis Presence

presence:{userId} → {online:true, serverId:ws-07}; TTL-based (online status expires if no heartbeat); O(1) lookup.

Offline Queue (Cassandra)

offline_queue(recipientId, msgId, sentAt); drained on reconnect; deleted after ACK.

Push Service

Integrates FCM (Android) + APNs (iOS); fires when recipient is offline; payload is a preview (not ciphertext).

E2E Encryption

Signal Protocol (Double Ratchet); keys exchanged device-to-device; server stores only ciphertext; even WhatsApp cannot read messages.

Step 5 — Addressing Non-Functional Requirements

Availability

  • WS servers are stateless (user can reconnect to any server); Cassandra quorum writes ensure durability before routing.

Delivery (<100ms)

  • Redis presence O(1); WS push avoids HTTP handshake overhead; inter-server routing via internal gRPC.

Group Fan-out (1024 members)

  • Publish once to group Kafka topic; Group Fan-out Service reads and pushes to each online member's WS server; offline members get individual push notifications.
── Final Architecture Summary ──
[Sender]  ←─ E2E encrypted (Signal Protocol) ─→  [Recipient]
    │                                                    │
WebSocket Server Cluster (consistent hash routing)
    │
Message Service (stateless)
    │
    ├──▶ Cassandra (messages + offline_queue — durable, write-heavy)
    ├──▶ Redis (presence map: userId → serverId)
    └──▶ Push Notification Service
               ├── FCM  (Android)
               └── APNs (iOS)

Media: client encrypts → S3 pre-signed URL upload → S3 key sent in message
💡 Key insight: WhatsApp's server is a dumb routing engine — it never sees message content (E2E encryption). The real scaling challenge is 500M persistent WebSocket connections. Each user is pinned to one WS server via consistent hashing, and that server ID is stored in Redis. Any service can route a message to any user in one Redis lookup + one internal server-to-server push.

Case Study: Design Uber / Lyft

🏗️ System Design · Case Studies · 15 min read

Uber matches riders with nearby drivers in real-time. The defining challenge is continuously ingesting 333K driver location updates per second, querying geospatial proximity in milliseconds, and completing a match within 5 seconds of a rider's request.

Step 1 — Functional Requirements

  • Riders request a trip (pickup + destination); system finds nearby drivers
  • Drivers accept/decline trip requests
  • Rider sees real-time driver location on a map during the trip
  • Fare estimated before trip, calculated after (distance + duration + surge)
  • Riders and drivers rate each other after trip completion
  • Surge pricing based on local demand/supply ratio

Out of scope: Uber Eats, Uber Freight, scheduled rides, autonomous vehicles.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

130M monthly users · 5M drivers active daily · 15M trips/day (~175/sec, peak 500/sec) · 333K location updates/sec.

Availability

99.99%. A failed match = lost revenue. Location updates must not be lost.

Latency

Match found < 5 seconds. Location update applied < 1 second. Fare estimate < 200ms.

Storage

Location is ephemeral (Redis GeoSet, latest only). Trips in PostgreSQL (~500B/trip × 15M/day = 7.5GB/day).

Step 3 — System's API & Sequence Diagram

API Design

POST /v1/trips                              → { tripId, estimatedFare, eta }
GET  /v1/drivers/nearby?lat=&lng=&radius=2  → { drivers:[{id,location,eta}] }
PUT  /v1/drivers/{id}/location              → 200 OK
PUT  /v1/trips/{id}/accept                  → { tripId, driverETA }
GET  /v1/trips/{id}/track                   → { driverLocation, status }

Trip Request & Driver Match — Sequence of Events

Rider App     →  API Gateway   : POST /v1/trips { pickupLat, pickupLng, destLat, destLng }
API Gateway   →  Trip Service  : forward (auth validated)
Trip Service  →  Pricing Svc   : GET surge { lat, lng }
Pricing Svc   →  Redis         : GET surge:{geohash}  → 1.3x
Trip Service  →  Location Svc  : FIND_NEARBY { lat, lng, radius:2km }
Location Svc  →  Redis         : GEORADIUS drivers:available lng lat 2km ASC COUNT 10
Redis         →  Location Svc  : [driverId1, driverId2, ...]
Trip Service  →  Notify Svc    : SEND_REQUEST to driverId1 { tripId, pickupLocation }
Trip Service  →  Trip DB       : INSERT trip (tripId, riderId, status=SEARCHING, fare=₹144)
Trip Service  →  Rider App     : { tripId, estimatedFare, status:SEARCHING }

Driver Accept & Location Streaming — Sequence of Events

Driver App    →  API Gateway   : PUT /v1/trips/{tripId}/accept
Trip Service  →  Trip DB       : UPDATE SET driverId=?, status=DRIVER_ASSIGNED
Trip Service  →  Rider App     : NOTIFY { driverName, eta } (WebSocket/Push)
── Real-time location updates ──
Driver App    →  Location Svc  : PUT /location { lat, lng }  (every 4 sec)
Location Svc  →  Redis         : GEOADD drivers:active driverId lng lat
Location Svc  →  Kafka         : publish LOC_UPDATE { driverId, lat, lng, tripId }
Loc Worker    ←  Kafka         : consume LOC_UPDATE
Loc Worker    →  Rider App     : PUSH { driverLocation }  (WebSocket)

Visual Sequence Diagram

① TRIP REQUEST ② DRIVER ACCEPT ⚡ LOCATION STREAMING RiderApp APIGateway TripService PricingSvc LocationSvc Redis DriverApp Kafka POST /v1/trips forward GET surge → 1.3x FIND_NEARBY radius:2km GEORADIUS { tripId, fare, SEARCHING } PUT /trips/{id}/accept NOTIFY { driverName, eta } UPDATE status=DRIVER_ASSIGNED PUT /location (every 4s) GEOADD publish LOC_UPDATE Loc Worker → PUSH driverLocation to Rider map (WebSocket)

Step 4 — Functional Architecture

Location Service

Ingests 333K location updates/sec; writes GEOADD drivers:active; publishes to Kafka for rider map streaming.

Redis GeoSet

drivers:available as sorted set with geohash scores; GEORADIUS finds nearest drivers in O(N+log M) sub-millisecond.

Trip Service

Manages trip state machine (SEARCHING→DRIVER_ASSIGNED→IN_PROGRESS→COMPLETED); PostgreSQL for persistence.

Matching Engine

Sequential offer to nearest driver; 10-second timeout per driver; moves to next if declined; parallel offers for high-demand zones.

Pricing Service

Surge multiplier = demand/supply ratio per geohash cell; recalculated every 30s; cached in Redis per cell.

Notification Service

WebSocket for real-time (online), FCM/APNs push for background; driver gets trip offer with 10-second countdown.

PostgreSQL

Trip records, statuses, fare, GPS breadcrumbs, ratings; read replicas for analytics.

ETA & Maps

Google Maps / proprietary routing engine; ETA per driver candidate; directions for in-trip navigation.

Step 5 — Addressing Non-Functional Requirements

Availability

  • Location Service sharded by city; Redis cluster multi-AZ; Trip Service stateless behind LB.

Match (<5 seconds)

  • Redis GEORADIUS sub-ms; driver notification via WebSocket (already connected); sequential with 10s timeout leaves 4+ candidates before expiry.

333K Location Writes/sec

  • Redis GEOADD is O(log N); Location Service sharded by geohash (each city shard handles its drivers); Kafka decouples streaming from storage.
── Final Architecture Summary ──
Driver App ──location (4s)──▶ Location Service ──▶ Redis GeoSet
                                                         │
Rider App ──trip request──▶ API Gateway ──▶ Trip Service─┘(GEORADIUS)
                                                │
                               ┌────────────────┤
                               ▼                ▼
                         Pricing Svc        Matching Engine
                         Redis(surge)       → Driver Notification
                                              (FCM / WebSocket)
PostgreSQL: trip lifecycle, payments, ratings
Kafka: location stream → Rider WebSocket (real-time driver pin)
💡 Key insight: Uber's architecture is dominated by one number: 333K location writes/second from 5M active drivers. Redis Geo commands (GEOADD, GEORADIUS) make this tractable — the entire active driver fleet for a city fits in memory, and finding the nearest 10 drivers is a sub-millisecond sorted-set operation. Everything else (matching, pricing, routing) is built on top of that fast geospatial index.

Case Study: Design a Hotel Booking System

🏗️ System Design · Case Studies · 15 min read

A hotel booking system like Booking.com allows users to search available rooms, view pricing, and complete a reservation. The core tension is between read-heavy browsing (1000:1 read/write) which benefits from caching, and booking which requires strong consistency to prevent double-bookings.

Step 1 — Functional Requirements

  • Search hotels by city, dates, guests, price range, and amenities
  • View hotel details, room types, photos, and reviews
  • Book a room for specific dates — guaranteed no double-booking
  • Cancel a booking (per hotel's cancellation policy)
  • Hotel owners manage room inventory and set pricing
  • Email/SMS confirmation after successful booking

Out of scope: Dynamic pricing ML, hotel mobile app, reviews system, payment processing.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

500K hotels · 5M rooms globally · 100M searches/day (1,160/sec) · 10M bookings/day (116/sec).

Availability

99.99%. Read path may degrade gracefully; booking path must never lose a confirmed reservation.

Latency

Search < 500ms p99. Booking confirmation < 3 seconds. Cancellation < 1 second.

Storage

Hotel metadata ~10KB × 500K = 5GB. Bookings ~1KB × 10M/day = 10GB/day. Photos on S3 + CDN.

Step 3 — System's API & Sequence Diagram

API Design

GET  /v1/hotels/search?city=&checkin=&checkout=&guests=  → { hotels:[], total }
GET  /v1/hotels/{id}/rooms?checkin=&checkout=            → { rooms:[], available }
POST /v1/bookings                                         → { bookingId, confirmationCode, total }
GET  /v1/bookings/{id}                                   → { booking details }
DELETE /v1/bookings/{id}                                 → { refundAmount }

Hotel Search with Availability — Sequence of Events

Client        →  API Gateway   : GET /hotels/search?city=Mumbai&checkin=2026-07-01&guests=2
API Gateway   →  Search Svc    : forward
Search Svc    →  Redis         : GET search:{hash(params)}  → MISS
Search Svc    →  Elasticsearch : QUERY hotels WHERE city=Mumbai AND available=true
Elasticsearch →  Search Svc    : { hotels:[50 results] }
Search Svc    →  Inventory Svc : CHECK_AVAIL { hotelIds:[50], checkin, checkout }
Inventory Svc →  PostgreSQL    : SELECT rooms WHERE hotel_id IN(...) AND dates NOT OVERLAP
PostgreSQL    →  Inventory Svc : { available rooms per hotel }
Search Svc    →  Redis         : SETEX search:{hash} 60 {results}
Search Svc    →  Client        : { hotels with available rooms and prices }

Room Booking (double-booking prevention) — Sequence of Events

Client        →  API Gateway   : POST /v1/bookings { roomId, checkin, checkout, paymentToken }
API Gateway   →  Booking Svc   : forward (auth required)
Booking Svc   →  Inventory Svc : RESERVE { roomId, checkin, checkout }
Inventory Svc →  PostgreSQL    : BEGIN TRANSACTION
                                  SELECT id FROM room_bookings
                                  WHERE room_id=? AND status='CONFIRMED'
                                  AND NOT (checkout <= ? OR checkin >= ?)
                                  FOR UPDATE  # row-level lock
                                  [conflict found → ROLLBACK → 409]
                                  INSERT INTO room_bookings (roomId, userId, checkin, status=PENDING)
                                  COMMIT
Inventory Svc →  Booking Svc   : { bookingId, reserved:true }
Booking Svc   →  Payment Svc   : CHARGE { paymentToken, amount }
Payment Svc   →  Booking Svc   : { txnId, status:SUCCESS }
Booking Svc   →  PostgreSQL    : UPDATE SET status=CONFIRMED, txnId=?
Booking Svc   →  Kafka         : publish BOOKING_CONFIRMED { bookingId, userId }
Booking Svc   →  Client        : { bookingId, confirmationCode }

Visual Sequence Diagram

① HOTEL SEARCH ② BOOKING (SELECT FOR UPDATE) ⚡ ASYNC CONFIRMATION Client APIGateway Search/BookSvc InventorySvc PostgreSQL Elasticsearch Redis Kafka GET /hotels/search forward GET search cache → MISS QUERY hotels (50 results) CHECK_AVAIL { hotels + availability } POST /v1/bookings RESERVE SELECT FOR UPDATE → INSERT CHARGE → SUCCESS UPDATE status=CONFIRMED publish BOOKING_CONFIRMED Notification Service → confirmation Email + SMS

Step 4 — Functional Architecture

Search Service

Elasticsearch for geo + text + filter queries; merges availability from Inventory Service; results cached in Redis 60s TTL.

Inventory Service

Single source of truth for room availability; SELECT FOR UPDATE prevents overbooking; manages room_bookings table.

Booking Service

Orchestrates reserve→charge→confirm; idempotent (booking token prevents duplicate submissions); state machine (PENDING→CONFIRMED→CANCELLED).

PostgreSQL

ACID transactions for bookings; room_bookings(roomId, checkin, checkout, status) with exclusion constraint; read replicas for hotel detail pages.

Elasticsearch

Denormalized hotel index (name, location, amenities, avg_price, rating); re-indexed nightly from PostgreSQL; real-time partial updates on booking changes.

Redis Cache

Search result cache (60s TTL); session tokens; rate limiting; popular hotel page cache.

Payment Service

Integrates Stripe/Razorpay; handles charge and refund; called synchronously (user waits for payment result).

Notification Service (Kafka consumer)

Sends booking confirmation email + SMS; async so it doesn't delay the booking response.

Step 5 — Addressing Non-Functional Requirements

No Double-Booking

  • SELECT FOR UPDATE acquires row-level lock within a transaction; only one concurrent booking for a room+dates succeeds; the other gets 409.

Search (<500ms)

  • 90% of searches hit Redis cache (60s TTL); Elasticsearch for cache miss; availability check parallelised for top-10 results.

1000:1 Read/Write

  • Elasticsearch + read replicas absorb reads; booking path is the only write-heavy path; PostgreSQL primary handles <116 writes/sec comfortably.
── Final Architecture Summary ──
Client
  │
CDN (hotel photos — S3 + CloudFront)
  │
Load Balancer
  │
  ├──▶ Search Service ──▶ Elasticsearch (geo+text+filter)
  │         └──▶ Redis (search cache 60s TTL)
  │
  ├──▶ Booking Service ──▶ Inventory Service ──▶ PostgreSQL
  │         │                                  (SELECT FOR UPDATE)
  │         └──▶ Payment Service (Stripe/Razorpay)
  │                   │
  │               Kafka → Notification Service (Email + SMS)
  │
  └──▶ Hotel Mgmt API → PostgreSQL (hotel config, pricing)
💡 Key insight: Hotel booking has a fundamental tension between search (cache-friendly, eventual consistency OK — showing a room as available when it just got booked is acceptable) and booking (strongly consistent, must never overbook). These are solved by separate systems: Elasticsearch + Redis for search, PostgreSQL SELECT FOR UPDATE for booking. Never use the cache to decide if a room is available — always go to the DB with a lock.

Case Study: Design a Credit Card Processing System

🏗️ System Design · Case Studies · 16 min read

A credit card processing system handles authorization (does the card have funds?), capture (finalise the charge), and settlement (end-of-day batch to merchants). It operates at 3,000+ TPS with sub-200ms authorization, 99.999% availability, and zero data loss — making it one of the most demanding systems to design.

Step 1 — Functional Requirements

  • Merchant submits card details; system approves or declines in real-time (<200ms)
  • Approved authorization can be captured (charge finalised) within 7 days
  • Merchants can issue full or partial refunds on captured transactions
  • Void an authorization before capture
  • Fraud detection flags suspicious transactions in real-time
  • End-of-day settlement batch moves funds from card network to merchant accounts

Out of scope: Card issuing, rewards/points, chargeback dispute resolution, forex conversion.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

3,000 TPS sustained · peak 10,000 TPS · 1B transactions/day.

Availability

99.999% (five nines). Every minute of downtime = 180K failed transactions.

Latency

Authorization < 200ms p99 (card network round-trip ~80ms; fraud scoring < 20ms).

Storage

Transaction log is append-only — never updated. 1KB × 1B/day = 1TB/day. 7-year retention = ~2.5PB.

Step 3 — System's API & Sequence Diagram

API Design

POST /v1/transactions/authorize    → { authCode, transactionId, status:APPROVED|DECLINED }
POST /v1/transactions/{id}/capture → { captureId, amount, status:CAPTURED }
POST /v1/transactions/{id}/void    → { status:VOIDED }
POST /v1/transactions/{id}/refund  → { refundId, amount, status:REFUNDED }
GET  /v1/transactions/{id}         → { full transaction history }

Card Authorization — Sequence of Events

Merchant      →  Gateway       : POST /authorize { cardNumber, expiry, CVV, amount }
Gateway       :  tokenize card → HSM encrypts raw PAN → return token
Gateway       →  Fraud Svc     : SCORE { token, amount, merchantId, IP, deviceId }
Fraud Svc     →  ML Model      : score risk (velocity, geo, merchant category)
Fraud Svc     →  Gateway       : { riskScore:23, action:PROCEED }
Gateway       →  Card Network  : AUTH_REQUEST { token, amount, merchantId }  (ISO 8583)
Card Network  →  Issuing Bank  : verify funds, check credit limit
Issuing Bank  →  Card Network  : APPROVED { authCode:XK4921 }
Card Network  →  Gateway       : { authCode, approved:true }
Gateway       →  Txn DB        : INSERT txn (id, token, amount, authCode, status=AUTHORIZED)
Gateway       →  Merchant      : { transactionId, authCode, status:APPROVED }  # within 200ms

End-of-Day Settlement — Sequence of Events

── Settlement batch runs at 23:00 ──
Settlement Svc →  Txn DB       : SELECT WHERE status=CAPTURED AND date=today()
Settlement Svc →  Card Network : BATCH_SETTLEMENT { transactions:[] }
Card Network   →  Settlement   : { batchId, totalAmount, networkFees }
Card Network   :  transfer funds from issuing banks to acquirer
Settlement Svc →  Txn DB       : UPDATE SET status=SETTLED, settledAt=NOW()
Settlement Svc →  Merchant Acct: CREDIT { merchantId, amount - fees }
Settlement Svc →  Audit Log    : WRITE settlement record (immutable append)

Visual Sequence Diagram

① AUTHORIZATION (<200ms) ② CAPTURE ⚡ NIGHTLY SETTLEMENT BATCH Merchant Gateway FraudSvc CardNetwork IssuingBank TxnDB SettleSvc AuditLog POST /authorize SCORE (tokenized) riskScore:23 PROCEED AUTH_REQUEST (ISO 8583) verify funds → APPROVED INSERT txn status=AUTHORIZED authCode POST /capture UPDATE status=CAPTURED (ready for settlement) SELECT CAPTURED BATCH_SETTLEMENT → Card Network UPDATE status=SETTLED CREDIT merchant account (amount - fees) WRITE audit

Step 4 — Functional Architecture

Payment Gateway

SSL termination, card tokenization, request routing; raw card data never leaves the gateway (PCI DSS scope boundary).

HSM (Hardware Security Module)

Encrypts/decrypts card data; stores keys in tamper-proof hardware; mandatory for PCI DSS Level 1.

Fraud Detection Service

ML model: device fingerprint, velocity checks (5 txns/min threshold), geo mismatch, merchant category risk; <20ms target; results cached in Redis.

Card Network Integration

Visa/Mastercard ISO 8583 message format; TCP connection pool maintained; timeout handling (network unavailable → decline with code); dual-connection for failover.

Transaction DB (PostgreSQL)

Append-only event log: AUTHORIZED → CAPTURED → SETTLED; SELECT FOR UPDATE for capture/void race condition; WAL-based replication.

Settlement Service

Nightly batch; reads CAPTURED transactions; sends batch file to card network; idempotent (safe to re-run with same batch ID); reconciles totals.

Audit Log (Kafka + S3)

Immutable append of every transaction event; used for dispute resolution, regulatory audit, reconciliation.

Idempotency Layer

Each auth request carries a unique idempotency key; duplicate requests (client retry) return cached response without re-running; prevents double-charges.

Step 5 — Addressing Non-Functional Requirements

99.999% (five nines)

  • Active-active multi-region; card network connections from both regions; circuit breaker on card network call; no single point of failure on auth path.

Authorization (<200ms)

  • Fraud scoring <20ms (Redis velocity counters + lightweight ML); card network ~80ms round-trip; all DB writes async except the final INSERT before response.

Zero Data Loss

  • Transaction written to PostgreSQL with WAL-level durability before response sent; Kafka for event stream; nightly reconciliation against card network totals.
── Final Architecture Summary ──
Merchant ──HTTPS──▶ Payment Gateway  (PCI DSS boundary)
                          │
                    ┌─────┴──────────────┐
                    ▼                    ▼
             Fraud Detection        HSM (encryption)
             ML scoring <20ms       PAN tokenization
                    │
                    ▼
             Card Network (Visa/MC — ISO 8583)
                    │
             Issuing Bank (approve/decline)
                    │
             Transaction DB (PostgreSQL, append-only)
             Audit Log (Kafka + S3, immutable)
                    │
             Settlement Service (nightly batch)
                    │
             Merchant Account (ACH/NEFT credit)
💡 Key insight: Credit card processing has three separate consistency requirements that cannot be compromised simultaneously: the authorization must be fast (<200ms — card network round-trip dominates); the transaction record must be durable (written to DB with fsync before the approval response is sent — financial data cannot be lost even if the server crashes after the response); and settlement must be exactly-once (idempotent batch that always produces the same total). These three constraints drive every architectural decision.

Case Study: Design a User Auth System

🏗️ System Design · Case Studies · 15 min read

A user authentication system handles registration, login, MFA, session management, and password reset. It must be secure, fast, and exceptionally available — when auth is down, every other service is inaccessible. The key design insight is that JWT validation must happen locally (without calling the auth service), removing it as a per-request bottleneck.

Step 1 — Functional Requirements

  • Register with email + password (bcrypt hashed); verify email address
  • Login returns a short-lived access token (JWT, 15 min) and refresh token (30 days)
  • MFA via TOTP (Google Authenticator / Authy)
  • Access token validated by downstream services locally — no auth service call per request
  • Logout invalidates refresh token; logout-all-devices invalidates all sessions
  • Password reset via time-limited email link

Out of scope: RBAC permissions, enterprise SSO, biometric authentication, OAuth2 provider.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

500M users · 100M DAU · 50M logins/day (~580/sec, peak 2,000/sec).

Availability

99.999%. Auth down = no user can access any service. Hardest availability requirement in the system.

Latency

Login < 200ms. JWT validation < 1ms (local, cryptographic). Token refresh < 100ms.

Storage

User table ~500B × 500M = 250GB. Refresh tokens ~200B × 2B active tokens = 400GB.

Step 3 — System's API & Sequence Diagram

API Design

POST /v1/auth/register           → { userId, verificationSent:true }
POST /v1/auth/login              → { accessToken(JWT), refreshToken, expiresIn:900 }
POST /v1/auth/refresh            → { accessToken, expiresIn:900 }
POST /v1/auth/logout             → 204 No Content
POST /v1/auth/mfa/setup          → { otpSecret, qrCodeUrl }
POST /v1/auth/mfa/verify         → { verified:true }
POST /v1/auth/password/reset     → { resetLinkSent:true }

Login with MFA — Sequence of Events

Client        →  API Gateway   : POST /v1/auth/login { email, password }
API Gateway   →  Auth Service  : forward (rate-limit: 5 attempts/15min per IP, Redis counter)
Auth Service  →  User DB       : SELECT (passwordHash, mfaEnabled, locked) WHERE email=?
User DB       →  Auth Service  : { passwordHash, mfaEnabled:true, locked:false }
Auth Service  :  bcrypt.verify(password, passwordHash)  → matches
Auth Service  →  Redis         : SET mfa:{mfaToken} { userId } EX 300   (5-min pending MFA)
Auth Service  →  Client        : { mfaRequired:true, mfaToken }
── MFA step ──
Client        →  Auth Service  : POST /v1/auth/mfa/verify { mfaToken, totpCode }
Auth Service  →  Redis         : GET mfa:{mfaToken}  → { userId }
Auth Service  :  TOTP.verify(totpCode, userSecret)  → valid
Auth Service  →  JWT Svc       : SIGN { sub:userId, email, roles } RS256 → accessToken
Auth Service  →  Refresh DB    : INSERT (tokenHash, userId, deviceId, expiresAt=30d)
Auth Service  →  Redis         : DEL mfa:{mfaToken}
Auth Service  →  Client        : { accessToken, refreshToken, expiresIn:900 }

Token Refresh and Revocation — Sequence of Events

── Token expired — client refreshes ──
Client        →  Auth Service  : POST /v1/auth/refresh { refreshToken }
Auth Service  →  Redis         : GET revoked:{hash(refreshToken)}  → nil (not revoked)
Auth Service  →  Refresh DB    : SELECT WHERE tokenHash=? AND expiresAt > NOW()
Refresh DB    →  Auth Service  : { userId, valid:true }
Auth Service  →  JWT Svc       : SIGN new accessToken (15 min)
Auth Service  →  Client        : { accessToken, expiresIn:900 }
── User logs out ──
Client        →  Auth Service  : POST /v1/auth/logout { refreshToken }
Auth Service  →  Refresh DB    : DELETE WHERE tokenHash=?
Auth Service  →  Redis         : SETEX revoked:{hash(refreshToken)} 2592000 "1"  (TTL 30d)
Auth Service  →  Client        : 204 No Content

Visual Sequence Diagram

① LOGIN + MFA ② TOKEN REFRESH ⚡ PASSWORD RESET Client API Gateway AuthService UserDB Redis RefreshDB JWTSvc POST /login forward (rate-limit 5/15min) SELECT passwordHash → bcrypt verify SET mfa:{token} EX 300 POST /mfa/verify (TOTP valid) SIGN JWT RS256 + INSERT refresh token { accessToken, refreshToken } POST /refresh GET revoked → nil SELECT valid → SIGN new accessToken POST /password/reset (rate-limit) HMAC-signed link (15min TTL) → email bcrypt update password

Step 4 — Functional Architecture

Auth Service

Stateless; handles login, registration, token operations; N replicas behind LB; all state in DB/Redis.

User DB (PostgreSQL)

users(id, email, passwordHash, mfaSecret, verified, locked, createdAt); unique index on email; read replica for login queries; primary for writes.

JWT Access Token

RS256 asymmetric signing: Auth Service signs with private key; every other service verifies with public key locally — zero network calls per request; payload: {sub, email, roles, exp}; 15-min TTL.

Refresh Token Store (PostgreSQL)

refresh_tokens(tokenHash, userId, deviceId, issuedAt, expiresAt); only the SHA-256 hash stored (not the token itself); revocable per device.

Redis

MFA pending state (5-min TTL); refresh token revocation blacklist (TTL = token lifetime); login rate-limit counters (login_attempts:{ip} INCR + EXPIRE).

bcrypt / Argon2id

Password hashing with cost factor 12+; slow by design (prevents brute-force); never store plaintext or reversible hash.

Email Service

Time-limited reset links (HMAC-SHA256 signed, 15-min TTL); Brevo/SendGrid; async via Kafka so email delay doesn't affect login path.

Step 5 — Addressing Non-Functional Requirements

99.999% Availability

  • Auth Service stateless + N replicas; User DB primary+replicas in 3 AZs; JWT validation fully local — other services keep working even if Auth Service goes down (until tokens expire).

Local JWT Validation (<1ms)

  • Services receive Auth Service's RSA public key at startup; verify JWT signature locally (O(1) asymmetric crypto); only calls Auth Service on refresh — removes auth as a per-request network bottleneck.

Brute-force Protection

  • Redis rate limiter login_attempts:{ip} with 5/15min threshold; CAPTCHA after 3 failures; IP-level block after 50 in 1 hour; account lock after 10 consecutive failures for the same email.
── Final Architecture Summary ──
Client
  │
API Gateway ──▶ Auth Service (stateless, N replicas)
                      │
         ┌────────────┼──────────────────┐
         ▼            ▼                  ▼
    User DB         Redis            Refresh
  (PostgreSQL)   (MFA state,        Token DB
  (passwords,     revocation,       (PostgreSQL)
   MFA secrets)   rate limits)
                      │
               JWT Service (RS256 sign)
                      │
           accessToken ──▶ All Services verify locally
                           (no Auth Service call per request)
                      │
               Email Service (password reset, verification)
💡 Key insight: The most important decision in auth design is JWT signing strategy. With asymmetric RS256, the Auth Service signs with a private key and every microservice verifies locally with the public key — zero network hops per request. The trade-off is tokens cannot be instantly revoked (valid until expiry). Mitigate with short TTL (15 min) for access tokens, and a Redis revocation list for refresh tokens on logout.

Case Study: Design a Payment System

🏗️ System Design · Case Studies · 16 min read

A payment system like PayPal or UPI manages account balances internally using double-entry accounting, handles money movement between users and merchants, and connects to external banks for fund deposits and withdrawals. Unlike credit card processing, it owns the ledger. Zero money duplication or loss is a hard requirement.

Step 1 — Functional Requirements

  • Users fund their wallet via bank transfer, UPI, or card
  • Users pay other users or merchants from their wallet
  • Merchants receive payments and withdraw to their bank account
  • Full or partial refunds on transactions
  • Transaction history with search and filtering
  • Every money movement recorded in a double-entry ledger (regulatory requirement)

Out of scope: Credit/lending, investments, insurance, crypto, recurring billing.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

400M users · 10M transactions/day (~116 TPS, peak 500 TPS) · multi-currency (150+).

Availability

99.999%. Money in transit must never be lost. Every transaction must complete or fully roll back.

Latency

Wallet-to-wallet payment < 3 seconds. Fund addition (UPI/bank) async — confirmation in < 30 seconds. Balance check < 100ms.

Storage

Ledger is append-only — 1KB × 10M txns/day = 10GB/day. 7-year retention = ~25TB. Amounts stored as integers (paise/cents, no floating point).

Step 3 — System's API & Sequence Diagram

API Design

POST /v1/payments                  → { paymentId, status:PENDING|SUCCESS|FAILED, txnRef }
POST /v1/wallets/fund              → { walletId, newBalance, transactionId }
GET  /v1/wallets/{id}              → { balance, currency, pendingBalance }
POST /v1/refunds                   → { refundId, amount, originalPaymentId }
GET  /v1/payments?userId=&page=    → { payments:[], total }

Wallet-to-Wallet Payment — Sequence of Events

Client        →  API Gateway   : POST /v1/payments { fromWalletId, toWalletId, amount, idempotencyKey }
API Gateway   →  Payment Svc   : forward (auth + idempotency check)
Payment Svc   →  Redis         : SETNX idempotency:{key} "PROCESSING" EX 600  → OK (new)
Payment Svc   →  Ledger Svc    : BEGIN_TXN { from, to, amount }
Ledger Svc    →  PostgreSQL    : BEGIN
                                  SELECT balance FROM wallets WHERE id=? FOR UPDATE → ₹5000
                                  [balance >= amount? YES. NO → ROLLBACK → INSUFFICIENT_FUNDS]
                                  INSERT ledger_entries (debit:from, credit:to, amount, type=PAYMENT)
                                  UPDATE wallets SET balance=balance-amount WHERE id=from
                                  UPDATE wallets SET balance=balance+amount WHERE id=to
                                  COMMIT
Ledger Svc    →  Payment Svc   : { txnId, status:SUCCESS }
Payment Svc   →  Redis         : SET idempotency:{key} txnId EX 600
Payment Svc   →  Kafka         : publish TXN_COMPLETED { paymentId, fromId, toId, amount }
Payment Svc   →  Client        : { paymentId, status:SUCCESS, txnRef }

Refund — Sequence of Events

Client        →  Payment Svc   : POST /v1/refunds { originalPaymentId, amount, reason }
Payment Svc   →  Ledger Svc    : VERIFY original { originalPaymentId }
Ledger Svc    →  PostgreSQL    : SELECT * FROM payments WHERE id=? AND status='SUCCESS'
                                  [not found or already refunded → 400]
Payment Svc   →  Ledger Svc    : BEGIN_TXN { from:merchant, to:user, amount, type:REFUND, refOf:origId }
Ledger Svc    →  PostgreSQL    : [same debit/credit pattern, links to originalPaymentId]
Payment Svc   →  Kafka         : publish REFUND_COMPLETED
Notify Worker ←  Kafka         : SEND "Refund of ₹{amount} received" (SMS/Push)
Payment Svc   →  Client        : { refundId, status:SUCCESS }

Visual Sequence Diagram

① PAYMENT (ACID double-entry) ② CONFIRMATION ⚡ ASYNC REFUND Client APIGateway PaymentSvc Redis LedgerSvc PostgreSQL Kafka NotifySvc POST /v1/payments forward SETNX idempotency → OK BEGIN_TXN FOR UPDATE → debit/credit → COMMIT { txnId, SUCCESS } { paymentId, SUCCESS } SET idempotency key publish TXN_COMPLETED POST /v1/refunds VERIFY + reverse debit/credit publish REFUND_COMPLETED SMS/Push

Step 4 — Functional Architecture

Payment Service

Orchestrates payment flow; enforces idempotency; payment state machine (PENDING→SUCCESS/FAILED); stateless, horizontally scalable.

Ledger Service

Core double-entry accounting engine; every payment = debit entry + credit entry; immutable — INSERT only, never UPDATE ledger_entries; single-service to own all balance mutations.

PostgreSQL Ledger DB

wallets(id, userId, balance, currency) + ledger_entries(id, debit_wallet, credit_wallet, amount, type, ref_id, created_at); balance updated in the same transaction as ledger entry.

Redis Idempotency Store

idempotency:{key} → paymentId; SETNX is atomic (prevents two concurrent requests with same key both proceeding); TTL 10 min.

Kafka

Async notification delivery; analytics pipeline; bank transfer initiation events; reconciliation triggers.

Bank Integration Service

Connects to NEFT/IMPS/UPI rails; async (bank transfers 1-30 min); stores pending transfers with status polling; webhook listener for bank confirmations.

Reconciliation Worker

Daily job compares ledger totals to external bank statements; sum(ledger_entries) must equal sum(bank_statements); flags any discrepancy for investigation.

Step 5 — Addressing Non-Functional Requirements

Zero Money Duplication

  • Redis SETNX atomically prevents duplicate processing; ledger transaction is atomic (both debit AND credit in one DB COMMIT); partial update = automatic rollback.

99.999% Availability

  • Active-active multi-region PostgreSQL with synchronous replication (cannot lose ledger entries); Payment Service stateless + LB; Redis cluster.

Exactly-once Semantics

  • Kafka consumer commits offset only after DB write succeeds; idempotency key prevents re-processing on Kafka retry; bank transfer polled (not fire-and-forget).
── Final Architecture Summary ──
Client
  │
API Gateway (auth · TLS)
  │
Payment Service (orchestrator, stateless)
  │
  ├──▶ Redis (idempotency — SETNX atomic)
  │
  ├──▶ Ledger Service ──▶ PostgreSQL (ACID, double-entry)
  │                        wallets + ledger_entries
  │                        (SELECT FOR UPDATE → debit → credit → COMMIT)
  │
  ├──▶ Kafka ──▶ Notification Worker (SMS/Push)
  │         └──▶ Analytics Worker
  │         └──▶ Reconciliation Worker (daily)
  │
  └──▶ Bank Integration Service (NEFT/IMPS/UPI — async)
💡 Key insight: The ledger is the heart of any payment system. Double-entry bookkeeping means every money movement creates two entries: a debit on one account and a credit on another. The sum of all ledger entries must always be zero — any discrepancy means money was created or destroyed (bug or fraud). The second most common payment bug is duplicate charges from client retries — prevented by an atomic SETNX idempotency key in Redis before any DB mutation.

Case Study: Design an API Rate Limiter

🏗️ System Design · Case Studies · 14 min read

An API rate limiter controls how many requests a client can make in a time window, protecting APIs from abuse, DDoS, and runaway clients. It must make an allow/deny decision in under 1ms on every request — it sits on the hot path for every API call, so its latency directly becomes your API's latency.

Step 1 — Functional Requirements

  • Allow up to N requests per user/API key per time window (e.g. 1,000 req/min)
  • Different rate limits per tier (Free: 100/min, Pro: 10K/min, Enterprise: custom)
  • Return 429 Too Many Requests with Retry-After header when limit exceeded
  • Rate limit by IP, API key, user ID, or combination
  • Distributed — same limit enforced across all server replicas (not per-instance counters)
  • Per-endpoint overrides (e.g. /search: 10/sec, /upload: 2/min)

Out of scope: DDoS mitigation (Cloudflare layer), cost-based throttling, GraphQL complexity limiting.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

10M API keys · 1M requests/sec throughput across all clients.

Availability

99.99%. If rate limiter is unavailable, fail open (allow requests) — degraded limiting is better than blocking all traffic.

Latency

< 1ms overhead per request (p99). The rate-limiter is in the critical path for every API call.

Storage

Sliding window counters: one sorted set per (apiKey, endpoint). 10M keys × 10 endpoints = 100M sorted sets. Avg 200B = 20GB in Redis — fits in memory.

Step 3 — System's API & Sequence Diagram

API Design (internal — called by API Gateway)

POST /v1/ratelimit/check                 → { allowed:bool, remaining:int, resetAt:timestamp }
PUT  /v1/limits                          → { plan, endpoint, limit, window }  # admin config
GET  /v1/limits/{apiKey}                 → { limits:[] }
# Headers added to every API response:
# X-RateLimit-Limit: 1000
# X-RateLimit-Remaining: 847
# X-RateLimit-Reset: 1719900060
# Retry-After: 42  (only on 429)

Request Check (Sliding Window) — Sequence of Events

Client        →  API Gateway   : GET /v1/search?q=hotels (API-Key: abc123)
API Gateway   →  Rate Limiter  : CHECK { apiKey:abc123, endpoint:/search, ts:now() }
Rate Limiter  →  Redis         : PIPELINE
                                  ZREMRANGEBYSCORE rl:abc123:/search 0 (now-60000)
                                  ZADD rl:abc123:/search {now()} {requestId}
                                  ZCARD rl:abc123:/search
                                  EXPIRE rl:abc123:/search 60
Redis         →  Rate Limiter  : count:47
Rate Limiter  :  47 < 1000 → ALLOWED
Rate Limiter  →  API Gateway   : { allowed:true, remaining:953, resetAt:1719900060 }
API Gateway   →  Backend Svc   : forward request
Backend Svc   →  API Gateway   : { response }
API Gateway   →  Client        : 200 OK + X-RateLimit-Remaining:953
── Limit exceeded path ──
Rate Limiter  :  count >= 1000 → DENIED
Rate Limiter  →  API Gateway   : { allowed:false, remaining:0, retryAfter:42 }
API Gateway   →  Client        : 429 Too Many Requests (Retry-After:42)

Admin Config Update — Sequence of Events

Admin         →  Admin API     : PUT /v1/limits { plan:PRO, endpoint:/search, limit:5000, window:60s }
Admin API     →  Config DB     : UPDATE rate_limits SET limit=5000 WHERE plan=PRO AND endpoint='/search'
Admin API     →  Redis         : SET limit:PRO:/search 5000   (fast-path cache)
Admin API     →  Kafka         : publish LIMIT_CHANGED { plan, endpoint, newLimit }
All Instances ←  Kafka         : refresh local config cache (in-process)
Admin API     →  Admin         : { updated:true, effectiveAt:now() }

Visual Sequence Diagram

① ALLOW PATH (sliding window) ② DENY PATH (429) ⚡ ADMIN CONFIG UPDATE Client API Gateway RateLimiter Redis BackendSvc ConfigDB Kafka GET /v1/search (API-Key) CHECK PIPELINE ZREM+ZADD+ZCARD → 47 allowed:true, remaining:953 forward → 200 OK + X-RateLimit headers count >= 1000 → DENIED retryAfter:42 429 (Retry-After:42) Admin: PUT /v1/limits → UPDATE Config DB SET limit:PRO:/search 5000 publish LIMIT_CHANGED all instances refresh in-process config cache

Step 4 — Functional Architecture

Rate Limiter Service

Stateless; makes allow/deny decision; deployed as sidecar in API Gateway pod or shared microservice; reads limit config from in-process cache (refreshed via Kafka).

Redis Cluster

All counters stored here (shared across 100+ API Gateway replicas); pipelined MULTI commands atomic; sharded by apiKey for even distribution; sub-ms response.

Sliding Window Log Algorithm

ZADD rl:{key}:{endpoint} ts reqId + ZREMRANGEBYSCORE (remove old) + ZCARD (count); most accurate; one sorted-set entry per request; memory ~50B per entry.

Fixed Window Counter (simpler)

INCR rl:{key}:{endpoint}:{window} + EXPIRE; O(1) and memory-efficient; flaw: allows 2× burst at window boundary edge case.

Token Bucket (burst-friendly)

Lua script: atomic GET tokens → if tokens>0 decrement and allow, else deny; refill scheduled job; best for APIs that want to allow short bursts.

Config Service

Stores plan→limit mappings; PostgreSQL for persistence; Redis for fast lookup; Kafka propagates changes to all Rate Limiter instances without restart.

Step 5 — Addressing Non-Functional Requirements

Decision (<1ms)

  • Redis pipeline executes 4 commands in one round-trip (~0.3ms local Redis); config read from in-process memory; no DB call on hot path.

Distributed Enforcement

  • All 100+ API Gateway instances share the same Redis cluster — a counter per instance would allow 100× more requests than intended.

Fail Open on Redis Unavailability

  • Circuit breaker; if Redis unreachable, allow all requests; log and alert; degraded rate-limiting beats blocking all traffic.
── Final Architecture Summary ──
Client
  │
API Gateway (100 replicas)
  │
  ├──▶ Rate Limiter Sidecar ──▶ Redis Cluster
  │     (sliding window,          (ZADD+ZCARD pipeline,
  │      <1ms decision)            shared across all GWs)
  │
  │    allowed? YES ──▶ Backend Service
  │           NO  ──▶ 429 + Retry-After header
  │
  └──▶ Config Service ──▶ PostgreSQL (plan limits)
                              └──▶ Kafka (broadcast to all instances)
💡 Key insight: Rate limiting sounds simple but the subtle trap is distributed enforcement. A naive counter stored per API server instance: with 100 replicas each allowing 1,000 req/min = 100,000 actual req/min allowed instead of 1,000. Fix: shared Redis cluster. The sliding window sorted-set with a pipelined ZADD + ZREMRANGEBYSCORE + ZCARD is atomic and executes in one Redis round-trip — keeping overhead under 1ms while being accurate across all replicas.

Case Study: Design a Video-On-Demand Streaming Service

🏗️ System Design · Case Studies · 16 min read

A VOD platform like Netflix streams high-quality video to 200M subscribers across every device and network condition. The two defining challenges are: (1) transcoding uploaded video into 10+ bitrate/resolution variants per title, and (2) delivering those segments to users with <2-second startup time through a global CDN — serving 100M hours of video per day.

Step 1 — Functional Requirements

  • Users browse a catalogue of movies and TV shows with metadata, thumbnails, and ratings
  • Stream video with adaptive bitrate — quality adjusts dynamically to network speed
  • Studios upload raw video; system transcodes into multiple formats (H.264, H.265) and resolutions (480p, 720p, 1080p, 4K)
  • Playback position saved and synced across devices (resume anywhere)
  • Content is DRM-protected — only paying subscribers can play
  • Users receive personalised recommendations based on watch history

Out of scope: Live streaming, user-generated content, comments, ads.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

200M subscribers · 100M streaming hours/day · catalogue 15K titles × 10 variants = 150K video files.

Availability

99.99%. Buffering or start-delay drives churn — reliability is directly tied to revenue.

Latency

Video start < 2 seconds. Zero buffering during playback (adaptive bitrate + CDN). Catalogue browse < 300ms.

Storage

1 hour of 4K H.265 ≈ 7GB. 15K titles × 2h avg × 10 variants × avg 3GB = ~900TB. CDN caches active content.

Step 3 — System's API & Sequence Diagram

API Design

GET  /v1/catalogue?genre=&page=         → { titles:[], nextCursor }
GET  /v1/titles/{id}                    → { metadata, cast, availableQualities }
GET  /v1/stream/{titleId}               → { manifestUrl (HLS/DASH), licenseToken }
POST /v1/watchprogress                  → 204 OK
GET  /v1/watchprogress/{titleId}        → { positionSeconds }
POST /v1/uploads/{titleId}              → { uploadId, s3PresignedUrl }

Video Upload & Transcoding Pipeline — Sequence of Events

Studio        →  Upload Svc    : POST /v1/uploads { titleId, fileSize }
Upload Svc    →  S3            : generate pre-signed upload URL
Upload Svc    →  Studio        : { presignedUrl }
Studio        →  S3            : PUT raw video file (direct upload — bypasses server)
S3            →  Upload Svc    : ObjectCreated event (SNS → SQS → service)
Upload Svc    →  Kafka         : publish VIDEO_UPLOADED { titleId, s3Key }
Transcode W1  ←  Kafka         : consume VIDEO_UPLOADED → transcode 480p H.264
Transcode W2  ←  Kafka         : consume VIDEO_UPLOADED → transcode 1080p H.265
Transcode W3  ←  Kafka         : consume VIDEO_UPLOADED → transcode 4K H.265
Transcode W4  ←  Kafka         : consume VIDEO_UPLOADED → extract subtitles, thumbnails
All Workers   →  S3            : PUT /video/{titleId}/{quality}.m3u8 + segments
All Workers   →  Metadata DB   : UPDATE title SET status=AVAILABLE, manifests=[...]
All Workers   →  CDN           : invalidate cache for this titleId

User Starts Streaming — Sequence of Events

Client        →  API Gateway   : GET /v1/stream/{titleId}?quality=auto
API Gateway   →  Stream Svc    : forward (auth + subscription check)
Stream Svc    →  Entitlement   : IS_SUBSCRIBED { userId }  → true
Stream Svc    →  DRM Svc       : generate license token { userId, titleId, deviceId, exp }
Stream Svc    →  Client        : { manifestUrl:cdn.7ds.in/video/{titleId}/master.m3u8, licenseToken }
── Client fetches segments from CDN ──
Client        →  CDN Edge      : GET master.m3u8  → [HIT — cached manifest]
Client        :  measure bandwidth → select 1080p
Client        →  CDN Edge      : GET 1080p.m3u8  → segment list
Client        →  CDN Edge      : GET segment_001.ts, segment_002.ts (every 4 sec)
CDN Edge      →  S3            : [MISS on first request — cache and serve]
Client        :  AES-128 decrypt segment → decode H.264/H.265 → render
Client        →  Progress Svc  : POST /watchprogress { titleId, positionSec:240 }  (every 60s)

Visual Sequence Diagram

① UPLOAD + TRANSCODING PIPELINE ② STREAM START (auth + DRM) ⚡ SEGMENT DELIVERY (ABR) Studio UploadSvc S3 Kafka TranscodeWorker Stream/DRMSvc CDNEdge Client POST /uploads PUT raw video (pre-signed) ObjectCreated event publish VIDEO_UPLOADED consume (parallel 480p/1080p/4K) PUT .m3u8 + segments → S3 GET /stream/{id} (auth) IS_SUBSCRIBED → DRM license token { manifestUrl, licenseToken } GET master.m3u8 (HIT) GET segment_NNN.ts (MISS → fetch S3, cache) POST /watchprogress (every 60s)

Step 4 — Functional Architecture

Transcoding Farm

Fleet of CPU/GPU workers consuming Kafka; each worker handles one resolution variant; parallel across all bitrates simultaneously; FFmpeg; outputs HLS (.m3u8 + .ts) and DASH (.mpd + .mp4).

S3 (Video Storage)

Raw uploads + all transcoded segments; lifecycle rules delete raw after 30 days; S3 Intelligent Tiering for cost (old titles → Glacier); ~1 exabyte at Netflix scale.

CDN (CloudFront)

300+ PoPs globally; video segments cached at edge near user; 98% cache hit rate for popular content; long TTL (segments are immutable); <20ms first-byte per segment.

Adaptive Bitrate Streaming (ABR)

HLS or DASH protocol; client measures available bandwidth every 4 seconds; switches quality levels mid-playback; maintains 30-second buffer; eliminates buffering at the cost of momentary quality dip.

DRM Service

Widevine (Chrome/Android), FairPlay (Safari/iOS), PlayReady (Windows); per-user per-device license token; AES-128 encrypted segments; token validated on every play session.

Metadata DB (DynamoDB)

Title info, cast, descriptions, thumbnail URLs, manifest paths; global tables for multi-region reads; handles 1M browse lookups/min.

Watch Progress Service

Saves position every 60 seconds; PostgreSQL + Redis cache; multi-device sync for resume; must not block playback (async write).

Recommendation Service

Offline collaborative filtering ML model; pre-computed personalised lists stored in DynamoDB; refreshed daily; real-time watch signals blended in.

Step 5 — Addressing Non-Functional Requirements

Video Start (<2 seconds)

  • Client receives manifest URL immediately (no round-trip to find segments); first segment at nearest CDN PoP is likely cached; DRM license pre-fetched in parallel with first segment.

Zero Buffering (<0.1%)

  • ABR algorithm switches bitrate before buffer drops below 10 seconds; CDN ensures segments available <20ms; 300+ PoPs mean user is always within 50ms of an edge node.

Storage Cost at Scale

  • Lifecycle rules: raw upload deleted after transcoding (saves 10×); old/rarely-watched titles moved to Glacier; aggressive CDN caching keeps S3 egress minimal.
── Final Architecture Summary ──
Studio ──▶ Upload Svc ──▶ S3 (raw video)
                               │
                         S3 Event → Kafka
                               │
              Transcoding Farm (parallel workers, FFmpeg)
              480p · 720p · 1080p · 4K · subtitles
                               │
                         S3 (transcoded segments)
                               │
                         CDN (300+ PoPs — 98% cache hit)
                               │
Client ──play──▶ Stream Svc (auth + DRM token)
           │
           ▼
         CDN (segment every 4s, ABR quality switching)
           │
       DRM Svc (Widevine/FairPlay license per device)
           │
   Progress Svc (PostgreSQL + Redis — resume on any device)

Metadata: DynamoDB (global tables, browse catalogue)
💡 Key insight: VOD streaming is a content delivery problem disguised as a streaming problem. The heavy lifting — transcoding — happens once at upload time, producing immutable video segments stored in S3. At playback time, 99% of requests are CDN cache hits: serving static files from edge nodes. The adaptive bitrate algorithm in the client prevents buffering — it doesn't stream the highest quality, it streams the highest quality the network can sustain at that moment.

Case Study: Design a Search Engine like Google

🏗️ System Design · Case Studies · 17 min read

A web search engine crawls billions of pages, builds an inverted index, ranks results by relevance and authority, and returns the top 10 in under 200ms for 98,000 searches per second. It is arguably the most complex distributed system ever built — crawling, indexing, ranking, and serving each run as massive independent pipelines.

Step 1 — Functional Requirements

  • Web crawler discovers and fetches pages across the internet (respecting robots.txt)
  • Indexer processes fetched pages: extracts text, builds inverted index, extracts links
  • Users enter a query → system returns top 10 ranked results in <200ms
  • Results include title, URL, and a relevant text snippet from the page
  • Results ranked by relevance (keyword match) + authority (PageRank / link graph)
  • Search supports operators: "exact phrase", site:, filetype:, -exclude

Out of scope: Image/video search, maps, autocomplete, knowledge graph, ads.

Step 2 — Non-Functional Requirements & Scale Estimates

Scale

60 trillion pages indexed · 15B pages crawled/day · 8.5B searches/day (98K/sec, peak 200K/sec).

Availability

99.999%. A search engine outage affects every user who uses it as a starting point for the web.

Latency

Query results < 200ms p99 worldwide. Index freshness: popular pages re-crawled within hours; long-tail within weeks.

Storage

Inverted index ~100PB. Crawl store (raw HTML) ~1EB. Query cache (Redis) covers top 20% of queries (80% of traffic).

Step 3 — System's API & Sequence Diagram

API Design

GET /v1/search?q=&page=&lang=&region=   → { results:[{title,url,snippet,rank}], total, timeTaken }
POST /v1/admin/crawl/schedule            → { url, priority }
GET  /v1/admin/index/status?url=         → { indexed:bool, lastCrawled, pageRank }

Web Crawling & Indexing — Sequence of Events

URL Frontier  →  Crawler       : FETCH next URL (priority-ordered: PageRank × freshness)
Crawler       →  example.com   : GET /page (respects robots.txt, rate-limited per domain)
example.com   →  Crawler       : HTML content
Crawler       →  Content Store : STORE raw HTML + metadata (ts, HTTP headers) to S3
Crawler       →  URL Extractor : PARSE → extract all <a href> links from HTML
URL Extractor →  URL Frontier  : ADD_URLS { newUrls:[20 links], source:pageUrl }
URL Frontier  :  dedup (Bloom filter) → prioritise by PageRank estimate → enqueue
Crawler       →  Kafka         : publish PAGE_FETCHED { url, s3Key, fetchedAt }
Indexer       ←  Kafka         : consume PAGE_FETCHED
Indexer       →  Content Store : READ raw HTML from S3
Indexer       :  tokenize → remove stop words → stem → compute tf-idf per term
Indexer       →  Inverted Index: ADD { term → [{url, tf-idf, position, anchorText}] }
Indexer       →  PageRank Eng  : NOTIFY { url, outboundLinks:[20] }

Query Processing & Result Assembly — Sequence of Events

Client        →  Search Gateway: GET /v1/search?q=best+system+design+books
Search Gateway→  Query Proc    : tokenize + parse query
Query Proc    :  tokens: ["best","system","design","books"]
              :  query expansion: "books" → ["textbooks","guides"]
              :  check Redis: GET cache:{"best system design books"}  → MISS
Query Proc    →  Index Shards  : PARALLEL LOOKUP each term (all shards simultaneously)
  Shard 1     :  "system" → [url1:0.92, url3:0.88, url7:0.75...]
  Shard 2     :  "design" → [url1:0.95, url3:0.80, url9:0.70...]
  Shard 3     :  "books"  → [url1:0.90, url5:0.85, url7:0.60...]
Query Proc    :  INTERSECT + SCORE (tf-idf × PageRank × freshness × CTR signals)
Query Proc    →  Snippet Gen   : EXTRACT relevant excerpt for top-10 URLs
Snippet Gen   →  Content Store : FETCH top-10 cached page content
Snippet Gen   →  Query Proc    : { snippets:[10] }
Search Gateway→  Redis         : SETEX cache:{query} 3600 {results}
Search Gateway→  Client        : { results:[10], timeTaken:"142ms" }

Visual Sequence Diagram

① CRAWL + INDEX ② QUERY PROCESSING (parallel shards) ⚡ RESULT ASSEMBLY + CACHE Client SearchGateway QueryProc IndexShards PageRankEng Crawler URLFrontier ContentStore FETCH next URL STORE raw HTML → S3 ADD_URLS (Bloom dedup) Indexer: tokenize → tf-idf → ADD to index NOTIFY outbound links GET /v1/search?q= tokenize (cache MISS) PARALLEL term lookup (all shards) INTERSECT + SCORE (tf-idf × PageRank × freshness) Snippet Gen → FETCH top-10 content SETEX cache:{query} 3600 { results:[10], 142ms }

Step 4 — Functional Architecture

URL Frontier

Priority queue of URLs to crawl; priority = PageRank estimate × freshness score; Bloom filter dedup (O(1) membership check for 10B URLs in ~1.2GB memory); 10B URLs queued at any time.

Distributed Crawler

10,000+ crawler servers; respects robots.txt and per-domain rate limits; headless Chrome for JavaScript-heavy pages; stores raw HTML in S3 (Content Store).

Inverted Index

{term → [(docId, tf-idf, positions)]} sharded across 1,000s of servers by hash of term; each shard: sub-10ms lookup; multiple replicas per shard; built and rebuilt via MapReduce / Apache Spark.

Query Processor

Tokenises, expands, parses operators; sends parallel lookups to all relevant index shards; merges results; BM25 scoring + PageRank × freshness × CTR-based reranking; BERT-based reranker for top-50.

PageRank Engine

Link graph computation (page = node, link = directed edge); PageRank = measure of authority by inbound links from authoritative pages; runs as distributed graph job (weekly full, daily incremental).

Snippet Generator

For each result URL, finds 1-2 sentences containing the most query terms; highlights matching words; pre-computed for the 1M most-searched queries; on-demand for others.

Redis Query Cache

Caches top-10 results for popular queries (TTL 1 hour); Pareto: top 20% of unique queries = 80% of search traffic; dramatically reduces index server load.

Content Store (S3)

Raw HTML snapshots; used by Indexer for processing and by Snippet Generator for extraction; lifecycle: compress after 30 days, archive after 1 year; structured data extracted to BigTable equivalent.

Step 5 — Addressing Non-Functional Requirements

Query Latency (<200ms)

  • Parallel index shard lookups (all terms fetched simultaneously); Redis cache for popular queries (<5ms hit); only top-50 results scored (not full index).

Index Freshness

  • News sites and social media crawled within minutes (RSS + API firehose); popular sites every few hours; URL Frontier assigns freshness score based on last-modified and change frequency.

98K Searches/sec

  • Search serving is stateless and read-only (trivially horizontally scalable); Redis covers 80% of traffic as cache hits; index servers have read replicas per shard.
── Final Architecture Summary ──
Internet (60T pages)
  │
Crawlers (10K servers) ←── URL Frontier (priority queue + Bloom filter)
  │
Content Store (S3 — raw HTML snapshots)
  │
Indexers (MapReduce pipeline)
  ├──▶ Inverted Index (1000+ shards, sharded by term)
  └──▶ PageRank Engine (link graph → authority scores)

User Query
  │
Search Gateway (logging, A/B, personalisation)
  │
Query Processor → [parallel] → Index Shards × 1000
  │                              (BM25 + PageRank + ML reranker)
Snippet Generator → Content Store (top-10 excerpts)
  │
Redis Cache (popular queries, TTL 1h)
  │
Client (<200ms)
💡 Key insight: A search engine is three completely separate systems running in parallel: (1) a crawler pipeline constantly discovering content, (2) an offline batch pipeline building and updating the inverted index, and (3) an online serving system answering queries from the pre-built index. The query path never crawls or indexes anything — it reads a pre-built snapshot. The entire 200ms latency budget is spent on index lookups, scoring, and snippet generation. Speed comes from pre-computation, not from fast on-demand computation.

Design: Subscription-Based Healthcare API Marketplace

🏗️ System Design · Case Studies · 18 min read

Design a cloud-native, subscription-based Healthcare API Marketplace where hospitals, clinics, and third-party health apps can securely subscribe to and consume FHIR APIs. It follows the Amazon API Gateway "marketplace" architecture with multi-tenancy, API subscriptions, and usage-based (pay-as-you-go) billing.

💡 FHIR (Fast Healthcare Interoperability Resources) is the industry-standard REST API spec for exchanging health data — resources like Patient, Practitioner, Appointment, Observation, Medication, and DiagnosticReport.

Step 1 — Requirements

Functional

  • Expose FHIR APIs: Patient, Practitioner (Doctor), Appointment, Observation (Lab Report), Medication, Diagnostic Report.
  • Hospitals, clinics & third-party apps register, browse a catalog, and subscribe to APIs.
  • Subscription plans: Free, Basic, Premium, Enterprise (different rate limits & prices).
  • Pay-as-you-go: charge per API call; track usage, generate invoices, process payments automatically.
  • Auth: API keys + OAuth2/JWT; rate limiting & throttling per plan; API analytics.

Non-Functional

Availability

99.99% — healthcare data access is critical; multi-AZ, no single point of failure.

Scalability

Horizontal scale of the gateway & FHIR services; multi-tenant isolation across thousands of consumers.

Security & Compliance

HIPAA — encryption in transit (TLS) & at rest (KMS), audit logging, least-privilege, PHI protection.

Fault Tolerance

Async billing/metering (never block API calls), circuit breakers, retries, graceful degradation.

Step 2 — High-Level Architecture

ConsumersHospitals · ClinicsHealth Apps Developer Portalregister · catalogsubscribe · keys Control Plane Subscription & Billing Service API Key / OAuth2 Service Usage Metering Service Notification Service AmazonAPI GatewayauthZ (keys/JWT)rate limit / throttleusage planscaching · WAFemit usage logs FHIR Microservices Patient Practitioner Appointment Observation Medication DiagnosticReport FHIR Data Store(per-tenant, encrypted) ↔ Hospital / EHR Systems (HL7/FHIR) Analytics & Monitoring — CloudWatch · usage dashboards · audit logs · alertsfed by API Gateway access logs + metering events API calls (key/JWT) validate usage events route

Two planes: a control plane (portal, subscriptions, billing, keys, metering) manages who can use what and how they're billed; a data plane (API Gateway → FHIR microservices) serves the actual health data. The gateway is the enforcement point — it authenticates, applies the subscriber's usage plan (rate limits), and emits usage events; it never lets an unbilled or over-quota call through.

Step 3 — Core Components

Developer Portal

Self-service UI: register, browse the API catalog, pick a plan, generate keys, view usage & invoices.

Subscription & Billing Service

Owns plans, subscriptions, invoices, payment orchestration. Source of truth for entitlements.

API Key / OAuth2 Service

Issues API keys and OAuth2 client credentials; validates JWTs; maps a caller → tenant → plan.

Amazon API Gateway

Front door: authZ, usage plans, rate limiting/throttling, caching, WAF, access logging.

FHIR Microservices

One service per resource (Patient, Appointment…), each owning its data; talks to hospital/EHR systems.

Usage Metering

Consumes gateway usage events; aggregates per tenant/API/plan for billing & analytics.

Analytics & Monitoring

Dashboards, audit trails, alerting (CloudWatch/Prometheus + Grafana).

Notification Service

Quota warnings, invoice ready, payment success/failure — via email/webhook.

Step 4 — Sequence: Subscribe → Consume → Bill

── A) Subscribe (control plane) ──
Developer    →  Portal            : sign up, browse catalog
Developer    →  Subscription Svc  : subscribe to "Patient API" · plan = Premium
Subscription →  Key/OAuth Svc     : create API key + OAuth2 client (scoped to tenant + plan)
Key/OAuth    →  API Gateway       : attach key to Usage Plan (rate=100/s, quota=1M/mo)
Subscription →  Developer         : { apiKey, clientId, clientSecret }

── B) Consume an API (data plane, hot path) ──
App          →  API Gateway       : GET /fhir/Patient/123  (x-api-key + Bearer JWT)
API Gateway  →  Key/OAuth Svc     : validate key + JWT · resolve tenant & plan
API Gateway  →  API Gateway       : enforce rate limit / quota (throttle if exceeded → 429)
API Gateway  →  Patient Service   : forward (tenant context injected)
Patient Svc  →  FHIR Data Store   : fetch patient (tenant-isolated, encrypted)
Patient Svc  →  App               : 200 FHIR Patient resource
API Gateway  →  Kafka             : publish USAGE_EVENT { tenant, api, ts, cost }  # async

── C) Meter & bill (async, never blocks B) ──
Metering     ←  Kafka             : consume USAGE_EVENT → aggregate per tenant/API
Billing      ←  (end of cycle)    : compute charges = plan fee + Σ(usage × rate)
Billing      →  Payment Gateway   : charge card (Stripe) · retry on failure
Billing      →  Notification Svc  : "invoice ready / payment processed"

Step 5 — Database Design (per service)

ServiceStoreKey tables
Subscription/BillingPostgreSQL (ACID)tenant, plan, subscription, invoice, payment
API Key/OAuthPostgreSQL + Redisapi_key, oauth_client, scope (Redis caches key→plan)
Usage MeteringTime-series (Timestream/Cassandra)usage_event, usage_agg_daily (high write volume)
FHIR ServicesPostgreSQL per resource (encrypted)patient, appointment, observation… (all with tenant_id)
💡 Multi-tenancy: every row carries a tenant_id; queries are always filtered by it (row-level isolation), enforced in a shared middleware so no service can accidentally leak another tenant's PHI. Large/enterprise tenants can be given a dedicated schema/DB (silo model).

Step 6 — Security Architecture (HIPAA)

── Defense in depth ──
Consumer ──TLS 1.2+──▶ WAF ──▶ API Gateway ──▶ Service ──▶ Encrypted DB (KMS)
                 (blocks OWASP)   │
                                  ├─ AuthN: API key + OAuth2 client-credentials → JWT
                                  ├─ AuthZ: scopes per API + tenant isolation
                                  ├─ Rate limit / quota (per plan)
                                  └─ Audit log EVERY access (who · what PHI · when)

PHI protection:  encryption in transit (TLS) + at rest (AES-256 / KMS) ·
                 field-level encryption for sensitive fields · no PHI in logs/URLs ·
                 signed BAA with cloud provider · least-privilege IAM roles

Step 7 — Billing & Metering Flow (event-driven)

API Gatewayemits usage event KafkaUSAGE_EVENT Metering Svcaggregate per tenant Billing Svcplan + Σ usage×rate PaymentStripe (retry) Invoice+ notify Async by design: metering & billing never sit on the API request path — a billing outage can't take FHIR APIs down.
PlanRate limitModel
Free10 req/s · 10K/mo$0 (hard cap)
Basic50 req/s · 500K/moflat fee + overage
Premium100 req/s · 5M/moflat fee + pay-as-you-go
Enterprisecustom · SLAcontract + dedicated tenancy

Step 8 — Addressing NFRs & Best Practices

  • API Gateway pattern — one enforcement point for authZ, rate limiting, caching, logging.
  • OAuth2 + JWT + API keys — client-credentials flow for machine-to-machine; scopes per API.
  • Multi-tenant designtenant_id everywhere (pool model); dedicated DB for Enterprise (silo).
  • Event-driven billing — usage via Kafka; metering & billing decoupled from the hot path.
  • Caching — gateway caches idempotent GETs; Redis caches key→plan lookups to keep auth fast.
  • High availability — multi-AZ, stateless services + auto-scaling, DB read replicas.
  • Fault tolerance — circuit breakers/retries to hospital systems; billing failures never block APIs.
  • Observability — access logs → analytics; per-tenant usage dashboards; audit trail for HIPAA.
💡 Key insight: a subscription API marketplace is fundamentally a control plane + data plane split. The gateway does the heavy lifting on the hot path (auth, throttling, metering emission) in a few milliseconds, while everything expensive and non-critical — aggregating usage, computing invoices, charging cards — happens asynchronously off the request path. That separation is what lets you bill precisely without ever slowing down (or risking the availability of) the healthcare APIs themselves.

Low-Level Design

Zooming in from the HLD boxes to the concrete API contracts, class structure, database schema, and core algorithms an engineer would implement (shown Spring-Boot-flavoured).

LLD 1 — API Contracts (key endpoints)

PlaneMethod & PathPurpose
ControlPOST /v1/developersRegister a developer/tenant
ControlGET /v1/catalogList available APIs & plans
ControlPOST /v1/subscriptionsSubscribe to an API on a plan → returns key
ControlPOST /oauth/tokenOAuth2 client-credentials → JWT
ControlGET /v1/usage?from=&to=Usage & cost for the caller's tenant
ControlGET /v1/invoices/{id}Invoice details
DataGET /fhir/Patient/{id}FHIR resource (metered, throttled)
DataGET /fhir/Appointment?patient=FHIR search
// POST /v1/subscriptions — request { "apiId": "patient-api", "plan": "PREMIUM" } // response 201 { "subscriptionId": "sub_9f2", "apiKey": "sk_live_a1b2...", // shown once, store hashed "clientId": "cli_77", "clientSecret": "***", "plan": "PREMIUM", "rateLimit": 100, "monthlyQuota": 5000000 }
💡 Consumer sends both on the hot path: x-api-key: sk_live_... (identifies subscription) and Authorization: Bearer <jwt> (proves identity + scopes). The gateway rejects at the edge if either fails or the quota is exceeded (429).

LLD 2 — Class / Domain Model

Tenantid, name, typestatus, createdAthospital · clinic · app Subscriptionid, tenantId, apiIdplanId, statusstartDate, endDate Invoiceid, tenantId, periodamount, status UsageRecordtenantId, apiIdcount, periodStart ApiKeyid, keyHash, tenantIdsubscriptionId, active Planid, name, rateLimitmonthlyQuotamonthlyFee, pricePerCall 1..* 1..* 1..* 1..* *..1
// service interfaces (Spring) interface SubscriptionService { Subscription subscribe(TenantId t, ApiId api, PlanName plan); // issues ApiKey + OAuth client void cancel(SubscriptionId id); } interface ApiKeyService { ApiCredential issue(SubscriptionId s); // key + client secret (hashed at rest) AuthContext validate(String apiKey, String jwt); // → { tenantId, planId, scopes } } interface MeteringService { void record(UsageEvent e); UsageSummary summary(TenantId t, Period p); } interface BillingService { Invoice generate(TenantId t, Period p); void charge(Invoice inv); }

LLD 3 — Database Schema (DDL)

CREATE TABLE tenant ( id UUID PRIMARY KEY, name VARCHAR(160) NOT NULL, type VARCHAR(20) NOT NULL, -- HOSPITAL|CLINIC|APP status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE plan ( id VARCHAR(20) PRIMARY KEY, -- FREE|BASIC|PREMIUM|ENTERPRISE rate_limit_rps INT NOT NULL, monthly_quota BIGINT NOT NULL, monthly_fee NUMERIC(10,2) NOT NULL, price_per_call NUMERIC(10,5) NOT NULL -- pay-as-you-go overage ); CREATE TABLE subscription ( id UUID PRIMARY KEY, tenant_id UUID REFERENCES tenant(id), api_id VARCHAR(40) NOT NULL, -- patient-api, appointment-api... plan_id VARCHAR(20) REFERENCES plan(id), status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', start_date DATE NOT NULL, UNIQUE (tenant_id, api_id) -- one active sub per API per tenant ); CREATE TABLE api_key ( id UUID PRIMARY KEY, key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256, never store raw key tenant_id UUID REFERENCES tenant(id), subscription_id UUID REFERENCES subscription(id), active BOOLEAN DEFAULT true ); CREATE INDEX idx_key_hash ON api_key(key_hash); -- hot-path lookup CREATE TABLE usage_event ( -- high write volume → time-series tenant_id UUID, api_id VARCHAR(40), ts TIMESTAMPTZ, cost NUMERIC(10,5) ); -- partitioned by day; aggregated into usage_agg_daily CREATE TABLE invoice ( id UUID PRIMARY KEY, tenant_id UUID REFERENCES tenant(id), period VARCHAR(7), -- 2026-07 amount NUMERIC(12,2), status VARCHAR(16), -- PENDING|PAID|FAILED UNIQUE (tenant_id, period) -- idempotent billing per cycle );
💡 Every FHIR & billing table carries tenant_id; a shared JPA filter (or row-level security in Postgres) injects WHERE tenant_id = :current so no query can leak another tenant's PHI. Use NUMERIC/BigDecimal for all money — never floats.

LLD 4 — Core Algorithms

Rate limiting — token bucket (per key)

// tokens refill at rateLimit/sec; each call spends one boolean allow(String keyHash, int rateLimit) { long now = nowMs(); Bucket b = redis.get(keyHash); // {tokens, lastRefill} double refill = (now - b.lastRefill)/1000.0 * rateLimit; b.tokens = min(rateLimit, b.tokens + refill); b.lastRefill = now; if (b.tokens < 1) return false; // → 429 Too Many Requests b.tokens -= 1; redis.set(keyHash, b); return true; }

Usage metering — async aggregation

@KafkaListener(topics = "usage-events") void onUsage(UsageEvent e) { // atomic per-tenant/api/day counter — cheap, idempotent-friendly redis.hincrBy("usage:" + e.tenantId + ":" + day(e.ts), e.apiId, 1); } // nightly: flush Redis counters → usage_agg_daily (durable) for billing

Billing — flat fee + tiered overage

BigDecimal charge(Plan p, long calls) { BigDecimal fee = p.monthlyFee(); long overage = max(0, calls - p.includedQuota()); return fee.add(p.pricePerCall().multiply(valueOf(overage))) .setScale(2, RoundingMode.HALF_UP); }

Payments — idempotent charge

void charge(Invoice inv) { // idempotency key = invoiceId → Stripe won't double-charge on retry stripe.charge(inv.amount(), idempotencyKey = inv.id()); inv.markPaid(); // or schedule retry on failure }

LLD 5 — Design Patterns Used

PatternWhere
StrategyPricing/rate-limit logic per plan (FREE vs ENTERPRISE)
FactoryCreating API credentials (key + OAuth client)
RepositoryData access per aggregate (Spring Data JPA)
Observer / Event-drivenUsage events → metering & billing (Kafka)
Chain of ResponsibilityGateway filters: authN → authZ → rate-limit → route
Circuit BreakerCalls to hospital/EHR systems & payment gateway (Resilience4j)
BuilderAssembling invoices from usage line items
💡 LLD takeaway: the two design linchpins are (1) the hashed API key → cached auth context lookup that keeps the hot path sub-millisecond, and (2) the event → aggregate → invoice pipeline that makes billing exact and fully async. Everything else (plans, patterns, schema) hangs off those two.