Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
Low Level DesignLLDOOPSOLIDDesign PatternsMachine CodingSystem DesignInterview Preparation

Low Level Design Interview Program — 22 Problems, OOP to AI

A complete 10-week Low Level Design curriculum explained topic by topic: OOP, object modeling, SOLID, design patterns, concurrency, and 22 machine-coding problems (Parking Lot, Chess, LRU Cache, Kafka, RAG, AI agents) with interview-ready TypeScript implementations.

Oct 3, 202631 min read

Introduction

Low Level Design is where a vague prompt becomes a working object model. An interviewer says "design a parking lot" and expects you to turn nouns into classes, invariants into validation, and rules into extensible policies — before you write a single line of syntax.

This guide is the full 10-week LLD program, organised topic by topic. Each concept is explained with what it is, why it matters, how to apply it, and the trade-off — followed by a worked problem. Examples are written in TypeScript with interfaces, generics, and dependency injection; the same modeling habits map cleanly to Java or C#. Read the HLD companion for scale-level questions: High Level Design Interview Program.

Every problem is solved with the same discipline:

Quote

requirements → entities → class design → code → trade-offs.

That sequence is the actual interview. Jumping straight to code signals you memorise solutions; walking the sequence signals you can design any system you have never seen.

← Back to System Design overview

Quick index

#TopicDescription
1Foundations & Thinking FrameworkOOP, object modeling, SOLID, dependency injection, the LLD framework.
2Design Patterns in PracticeCreational, structural and behavioral patterns with selection guidance.
3Game Engines & State-Based DesignTic-Tac-Toe, Snake & Ladder, Chess, Vending Machine.
4Allocation, Scheduling & Expense ModelingParking Lot, Elevator, Splitwise.
5Orders, Payments & Marketplace WorkflowsFood Delivery, Ride-Sharing, Payment Gateway, Trading System.
6Concurrent Backend ComponentsKafka, LRU Cache, Rate Limiter, Task Scheduler.
7Extensible Frameworks & AI Application LLDNotifications, Logging, In-Memory FS, Claude-style Assistant, Gateway.
8RAG Pipelines & AI Tool ExecutionLoaders, chunkers, retrievers, and the AI tool runner.
9Mock InterviewsCore LLD and AI application design with structured feedback.
10Full Problem List (22)Every problem, its focus, and where it was asked.

Week 1: Foundations & Thinking Framework

Low level design rests on four object-oriented pillars and five design principles. Interviewers rarely ask you to recite them — they watch whether your classes obey them.

The four pillars of OOP

PillarWhat it meansFailure signal
AbstractionExpose the essential behaviour, hide the mechanismLeaking internals the caller should not know
EncapsulationKeep state private; allow change only through valid operationsPublic mutable fields, impossible states
InheritanceModel genuine "is-a" subtypes, reusing base behaviourSubclass that throws on an inherited method
PolymorphismLet callers depend on a contract, not a concrete typeif (type === 'car') chains everywhere

The pillars are not decoration — they are the mechanism by which a design absorbs change. Abstraction lets you replace an implementation without touching callers. Encapsulation guarantees an object is always in a valid state because nobody can write its fields directly. Polymorphism is what makes the Open/Closed principle possible: adding a variant means adding a class, not editing a switch.

Object modeling vocabulary

Getting the nouns right is most of LLD. Four distinctions come up constantly:

  • Entity — has identity that survives change; two entities with identical attributes are still different (User#42 vs User#43). Model as a class with an id.
  • Value object — defined entirely by its attributes and immutable; two are equal if their values are equal (Money, Coordinate, DateRange). Model as an immutable class, often with an equals.
  • Interface — a capability contract. It says what a collaborator can do, never how.
  • Association vs aggregation vs composition — the strength of a "has-a" relationship:
    • Association — a plain reference (Driver ↔ Ride).
    • Aggregation — a shared part with an independent lifetime (Team has Players that exist without the team).
    • Composition — an owned part whose lifetime is bound to the owner (Order owns OrderLines; delete the order and the lines die).
typescript
class Money {
  constructor(
    private readonly amount: number,
    private readonly currency: string,
  ) {}
 
  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error('Currency mismatch')
    return new Money(this.amount + other.amount, this.currency)
  }
}

Note

Money is a value object: it is immutable, and add returns a new instance rather than mutating. Immutability removes a whole class of bugs — no aliasing surprises, safe to share across threads. Reach for value objects whenever two objects with the same data should be interchangeable.

SOLID principles

