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.
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:
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
| # | Topic | Description |
|---|---|---|
| 1 | Foundations & Thinking Framework | OOP, object modeling, SOLID, dependency injection, the LLD framework. |
| 2 | Design Patterns in Practice | Creational, structural and behavioral patterns with selection guidance. |
| 3 | Game Engines & State-Based Design | Tic-Tac-Toe, Snake & Ladder, Chess, Vending Machine. |
| 4 | Allocation, Scheduling & Expense Modeling | Parking Lot, Elevator, Splitwise. |
| 5 | Orders, Payments & Marketplace Workflows | Food Delivery, Ride-Sharing, Payment Gateway, Trading System. |
| 6 | Concurrent Backend Components | Kafka, LRU Cache, Rate Limiter, Task Scheduler. |
| 7 | Extensible Frameworks & AI Application LLD | Notifications, Logging, In-Memory FS, Claude-style Assistant, Gateway. |
| 8 | RAG Pipelines & AI Tool Execution | Loaders, chunkers, retrievers, and the AI tool runner. |
| 9 | Mock Interviews | Core LLD and AI application design with structured feedback. |
| 10 | Full 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
| Pillar | What it means | Failure signal |
|---|---|---|
| Abstraction | Expose the essential behaviour, hide the mechanism | Leaking internals the caller should not know |
| Encapsulation | Keep state private; allow change only through valid operations | Public mutable fields, impossible states |
| Inheritance | Model genuine "is-a" subtypes, reusing base behaviour | Subclass that throws on an inherited method |
| Polymorphism | Let callers depend on a contract, not a concrete type | if (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#42vsUser#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 anequals. - 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 (
TeamhasPlayers that exist without the team). - Composition — an owned part whose lifetime is bound to the owner (
OrderownsOrderLines; delete the order and the lines die).
- Association — a plain reference (
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)
}
}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
Reportclass 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 RectanglebreakssetWidth, 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
Printershould not be forced to implementscan. - Dependency Inversion (DIP) — depend on abstractions, not concretions. High-level policy should not import a low-level driver.
interface PaymentMethod {
charge(amount: Money): Promise<Receipt>
}
class Checkout {
constructor(private readonly method: PaymentMethod) {}
async pay(amount: Money) {
return this.method.charge(amount)
}
}The LLD framework
Five steps, in order, every time:
- Requirements — clarify functional scope and constraints (how many floors? concurrent users? what happens at capacity?).
- Entities — extract the nouns and decide entity vs value object vs relation.
- Class design — assign responsibilities, define interfaces between them, choose patterns.
- Code — the critical path and the tricky invariants; leave obvious getters as stubs.
- Trade-offs — name extensibility points and what you deliberately skipped.
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
| Pattern | Intent | LLD use case |
|---|---|---|
| Singleton | One shared instance with controlled access | Config, connection pool |
| Factory Method | Defer creation to subclasses/variants | VehicleFactory by type |
| Abstract Factory | Create families of related products | Cross-provider payment SDKs |
| Builder | Construct step-by-step, validate at the end | OrderBuilder, HTTP requests |
| Prototype | Clone a configured instance | Duplicating board/game state |
Structural — how objects compose
| Pattern | Intent | LLD use case |
|---|---|---|
| Adapter | Convert one interface into another | StripeAdapter, PayPalAdapter |
| Decorator | Add behaviour without subclassing | Pricing surcharges, log wrappers |
| Facade | Simplify a subsystem behind one API | PaymentFacade |
| Proxy | Control access to an object | Lazy loading, access checks |
| Composite | Treat leaf and container uniformly | Files and directories |
Behavioral — how objects interact
| Pattern | Intent | LLD use case |
|---|---|---|
| Strategy | Swap algorithms behind one interface | Fee policy, pricing, routing |
| Observer | Notify many dependents of state change | Notifications, pub/sub |
| State | Behaviour changes with internal state | Vending machine, order status |
| Command | Encapsulate a request as an object | Undo, job queue, tool calls |
| Chain of Responsibility | Pass a request along handlers | Approval flows, middleware |
| Template Method | Fix the skeleton, vary the steps | ETL/loader pipelines |
Strategy and Observer are the workhorses; here is each in 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)
}
}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.
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.
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
}
}Problem: Snake & Ladder (🎯 Asked at Flipkart)
The board is a map of portal jumps; the game is dice + turn rotation + terminal condition.
Snake/Ladderboth mapfrom → to; store oneMap<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).
| Class | Responsibility |
|---|---|
Piece | colour, position, abstract moves(board) |
Board | occupancy, move execution, capture |
MoveValidator | legal destinations, checks, pins |
Game | turn, 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.
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:
| Aspect | State | Strategy |
|---|---|---|
| Who changes it | The object itself, on events | The client, on intent |
| Variants | States know each other | Strategies are independent |
| Goal | Model a finite-state machine | Swap an algorithm |
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.
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,
) {}
}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)
}
}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
Elevatoras a state machine (IDLE,MOVING_UP,MOVING_DOWN,DOORS_OPEN) with amin-heap/TreeSetof 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.
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
}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:
Orderwith a lifecycle:PLACED → MATCHED → IN_PROGRESS → COMPLETED | CANCELLED.Matcherpicks a provider (nearest driver / available courier) — Strategy pattern, so a new matching rule is a new class.AssignmentServiceholds the order and the chosen provider atomically, so one driver is not assigned twice.- Publish
OrderStateChangedevents 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.
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 → resultso 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.
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.
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:
| Concept | Responsibility |
|---|---|
| Topic | Named, append-only log split into partitions |
| Partition | Ordered, immutable sequence; unit of parallelism |
| Offset | Consumer position within a partition |
| Consumer Group | Consumers 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.
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
}
}Problem: Rate Limiter (🎯 Asked at Razorpay)
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.
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.
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.
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)
| Component | Responsibility |
|---|---|
Session | Ordered conversation, owner, metadata |
MessageStore | Append-only messages with roles and timestamps |
ContextBuilder | Truncate/summarize to fit the token budget |
ModelClient | Streams 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.
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')
}
}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:
| Stage | Interface | Notes |
|---|---|---|
| Loader | DocumentLoader | PDF, HTML, DB rows → raw text |
| Chunker | Chunker | Fixed, recursive, or semantic splits |
| Embedder | Embedder | Provider-agnostic vector generation |
| Retriever | Retriever | Top-k vector (and/or keyword) search |
| Reranker | Reranker | Cross-encoder refinement of the top-k |
| Generator | Answerer | Prompt with citations back to sources |
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.
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)
}
}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:
- Clarify — functional and non-functional requirements in the first five minutes.
- Model — entities, relationships, interfaces before classes.
- Design — apply the smallest pattern that fits the axis of change.
- Code — the critical path only; leave stubs for the obvious.
- Trade-offs — name what you would do at 10× scale and what you deliberately skipped.
Full problem list (22)
| # | Problem | Focus | Asked at |
|---|---|---|---|
| 1 | Chess | Board state, moves, check/checkmate | |
| 2 | Snake & Ladder | Game engine, dice, board state | Flipkart |
| 3 | Tic-Tac-Toe | Game engine, win detection | Amazon |
| 4 | Parking Lot | Multi-floor, vehicle types, pricing | Amazon |
| 5 | Elevator System | Scheduling, multi-car dispatch | Zomato |
| 6 | Trading System | Order matching, order book | Microsoft |
| 7 | Splitwise | Expense splitting, debt simplification | PhonePe |
| 8 | Food Delivery System | Order tracking, dispatch | Swiggy |
| 9 | Ride-Sharing System | Matching, pricing, tracking | Uber |
| 10 | Kafka | Partitions, offsets, consumer groups | Uber |
| 11 | Payment Gateway | Idempotency, retries, ledger | Razorpay |
| 12 | LRU Cache | HashMap + doubly linked list, O(1) | Uber |
| 13 | Rate Limiter | Token Bucket, Sliding Window | Razorpay |
| 14 | Task Scheduler | Priority execution, delayed jobs | Spotify |
| 15 | Notification System | Multi-channel: email, SMS, push | Swiggy |
| 16 | Logging Framework | Log levels, appenders, extensibility | Microsoft |
| 17 | In-Memory File System | Directories, files, operations | Netflix |
| 18 | Vending Machine | State machine, inventory, payment | Meesho |
| 19 | Claude-style AI Assistant | Sessions, messages, streaming, context | — |
| 20 | Multi-Provider Model Gateway | Adapters, routing, request contracts | — |
| 21 | RAG Pipeline | Loaders, chunkers, retrievers, citations | — |
| 22 | AI Agent Tool Runner | Registry, validation, execution states | — |
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?
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime