Skip to content
Unverified — AI-generated content. Help verify this page

LLD Practice: Easy ​

These 10 problems build your LLD muscle memory. Each problem is self-contained — you should be able to design and implement any of them in 30-45 minutes. For each problem, read the requirements, study the class diagram hint, then try to implement it yourself before looking at the key interfaces.

Problem 1: Logger Framework ​

Build a configurable logging framework that supports multiple log levels, output destinations, and formatters.

Requirements ​

  • Support log levels: DEBUG, INFO, WARN, ERROR, FATAL
  • Multiple output destinations: Console, File, HTTP endpoint
  • Configurable log format (JSON, plain text)
  • Filter by minimum log level
  • Thread-safe logging
  • Support for structured metadata (key-value pairs)

Class Diagram Hint ​

Key Interfaces ​

typescript
enum LogLevel { DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3, FATAL = 4 }

interface LogEntry {
  level: LogLevel;
  message: string;
  timestamp: Date;
  metadata?: Record<string, unknown>;
}

interface LogHandler {
  handle(entry: LogEntry): void;
}

interface LogFormatter {
  format(entry: LogEntry): string;
}

Approach ​

  • Strategy Pattern for formatters — swap formatting logic without changing handlers
  • Observer Pattern for handlers — logger pushes entries to all registered handlers
  • Chain of Responsibility (optional) — handlers can filter which entries they process
  • Use a LogLevel enum with numeric values for easy comparison (if (entry.level >= this.minLevel))

Problem 2: In-Memory Cache with TTL ​

Build a cache that stores key-value pairs with time-to-live (TTL), eviction policies, and size limits.

Requirements ​

  • Get/Set/Delete operations with O(1) average time
  • Per-key TTL (time-to-live) — expired entries are not returned
  • Maximum capacity with LRU eviction when full
  • Cache statistics: hits, misses, evictions
  • Optional: support for cache-aside pattern (loader function on miss)

Class Diagram Hint ​

Key Interfaces ​

typescript
interface CacheOptions<K, V> {
  capacity: number;
  defaultTtl?: number;  // milliseconds
  evictionPolicy?: 'lru' | 'lfu' | 'fifo';
  onEvict?: (key: K, value: V) => void;
  loader?: (key: K) => Promise<V>;  // Cache-aside loader
}

interface CacheStats {
  hits: number;
  misses: number;
  evictions: number;
  size: number;
  hitRate: number;
}

Approach ​

  • Use a Map for O(1) lookups combined with a doubly linked list for LRU ordering
  • Lazy expiration: check TTL on get(), do not run a background cleanup timer
  • Optional: periodic cleanup via setInterval to prevent memory leaks from never-accessed expired entries
  • The LRU eviction uses the classic Map + DoublyLinkedList pattern (same as LeetCode #146)

Problem 3: Iterator Pattern ​

Design a generic iterator that supports iteration over different data structures (arrays, linked lists, trees) with a unified interface.

Requirements ​

  • Unified Iterator interface: hasNext(), next(), reset()
  • Support iterating arrays, linked lists, binary trees (in-order)
  • Composable: FilterIterator, MapIterator, TakeIterator
  • Lazy evaluation — don't traverse until next() is called
  • Support for for...of protocol

Key Interfaces ​

typescript
interface Iterator<T> {
  hasNext(): boolean;
  next(): T;
  reset(): void;
  [Symbol.iterator](): Iterator<T>;
}

// Composable iterators
class FilterIterator<T> implements Iterator<T> {
  constructor(private source: Iterator<T>, private predicate: (item: T) => boolean) {}

  hasNext(): boolean { /* find next matching element */ }
  next(): T { /* return next matching element */ }
}

class MapIterator<T, U> implements Iterator<U> {
  constructor(private source: Iterator<T>, private transform: (item: T) => U) {}
}

class TakeIterator<T> implements Iterator<T> {
  constructor(private source: Iterator<T>, private count: number) {}
}

Approach ​

  • Iterator Pattern — classic GoF pattern providing uniform traversal
  • Lazy evaluation means FilterIterator only advances the source iterator when next() is called
  • Composition: new TakeIterator(new FilterIterator(new ArrayIterator(data), fn), 10) takes 10 filtered items
  • Tree iterators use an explicit stack (not recursion) to maintain state between next() calls

Problem 4: Event Emitter ​

Build an event emitter (pub-sub within a single process) that supports typed events, once listeners, and error handling.

Requirements ​

  • on(event, listener) — register a listener
  • off(event, listener) — remove a listener
  • emit(event, ...args) — trigger all listeners for an event
  • once(event, listener) — listener fires once then auto-removes
  • Support wildcard listeners (* matches all events)
  • Error handling: if a listener throws, other listeners still execute

Class Diagram Hint ​

Key Interfaces ​

typescript
type EventListener<T = any> = (...args: T[]) => void;
type Unsubscribe = () => void;

interface IEventEmitter {
  on(event: string, listener: EventListener): Unsubscribe;
  off(event: string, listener: EventListener): void;
  once(event: string, listener: EventListener): Unsubscribe;
  emit(event: string, ...args: any[]): boolean;
  listenerCount(event: string): number;
  removeAllListeners(event?: string): void;
}

Approach ​

  • Store listeners in a Map<string, Array<{ callback, once }>> for O(1) event lookup
  • once() wraps the callback: on first invocation, it calls the real callback then removes itself
  • on() returns an unsubscribe function (closure that calls off())
  • Error isolation: wrap each listener invocation in try/catch so one failing listener does not prevent others from executing
  • Wildcard: maintain a separate * listener array; emit() always invokes these

Problem 5: Rate Limiter ​

Design a rate limiter that supports fixed window, sliding window, and token bucket algorithms.

Requirements ​

  • Multiple algorithms: Fixed Window, Sliding Window Log, Token Bucket
  • Configurable: requests per window, window size, burst capacity
  • Thread-safe (or concurrent-safe in single-threaded async)
  • Return remaining quota and reset time in responses
  • Support per-key rate limiting (by user, IP, API key)

Class Diagram Hint ​

Key Interfaces ​

typescript
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  retryAfterMs?: number;
  limit: number;
  resetAt: Date;
}