SOLID is five rules that keep a design changeable. Learn each as a symptom it prevents:

  • Single Responsibility (SRP) — a class should have one reason to change. If a Report class formats, saves, and emails, three unrelated changes can break it. Split the responsibilities.
  • Open/Closed (OCP) — open for extension, closed for modification. Add a new payment type by adding a class, not by editing an existing switch.
  • Liskov Substitution (LSP) — a subtype must be usable anywhere its base type is, without surprises. If Square extends Rectangle breaks setWidth, it is a modelling error, not a language quirk.
  • Interface Segregation (ISP) — clients should not depend on methods they do not use. Many small interfaces beat one fat one; a Printer should not be forced to implement scan.
  • Dependency Inversion (DIP) — depend on abstractions, not concretions. High-level policy should not import a low-level driver.
typescript
interface PaymentMethod {
  charge(amount: Money): Promise<Receipt>
}
 
class Checkout {
  constructor(private readonly method: PaymentMethod) {}
  async pay(amount: Money) {
    return this.method.charge(amount)
  }
}

Note

Checkout depends on the PaymentMethod interface, not on StripeClient. That is the Dependency Inversion Principle and it is the same shape as dependency injection in Spring or Nest — the container is a detail, the inversion is the design. It also makes Checkout testable with a fake PaymentMethod.

Warning

A subclass that overrides a method to throw UnsupportedOperationException violates Liskov. If a variant cannot fulfil the contract, it is not a subtype — model it as a sibling implementation of a narrower interface instead.

The LLD framework

Five steps, in order, every time:

  1. Requirements — clarify functional scope and constraints (how many floors? concurrent users? what happens at capacity?).
  2. Entities — extract the nouns and decide entity vs value object vs relation.
  3. Class design — assign responsibilities, define interfaces between them, choose patterns.
  4. Code — the critical path and the tricky invariants; leave obvious getters as stubs.
  5. Trade-offs — name extensibility points and what you deliberately skipped.

Interview Answer

The single strongest LLD habit is narrating the sequence. "Before code, let me list the entities and their responsibilities." Interviewers are grading the reasoning; a clean model with a half-finished implementation beats an implementation with no model.

Back to index


Week 2 & 3: Design Patterns in Practice

Patterns are vocabulary for problems that recur. Learn the intent, the participants, and the trade-off — not the diagram. Interviewers respond to "this is a Strategy, because pricing is the axis of change" far better than to a named pattern with no justification.

Creational — how objects come into existence

PatternIntentLLD use case
SingletonOne shared instance with controlled accessConfig, connection pool
Factory MethodDefer creation to subclasses/variantsVehicleFactory by type
Abstract FactoryCreate families of related productsCross-provider payment SDKs
BuilderConstruct step-by-step, validate at the endOrderBuilder, HTTP requests
PrototypeClone a configured instanceDuplicating board/game state

Structural — how objects compose

PatternIntentLLD use case
AdapterConvert one interface into anotherStripeAdapter, PayPalAdapter
DecoratorAdd behaviour without subclassingPricing surcharges, log wrappers
FacadeSimplify a subsystem behind one APIPaymentFacade
ProxyControl access to an objectLazy loading, access checks
CompositeTreat leaf and container uniformlyFiles and directories

Behavioral — how objects interact

PatternIntentLLD use case
StrategySwap algorithms behind one interfaceFee policy, pricing, routing
ObserverNotify many dependents of state changeNotifications, pub/sub
StateBehaviour changes with internal stateVending machine, order status
CommandEncapsulate a request as an objectUndo, job queue, tool calls
Chain of ResponsibilityPass a request along handlersApproval flows, middleware
Template MethodFix the skeleton, vary the stepsETL/loader pipelines

Strategy and Observer are the workhorses; here is each in TypeScript.

typescript
interface PricingStrategy {
  price(hours: number): number
}
 
class FlatHourly implements PricingStrategy {
  constructor(private readonly rate: number) {}
  price(hours: number) {
    return this.rate * hours
  }
}
 
class ParkingFee {
  constructor(private strategy: PricingStrategy) {}
  setStrategy(s: PricingStrategy) {
    this.strategy = s
  }
  total(hours: number) {
    return this.strategy.price(hours)
  }
}
typescript
type Listener<T> = (event: T) => void
 
class EventBus<T> {
  private listeners = new Set<Listener<T>>()
  subscribe(l: Listener<T>) {
    this.listeners.add(l)
    return () => this.listeners.delete(l)
  }
  publish(event: T) {
    this.listeners.forEach((l) => l(event))
  }
}

How to choose a pattern

Name the axis of change first; the pattern follows:

  • New interchangeable algorithm → Strategy.
  • New object type/family → Factory / Abstract Factory.
  • Behaviour driven by internal state transitions → State.
  • Changes must notify many dependents → Observer.
  • A request should be stored, queued, or undone → Command.
  • Steps are fixed but details vary → Template Method.
  • A request passes through a sequence of handlers → Chain of Responsibility.

Tip

Choose a pattern by naming the axis of change. If pricing changes, Strategy. If new steps appear in a pipeline, Template Method or Chain of Responsibility. If state drives behaviour, State. If new object types appear, Factory.

Warning

Patterns are not a checklist. A Singleton used as a global mutable grab-bag is a testing liability; a Factory that only ever returns one class is indirection with no payoff. Add the pattern when the second variant arrives, not before.

Back to index


Week 4: Game Engines & State-Based Design · 4 Problems

Focus: board models, turn management, rule validation, and explicit state machines.

Core theory: finite state machines and turn-based design

Most game problems are finite state machines in disguise. The discipline:

  • Enumerate the states (X to move, O to move, won, draw).
  • Enumerate the events that trigger transitions (play, reset).
  • Decide which events are legal in which state, and reject the rest.

Turn-based games add one more rule: the board is the source of truth, and the game loop only mutates it through validated moves. Separate the immutable rules (win detection, legal moves) from the mutable session (whose turn, move history) so rules can be unit-tested in isolation.

Problem: Tic-Tac-Toe (🎯 Asked at Amazon)

Model the board, enforce turns, and detect a win without hardcoding eight magic arrays.

typescript
type Mark = 'X' | 'O'
type Cell = Mark | null
 
class Board {
  private grid: Cell[][] = Array.from({ length: 3 }, () => [null, null, null])
 
  place(r: number, c: number, mark: Mark): boolean {
    if (this.grid[r][c] !== null) return false
    this.grid[r][c] = mark
    return true
  }
 
  winner(): Mark | null {
    const lines: number[][][] = []
    for (let i = 0; i < 3; i++) {
      lines.push([
        [i, 0],
        [i, 1],
        [i, 2],
      ])
      lines.push([
        [0, i],
        [1, i],
        [2, i],
      ])
    }
    lines.push(
      [
        [0, 0],
        [1, 1],
        [2, 2],
      ],
      [
        [0, 2],
        [1, 1],
        [2, 0],
      ],
    )
    for (const line of lines) {
      const [a, b, c] = line.map(([r, cc]) => this.grid[r][cc])
      if (a && a === b && b === c) return a
    }
    return null
  }
}
 
class Game {
  private turn: Mark = 'X'
  constructor(private board = new Board()) {}
 
  play(r: number, c: number): Mark | 'INVALID' | null {
    if (!this.board.place(r, c, this.turn)) return 'INVALID'
    const won = this.board.winner()
    if (!won) this.turn = this.turn === 'X' ? 'O' : 'X'
    return won
  }
}

Interview Answer

Tic-Tac-Toe is a warm-up for state modeling. Extend it to N×N by keeping row/column/diagonal counters — a win becomes O(1) instead of scanning all lines. Say that out loud; it signals you think about complexity even in "simple" problems.

Problem: Snake & Ladder (🎯 Asked at Flipkart)

The board is a map of portal jumps; the game is dice + turn rotation + terminal condition.

  • Snake/Ladder both map from → to; store one Map<number, number> for both.
  • A move that overshoots 100 is rejected (bounce or skip — clarify the rule, because it changes the win condition).
  • Win is a special case, not a separate phase.

The design insight: model Snake and Ladder as the same abstraction (a portal) rather than two classes. This is polymorphism in the small — one Map instead of two parallel structures.

Problem: Chess (🎯 Asked at Google)

Piece modeling and move validation are the real interview. The board is a 2D occupancy grid; each piece knows its colour and position and can produce candidate destinations; a validator filters those candidates by rules (blocked paths, checks, pins).

ClassResponsibility
Piececolour, position, abstract moves(board)
Boardoccupancy, move execution, capture
MoveValidatorlegal destinations, checks, pins
Gameturn, check/checkmate/stalemate detection

Later: MoveStrategy per piece (Strategy pattern) and a move history for undo (Command pattern). Say that you would keep moves per piece behind an interface so a new piece type is an addition, not an edit.