interface RateLimiterConfig {
  algorithm: 'fixed-window' | 'sliding-window' | 'token-bucket';
  limit: number;
  windowMs?: number;         // For window-based
  refillRate?: number;       // For token bucket: tokens per second
  burstCapacity?: number;    // For token bucket: max tokens
}

Approach ​

  • Fixed Window: divide time into fixed intervals, count requests per interval, reset at window boundary
  • Sliding Window Log: store timestamp of each request, count requests in the trailing window
  • Token Bucket: bucket refills at fixed rate, each request consumes a token, burst up to max capacity
  • Use Map<string, State> for per-key tracking. Clean up stale entries periodically.

Problem 6: Retry Mechanism ​

Design a retry framework with exponential backoff, jitter, circuit breaker integration, and configurable retry conditions.

Requirements ​

  • Configurable max retries, base delay, max delay
  • Exponential backoff with jitter (full, equal, decorrelated)
  • Retry only on specific errors (configurable predicate)
  • Abort retry on specific errors (non-retryable errors)
  • Optional circuit breaker integration
  • Callbacks: onRetry, onSuccess, onFailure

Key Interfaces ​

typescript
interface RetryConfig {
  maxRetries: number;
  baseDelay: number;         // ms
  maxDelay: number;          // ms
  backoffMultiplier: number; // typically 2
  jitter: 'none' | 'full' | 'equal' | 'decorrelated';
  retryOn?: (error: Error) => boolean;
  abortOn?: (error: Error) => boolean;
  onRetry?: (error: Error, attempt: number, delay: number) => void;
}

class RetryExecutor {
  constructor(private config: RetryConfig) {}

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    // Implement retry loop with backoff
  }

  private calculateDelay(attempt: number): number {
    const exponentialDelay = this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attempt);
    const clampedDelay = Math.min(exponentialDelay, this.config.maxDelay);

    switch (this.config.jitter) {
      case 'none': return clampedDelay;
      case 'full': return Math.random() * clampedDelay;
      case 'equal': return clampedDelay / 2 + Math.random() * (clampedDelay / 2);
      case 'decorrelated': return Math.min(this.config.maxDelay,
        this.config.baseDelay + Math.random() * (clampedDelay * 3 - this.config.baseDelay));
    }
  }
}

Approach ​

  • Jitter prevents the thundering herd — without it, all retries happen at the same time
  • Decorrelated jitter (AWS recommendation) gives the best distribution
  • retryOn predicate lets you skip retries on non-transient errors (e.g., 400 Bad Request)
  • The retry executor is a decorator — it wraps any async function transparently

Problem 7: Config Manager ​

Design a configuration manager that supports multiple sources, environment overrides, type validation, and hot reloading.

Requirements ​

  • Load config from: JSON file, environment variables, command-line args
  • Priority order: CLI args > env vars > config file > defaults
  • Type-safe access with validation
  • Hot reload: detect config file changes and update without restart
  • Support nested config keys (e.g., database.host)
  • Singleton access pattern

Key Interfaces ​

typescript
interface ConfigSource {
  name: string;
  priority: number;  // Higher overrides lower
  load(): Promise<Record<string, unknown>>;
  watch?(onChange: () => void): void;
}

interface ConfigSchema {
  [key: string]: {
    type: 'string' | 'number' | 'boolean' | 'object';
    required?: boolean;
    default?: unknown;
    validate?: (value: unknown) => boolean;
    env?: string;  // MAP to environment variable
  };
}

class ConfigManager {
  private static instance: ConfigManager;
  private sources: ConfigSource[] = [];
  private resolved: Map<string, unknown> = new Map();

  static getInstance(): ConfigManager { /* Singleton */ }

  addSource(source: ConfigSource): void;
  get<T>(key: string): T;
  getOrDefault<T>(key: string, defaultValue: T): T;
  reload(): Promise<void>;
  onChange(key: string, callback: (oldValue, newValue) => void): void;
}