Problem: Vending Machine (🎯 Asked at Meesho)

A textbook State problem: each state permits a different set of events, and the object transitions itself.

typescript
interface VendingState {
  insertCoin(m: Machine, amount: number): void
  selectProduct(m: Machine, code: string): void
  dispense(m: Machine): void
}
 
class IdleState implements VendingState {
  insertCoin(m: Machine, amount: number) {
    m.balance += amount
    m.setState(m.ready)
  }
  selectProduct() {
    throw new Error('Insert coins first')
  }
  dispense() {
    throw new Error('Nothing selected')
  }
}

State vs Strategy

Both compose behaviour behind an interface, which is why they are confused. The difference is who owns the transition:

AspectStateStrategy
Who changes itThe object itself, on eventsThe client, on intent
VariantsStates know each otherStrategies are independent
GoalModel a finite-state machineSwap an algorithm

Back to index


Week 5: Allocation, Scheduling & Expense Modeling · 3 Problems

Focus: resource allocation, fairness, and concurrency on shared state.

Core theory: allocation and scheduling

  • Allocation answers "which resource do I give this request?" — a strategy (first-fit, best-fit) that must be efficient and fair, and must not hand the same resource to two requests.
  • Scheduling answers "in what order do I serve pending work?" — policies like FCFS, shortest-job-first, or SCAN minimise waiting and avoid starvation.
  • Both are exercises in turning a rule into code, then naming the invariant that must always hold (e.g. "a spot has at most one vehicle").

Problem: Parking Lot (🎯 Asked at Amazon) — full walkthrough

1. Requirements. Multi-floor lot; vehicle types (motorcycle, car, bus); allocate the first fitting spot; issue a ticket on entry; compute a fee on exit; support multiple pricing policies.

2. Entities. ParkingLot, Floor, ParkingSpot, Vehicle, Ticket, PricingStrategy. Note VehicleType describes a spot's size as well as a vehicle's class — a bus needs a bus-sized spot.

typescript
enum VehicleType {
  Motorcycle = 'motorcycle',
  Car = 'car',
  Bus = 'bus',
}
 
interface Vehicle {
  readonly type: VehicleType
  readonly plate: string
}
 
class ParkingSpot {
  private vehicle: Vehicle | null = null
  constructor(
    readonly id: string,
    readonly type: VehicleType,
  ) {}
 
  get isFree() {
    return this.vehicle === null
  }
 
  assign(v: Vehicle) {
    if (!this.isFree) throw new Error('Spot occupied')
    this.vehicle = v
  }
 
  release() {
    this.vehicle = null
  }
}
 
class Floor {
  constructor(readonly spots: ParkingSpot[]) {}
 
  findSpot(type: VehicleType): ParkingSpot | undefined {
    return this.spots.find((s) => s.isFree && s.type === type)
  }
}
 
interface PricingStrategy {
  price(hours: number): number
}
 
class Ticket {
  exitAt?: Date
  constructor(
    readonly id: string,
    readonly vehicle: Vehicle,
    readonly spot: ParkingSpot,
    readonly entryAt: Date,
  ) {}
}
typescript
class ParkingLot {
  constructor(
    private readonly floors: Floor[],
    private readonly pricing: PricingStrategy,
  ) {}
 
  park(v: Vehicle): Ticket {
    for (const floor of this.floors) {
      const spot = floor.findSpot(v.type)
      if (spot) {
        spot.assign(v)
        return new Ticket(crypto.randomUUID(), v, spot, new Date())
      }
    }
    throw new Error('Lot full')
  }
 
  unpark(t: Ticket): number {
    t.exitAt = new Date()
    const hours = Math.ceil((t.exitAt.getTime() - t.entryAt.getTime()) / 3_600_000)
    t.spot.release()
    return this.pricing.price(hours)
  }
}

Performance

findSpot is O(spots) per request. At interview scale that is fine, but say the upgrade path: keep a free-list per (floor, type) so allocation is O(1), and guard assignment with a lock so two concurrent cars cannot claim the same spot.

Problem: Elevator (🎯 Asked at Zomato)

  • Requests: external (floor + direction) and internal (destination).
  • Scheduling: SCAN/LOOK (sweep up, then down) reuses direction and avoids starvation — the same idea as a disk-scheduling algorithm.
  • Multi-car: assign each request to the car minimising estimated arrival; balance load across cars.
  • Model Elevator as a state machine (IDLE, MOVING_UP, MOVING_DOWN, DOORS_OPEN) with a min-heap/TreeSet of pending stops.

The subtle part is the set of stops: an elevator going up should serve all upward requests before reversing, which is exactly what an ordered structure plus a direction flag gives you.

Problem: Splitwise (🎯 Asked at PhonePe)

Expense splitting plus debt simplification — settle N balances with at most N−1 transfers using a greedy max-creditor / max-debtor match. Greedy works here because any settlement that strictly reduces total debt is valid; the algorithm repeatedly pays the largest debtor from the largest creditor.

typescript
function simplify(balances: Map<string, number>): Array<[string, string, number]> {
  const creditors = [...balances].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
  const debtors = [...balances].filter(([, v]) => v < 0).sort((a, b) => a[1] - b[1])
  const transfers: Array<[string, string, number]> = []
  let i = 0
  let j = 0
  while (i < debtors.length && j < creditors.length) {
    const pay = Math.min(-debtors[i][1], creditors[j][1])
    transfers.push([debtors[i][0], creditors[j][0], pay])
    debtors[i][1] += pay
    creditors[j][1] -= pay
    if (debtors[i][1] === 0) i++
    if (creditors[j][1] === 0) j++
  }
  return transfers
}

Note

Mention thread-safety during expense updates: two concurrent splits on the same group must not lose a write. In a single-process interview, a per-group lock or an optimistic version counter is enough to demonstrate awareness.

Back to index


Week 6: Orders, Payments & Marketplace Workflows · 4 Problems

Focus: state transitions, transaction boundaries, and concurrent updates.

Core theory: lifecycle state machines and idempotency

Order-like systems are lifecycle state machines: an order moves PLACED → MATCHED → IN_PROGRESS → COMPLETED | CANCELLED, and illegal transitions must be rejected (you cannot cancel a delivered order). Two ideas recur:

  • Idempotency — the same request applied twice has the same effect as once. Essential wherever retries are possible (networks, queues, payments).
  • Transaction boundary — the set of operations that must succeed or fail together. Keep external calls outside the boundary so a slow provider cannot hold a lock.

Problem: Food Delivery & Ride-Sharing (🎯 Asked at Swiggy / Uber)

Both are order + matching systems. Reuse the same skeleton:

  • Order with a lifecycle: PLACED → MATCHED → IN_PROGRESS → COMPLETED | CANCELLED.
  • Matcher picks a provider (nearest driver / available courier) — Strategy pattern, so a new matching rule is a new class.
  • AssignmentService holds the order and the chosen provider atomically, so one driver is not assigned twice.
  • Publish OrderStateChanged events so tracking and notifications stay decoupled (Observer).

State clearly that tracking is a read model fed by events, not a synchronous query into the order service.

Problem: Payment Gateway (🎯 Asked at Razorpay)

The heart of payments is idempotency and a double-entry ledger. Providers differ, so they sit behind an adapter.

typescript
interface PaymentProvider {
  authorize(req: PaymentRequest): Promise<ProviderResult>
  capture(id: string): Promise<ProviderResult>
  refund(id: string, amount: number): Promise<ProviderResult>
}
 
class StripeAdapter implements PaymentProvider {
  async authorize(req: PaymentRequest) {
    /* map domain -> Stripe, call API */
  }
  async capture(id: string) {
    /* ... */
  }
  async refund(id: string, amount: number) {
    /* ... */
  }
}
  • Accept an Idempotency-Key; store key → result so a retried request returns the original outcome instead of charging twice.
  • Every money movement writes two ledger rows (debit + credit) that must sum to zero.
  • Refunds reference the original charge and cannot exceed the captured amount.

Warning

Never make an external provider call inside a database transaction you cannot roll back. Persist a PENDING intent, call the provider, then reconcile. This is the "transaction boundary" answer that separates a junior from a senior response.

Problem: Trading System (🎯 Asked at Microsoft)

  • Order book: separate bid/ask sides as price-sorted structures (a TreeMap/sorted map per side), so best price is the first element.
  • Matching engine: price-time priority — best price first, oldest order first within a price. This fairness rule is the core invariant.
  • Partial fills: an order may match across multiple resting orders; track remaining quantity and keep the book consistent after each fill.
  • Emit trades and update both sides atomically; a snapshot + event log enables recovery after a crash.

Back to index


Week 7: Concurrent Backend Components · 4 Problems

Focus: locks, critical sections, producer-consumer, and O(1) data structures.