Approach ​

  • Template Method Pattern for config sources — each source implements load() differently
  • Observer Pattern for change notifications — services subscribe to config key changes
  • Merge configs by priority: iterate sources from lowest to highest, deep-merge objects
  • Validate against schema on load — fail fast with clear error messages for missing/invalid config

Problem 8: Connection Pool ​

Design a database connection pool with configurable min/max connections, health checking, and connection recycling.

Requirements ​

  • Configurable min/max pool size
  • Acquire/release connection with timeout
  • Health checking: periodic ping to detect stale connections
  • Connection recycling: close connections older than max lifetime
  • Wait queue: if pool is exhausted, requests wait (with timeout)
  • Pool statistics: active, idle, waiting, total

Class Diagram Hint ​

Key Interfaces ​

typescript
interface PoolConfig {
  min: number;           // Minimum idle connections
  max: number;           // Maximum total connections
  acquireTimeout: number; // ms to wait for a connection
  idleTimeout: number;    // ms before idle connection is closed
  maxLifetime: number;    // ms before connection is recycled
  healthCheckInterval: number; // ms between health checks
  createConnection: () => Promise<Connection>;
  destroyConnection: (conn: Connection) => Promise<void>;
  validateConnection: (conn: Connection) => Promise<boolean>;
}

interface PoolStats {
  total: number;
  active: number;
  idle: number;
  waiting: number;
  created: number;   // lifetime total
  destroyed: number; // lifetime total
}

Approach ​

  • Object Pool Pattern — reuse expensive-to-create connections
  • Idle connections stored in a deque: LIFO for better cache locality (most recently used connection likely has warm TCP state)
  • Background task: health check idle connections, close expired ones, maintain minimum pool size
  • Wait queue: when pool is full, acquire() returns a Promise that resolves when a connection is released

Problem 9: In-Process Pub-Sub ​

Design an in-process publish-subscribe system with topics, subscriptions, and message ordering.

Requirements ​

  • Create/delete topics
  • Subscribe/unsubscribe to topics
  • Publish messages to a topic — delivered to all subscribers
  • Message ordering guaranteed within a topic
  • Support for message filtering (subscribers specify filter criteria)
  • Dead letter handling for failed message processing
  • At-least-once delivery semantics

Key Interfaces ​

typescript
interface Message<T = unknown> {
  id: string;
  topic: string;
  payload: T;
  timestamp: Date;
  headers: Record<string, string>;
}

interface Subscription {
  id: string;
  topic: string;
  handler: (message: Message) => Promise<void>;
  filter?: (message: Message) => boolean;
  maxRetries?: number;
}

interface PubSub {
  createTopic(name: string): void;
  deleteTopic(name: string): void;
  publish<T>(topic: string, payload: T, headers?: Record<string, string>): string;
  subscribe(topic: string, handler: MessageHandler, options?: SubscriptionOptions): string;
  unsubscribe(subscriptionId: string): void;
}

Approach ​

  • Topics stored in a Map<string, Topic> where each Topic has a list of subscriptions
  • Message ordering: process subscribers sequentially for each message (or use per-subscriber queue)
  • Dead letter: after maxRetries failures, move message to a DLQ topic
  • Message IDs enable idempotent processing (subscribers can track processed IDs)

Problem 10: Task Runner ​

Design a task runner that executes tasks with dependencies, parallelism, timeouts, and retry logic.

Requirements ​

  • Define tasks with dependencies (DAG)
  • Detect circular dependencies before execution
  • Execute independent tasks in parallel
  • Configurable concurrency limit
  • Per-task timeout and retry configuration
  • Task status tracking: pending, running, completed, failed, skipped
  • Cancel running execution

Class Diagram Hint ​

Key Interfaces ​

typescript
interface TaskDefinition {
  id: string;
  execute: (context: TaskContext) => Promise<unknown>;
  dependencies?: string[];
  timeout?: number;
  retries?: number;
  retryDelay?: number;
}

type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';

interface TaskContext {
  taskId: string;
  attempt: number;
  results: Map<string, unknown>;  // Results from dependency tasks
  signal: AbortSignal;            // For cancellation
}

Approach ​

  • Build a DAG from task definitions. Detect cycles using DFS with visited + inStack sets.
  • Use topological sort to determine execution order. Tasks with no unfinished dependencies can run in parallel.
  • Concurrency limiter: use a semaphore (counter + queue) to limit parallel tasks.
  • When a task fails after all retries, mark all dependent tasks as skipped.
  • Cancellation: set an AbortController, check signal.aborted in task execution.

Practice Strategy ​

PhaseTimeFocus
Read5 minUnderstand requirements, identify patterns
Design10 minDraw class diagram, define interfaces
Implement20 minCore logic, happy path
Edge cases10 minError handling, concurrency, cleanup

Start with problems 1, 4, and 5 (Logger, Event Emitter, Rate Limiter) — they appear most frequently in interviews and teach patterns used in every other problem.

"What I cannot create, I do not understand." — Richard Feynman