Core theory: concurrency primitives

  • Race condition — the result depends on interleaving. Caused by two threads touching shared state without synchronisation.
  • Critical section — code that must run atomically; protect it with a mutex/lock.
  • Producer-consumer — decouple faster producers from slower consumers via a bounded buffer; a full buffer applies backpressure.
  • Thread pool — a fixed set of workers consuming a queue; bounds resource use and avoids thread-per-request.
  • Deadlock — two threads each waiting on a lock the other holds. Prevent by consistent lock ordering and timeouts.

Two data structures define this week: the LRU cache (hash map + doubly linked list) and the token bucket.

Problem: Kafka (🎯 Asked at Uber)

Model the mental model, not the wire format:

ConceptResponsibility
TopicNamed, append-only log split into partitions
PartitionOrdered, immutable sequence; unit of parallelism
OffsetConsumer position within a partition
Consumer GroupConsumers sharing partitions, one owner per partition

The key invariant: order is guaranteed within a partition, never across partitions. Partition by key when ordering matters for a key (e.g. all events for one order). Consumer groups add parallelism without breaking per-partition order; retention lets new consumer groups replay history.

Problem: LRU Cache (🎯 Asked at Uber) — full implementation

HashMap for O(1) lookup + doubly linked list for O(1) recency updates. The map points at nodes; the list orders them most-recent-first. On a get, move the node to the front; on insert past capacity, evict from the back.

typescript
class Node<K, V> {
  prev: Node<K, V> | null = null
  next: Node<K, V> | null = null
  constructor(
    public key: K,
    public value: V,
  ) {}
}
 
class LRUCache<K, V> {
  private map = new Map<K, Node<K, V>>()
  private head = new Node<K, V>(null as K, null as V) // most recent
  private tail = new Node<K, V>(null as K, null as V) // least recent
 
  constructor(private readonly capacity: number) {
    this.head.next = this.tail
    this.tail.prev = this.head
  }
 
  get(key: K): V | undefined {
    const node = this.map.get(key)
    if (!node) return undefined
    this.detach(node)
    this.attach(node)
    return node.value
  }
 
  put(key: K, value: V): void {
    let node = this.map.get(key)
    if (node) {
      node.value = value
      this.detach(node)
      this.attach(node)
      return
    }
    if (this.map.size === this.capacity) {
      const lru = this.tail.prev!
      this.detach(lru)
      this.map.delete(lru.key)
    }
    node = new Node(key, value)
    this.map.set(key, node)
    this.attach(node)
  }
 
  private detach(n: Node<K, V>) {
    n.prev!.next = n.next
    n.next!.prev = n.prev
  }
 
  private attach(n: Node<K, V>) {
    n.next = this.head.next
    n.prev = this.head
    this.head.next!.prev = n
    this.head.next = n
  }
}

Performance

Every operation is O(1). In a real cache add a TTL and a load factor: evict on both LRU and expiry, and back the map with a segmented lock (or a single lock) so get/put stay safe under concurrency.

Problem: Rate Limiter (🎯 Asked at Razorpay)

typescript
class TokenBucket {
  private tokens: number
  private last = Date.now()
  constructor(
    private readonly capacity: number,
    private readonly refillPerSec: number,
  ) {
    this.tokens = capacity
  }
 
  allow(): boolean {
    const now = Date.now()
    this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.refillPerSec)
    this.last = now
    if (this.tokens >= 1) {
      this.tokens -= 1
      return true
    }
    return false
  }
}

Token bucket refills at a steady rate and allows a burst up to capacity. Sliding window log gives exact limits at the cost of memory. For distributed limiting the counter lives in Redis with an atomic Lua/INCR+EXPIRE, because two app servers must share one budget.

Problem: Task Scheduler (🎯 Asked at Spotify)

  • Worker pool: a bounded set of workers pulling from a blocking queue.
  • Delayed jobs: a min-heap keyed by runAt; a timer promotes due jobs into the ready queue.
  • Cancellation: keep a Set<jobId> of cancelled ids checked before execution.
  • Failure: retries with exponential backoff and a dead-letter queue after max attempts.

Note

Concurrency concepts to name explicitly: ReadWriteLock (many readers, one writer), producer-consumer with a bounded buffer, backpressure when the queue is full, and deadlock avoidance by consistent lock ordering.

Back to index


Week 8: Extensible Frameworks & AI Application LLD · 5 Problems

Focus: plugin-style extensibility and LLM application design.

Core theory: building for extension

A framework is extensible when new behaviour arrives as a new class, not an edited switch. The tools: a narrow interface per extension point, a registry to look implementations up by name, dependency injection to wire them, and tests that swap in fakes. This is the same Open/Closed + Dependency Inversion idea from Week 1, applied at the framework level.

Problem: Notifications & Logging (🎯 Asked at Swiggy / Microsoft)

Both are "fan-out to pluggable channels/sinks" problems — the same Adapter + Observer shape.

typescript
interface Channel {
  send(to: string, message: string): Promise<void>
}
 
class NotificationService {
  private channels = new Map<string, Channel>()
  register(name: string, channel: Channel) {
    this.channels.set(name, channel)
  }
  async notify(to: string, message: string, via: string[]) {
    await Promise.all(via.map((name) => this.channels.get(name)!.send(to, message)))
  }
}
  • Notifications: channels = email/SMS/push adapters; add async queues so a slow provider cannot block the request path.
  • Logging: levels + appenders (console, file, remote) + async batch flush; a formatter is a Strategy. The logging framework is the classic "levels + appenders" extensibility exercise.

Problem: In-Memory File System (🎯 Asked at Netflix)

Directories and files form a tree; path operations are tree traversals. The Composite pattern earns its keep: a Directory and a File share a size() operation and a container treats them uniformly.

typescript
abstract class FsNode {
  constructor(
    public name: string,
    public parent: Directory | null = null,
  ) {}
  abstract size(): number
}
 
class File extends FsNode {
  constructor(
    name: string,
    private content: string,
    parent: Directory | null = null,
  ) {
    super(name, parent)
  }
  size() {
    return this.content.length
  }
}
 
class Directory extends FsNode {
  private children = new Map<string, FsNode>()
 
  add(node: FsNode) {
    node.parent = this
    this.children.set(node.name, node)
  }
 
  size(): number {
    let total = 0
    for (const child of this.children.values()) total += child.size()
    return total
  }
}

mkdir, ls, stat, read, write are all O(path segments) map lookups; size() is a recursive tree sum (cache it and invalidate up the parent chain for O(1) reads).

Problem: Claude-style Assistant (sessions, messages, streaming, context)

ComponentResponsibility
SessionOrdered conversation, owner, metadata
MessageStoreAppend-only messages with roles and timestamps
ContextBuilderTruncate/summarize to fit the token budget
ModelClientStreams tokens, handles retries and cancellation

The context budget is the design pressure: summarise old turns, keep recent turns intact, and never drop system instructions. Streaming is modelled as an Observer — tokens are events pushed to the client as they arrive.

Problem: Model Gateway (adapters, routing, timeouts, fallback)

Wrap each provider behind one interface; route by capability, cost, or health; apply timeouts, retries, and fallback to a secondary provider when the primary degrades. This is Adapter + Strategy + Circuit Breaker in one component.

typescript
interface ModelAdapter {
  complete(prompt: string, signal: AbortSignal): Promise<string>
}
 
class ModelGateway {
  constructor(private readonly providers: ModelAdapter[]) {}
  async complete(prompt: string): Promise<string> {
    for (const provider of this.providers) {
      const controller = new AbortController()
      const timer = setTimeout(() => controller.abort(), 5_000)
      try {
        return await provider.complete(prompt, controller.signal)
      } catch {
        /* try next provider */
      } finally {
        clearTimeout(timer)
      }
    }
    throw new Error('All providers failed')
  }
}

Tip

"Java interfaces, DI, extensibility tests" from the source program translate directly: define a narrow interface per provider, inject it, and write a test that swaps in a fake provider to assert routing and fallback without network calls.

Back to index


Week 9: RAG Pipelines & AI Tool Execution

Focus: retrieval as a pipeline, and tool calling as a controlled execution.

Core theory: pipelines and the tool-execution lifecycle

A RAG pipeline is a chain of replaceable stages, each behind an interface — so a better chunker or retriever is a drop-in, not a rewrite. A tool runner is a state machine per invocation plus a registry, exactly the Command pattern with validation and lifecycle.

Problem: RAG Pipeline

Model each stage as a replaceable component behind an interface:

StageInterfaceNotes
LoaderDocumentLoaderPDF, HTML, DB rows → raw text
ChunkerChunkerFixed, recursive, or semantic splits
EmbedderEmbedderProvider-agnostic vector generation
RetrieverRetrieverTop-k vector (and/or keyword) search
RerankerRerankerCross-encoder refinement of the top-k
GeneratorAnswererPrompt with citations back to sources
typescript
interface Retriever {
  retrieve(query: string, k: number): Promise<Array<{ id: string; text: string; score: number }>>
}
 
class RagPipeline {
  constructor(
    private readonly retriever: Retriever,
    private readonly answerer: (q: string, context: string[]) => Promise<string>,
  ) {}
 
  async ask(query: string) {
    const hits = await this.retriever.retrieve(query, 5)
    return this.answerer(
      query,
      hits.map((h) => h.text),
    )
  }
}

The design value is substitution: swap a keyword retriever for a vector retriever, or add a reranker, without touching the pipeline. Chunking quality and citation tracking are the parts interviewers dig into.

Problem: AI Tool Runner

The runner is essentially a registry + a state machine per invocation. Validation happens at the boundary (untrusted input), and every run carries a cancellable signal.

typescript
type ToolState = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'
 
interface Tool<I, O> {
  name: string
  schema: { parse(input: unknown): I } // validation at the boundary
  run(input: I, signal: AbortSignal): Promise<O>
}
 
class ToolRegistry {
  private tools = new Map<string, Tool<unknown, unknown>>()
  register<I, O>(tool: Tool<I, O>) {
    this.tools.set(tool.name, tool as Tool<unknown, unknown>)
  }
  get(name: string) {
    return this.tools.get(name)
  }
}

Warning

Treat tool input as untrusted. Validate against the schema before execution, enforce timeouts and cancellation with an AbortSignal, bound concurrency, and require explicit approval for destructive tools. Failure-path tests (invalid args, timeout, partial failure) matter more than happy-path ones.

Note

AI-application LLD maps old patterns onto new nouns: Strategy for model/retriever selection, Adapter for providers, Command for tool calls, Observer for streaming events, and State for invocation lifecycle.

Back to index


Week 10: Mock Interviews

The final week is 1:1 practice on both core LLD and AI application design. Run each mock with a timer and the same rubric an interviewer uses:

  1. Clarify — functional and non-functional requirements in the first five minutes.
  2. Model — entities, relationships, interfaces before classes.
  3. Design — apply the smallest pattern that fits the axis of change.
  4. Code — the critical path only; leave stubs for the obvious.
  5. Trade-offs — name what you would do at 10× scale and what you deliberately skipped.

Interview Answer

The candidates who pass LLD rounds are not the fastest coders — they are the ones whose classes explain themselves. If the interviewer can predict your next method signature from your interfaces, you have modeled the problem correctly.

Back to index


Full problem list (22)

#ProblemFocusAsked at
1ChessBoard state, moves, check/checkmateGoogle
2Snake & LadderGame engine, dice, board stateFlipkart
3Tic-Tac-ToeGame engine, win detectionAmazon
4Parking LotMulti-floor, vehicle types, pricingAmazon
5Elevator SystemScheduling, multi-car dispatchZomato
6Trading SystemOrder matching, order bookMicrosoft
7SplitwiseExpense splitting, debt simplificationPhonePe
8Food Delivery SystemOrder tracking, dispatchSwiggy
9Ride-Sharing SystemMatching, pricing, trackingUber
10KafkaPartitions, offsets, consumer groupsUber
11Payment GatewayIdempotency, retries, ledgerRazorpay
12LRU CacheHashMap + doubly linked list, O(1)Uber
13Rate LimiterToken Bucket, Sliding WindowRazorpay
14Task SchedulerPriority execution, delayed jobsSpotify
15Notification SystemMulti-channel: email, SMS, pushSwiggy
16Logging FrameworkLog levels, appenders, extensibilityMicrosoft
17In-Memory File SystemDirectories, files, operationsNetflix
18Vending MachineState machine, inventory, paymentMeesho
19Claude-style AI AssistantSessions, messages, streaming, context—
20Multi-Provider Model GatewayAdapters, routing, request contracts—
21RAG PipelineLoaders, chunkers, retrievers, citations—
22AI Agent Tool RunnerRegistry, validation, execution states—

Back to index


Interview discipline

Low level design interviews test structured modeling, not memorized class diagrams. Clarify requirements, extract entities, define interfaces, apply the pattern that matches the axis of change, then code the critical path and state the trade-offs.

Interview Reflection

Which approach do you use in design rounds?

Interview Answer

My observation from LLD rounds: the pass/fail line is almost never raw coding speed. It is whether the design absorbs the follow-up — "now add a new vehicle type", "now make it concurrent", "now support refunds". Designs built on interfaces and strategy swap cleanly; designs built on if/else collapse under the first extension.

Back to index


Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime