Track C · Design interviews

Low-Level Design (LLD)

The low-level design round (also called object-oriented design, OOD, or machine coding) asks you to turn a fuzzy prompt like "design a parking lot" or "build a rate limiter" into clean, extensible, correct code in under an hour. It is graded less on cleverness than on judgement: did you ask the right questions, find the right entities, put behaviour in the right place, and leave seams for the extensions the interviewer is about to ask for? This page is meant to be enough on its own. It covers what the round is and how it's graded, a minute-by-minute approach, OOP/SOLID with bad-to-good code, the design patterns that actually come up, UML, concurrency, ten classic problems worked in depth, eight AI-flavoured problems that AI-engineering loops increasingly use, testing, machine-coding tactics, and a question bank. Code is typed Python, with notes on the Java idioms interviewers often expect. Every Python snippet on this page was executed before publishing; the Java snippets were not compiled (see the note in §3).

TL;DR: the 8–12 things to be able to say out loud

  • LLD is not HLD. HLD is boxes and arrows across machines: scale, storage, availability. LLD is classes, interfaces and methods inside one process: responsibilities, abstractions, state, concurrency, and code that runs.
  • Spend the first 5–10 minutes on requirements. Agree functional scope, non-functional needs (concurrency? persistence? scale?) and what's explicitly out. Write it down. Most failed rounds fail here.
  • Entities from nouns, methods from verbs. Then write interfaces before implementations, and sketch a class diagram before coding.
  • Happy path end-to-end first, runnable, then edge cases, then extensibility, then concurrency and tests. A working narrow slice beats a beautiful half-built framework.
  • Strategy is the most-used pattern in LLD rounds, followed by State, Factory, Observer, Command, Decorator and Chain of Responsibility. Name a pattern only when it solves a problem you can point to.
  • SOLID in one breath: one reason to change; extend by adding classes, not editing if-chains; subtypes must honour the parent's contract; small interfaces; depend on abstractions you inject.
  • Composition over inheritance. Inherit for genuine "is-a" with a stable contract; compose for behaviour that varies.
  • Inject time, randomness and I/O (clock, RNG, gateways). That's what makes the design testable and what interviewers mean by "testability".
  • Concurrency: find the shared mutable state, protect each invariant with exactly one lock (or make it immutable), never hold a lock across network I/O, order locks to avoid deadlock, and use idempotency keys for retries.
  • The big four classics: LRU (hash map + doubly linked list, O(1)), rate limiter (token bucket with lazy refill, per-key, locked), parking lot (strategy for allocation and pricing, enum-indexed free spots), booking (hold with TTL, all-or-nothing under a per-show lock, pay outside the lock).
  • AI-flavoured LLD is the same craft applied to an LLM client (Strategy + Decorator + Chain), a tool registry and agent loop (Command, schema validation, step cap), memory strategies, token-aware schedulers, prompt registries, vector-store interfaces, DAG executors and semantic caches.
  • Narrate trade-offs. "I chose X over Y because Z; if requirement W shows up, I'd change it here." That sentence is a large part of the grade.

1. What the LLD round is and how it's graded

The three variants

Whiteboard OOD (45–60 min)

"Design a library management system." You talk through requirements, draw a class diagram, write signatures and the key methods in pseudo-code or a real language. Code doesn't need to run. Common at large product companies and in loops where LLD is one round among several.

Machine coding (60–120 min)

You get a problem statement (often a page long, with sample inputs and outputs) and must produce working, runnable code, usually in your own IDE, sometimes followed by a review where the interviewer asks for an extension live. Very common in Indian product companies and startups, and increasingly elsewhere.

"Extend this codebase" (60–90 min)

You're handed a small existing repo (a CLI, a toy service) and asked to add a feature, fix a bug, or refactor for a new requirement. This tests reading code, finding the right seam, and not breaking things. AI-engineering loops often use this format with an LLM-backed toy app.

Hybrid / take-home

A take-home (2–4 hours) followed by a live session where you defend choices and extend it. The live part is graded like an LLD round, so the code needs to be organised to make extension easy.

LLD vs HLD

Low-level designHigh-level design (system design)
Unit of designClasses, interfaces, methods, enums, modulesServices, databases, queues, caches, CDNs
Main concernsResponsibilities, abstractions, state transitions, extensibility, thread safety, testabilityScale, latency, availability, consistency, partitioning, cost
ArtefactsClass diagram, sequence diagram, running code, testsArchitecture diagram, data model, API, capacity estimates
Concurrency meansLocks, conditions, atomic ops inside one processDistributed locks, consensus, idempotent consumers, replication
Typical failureGod class, if-else chains on type, no seams, racesHand-waved bottleneck, single point of failure, wrong store

The two overlap. A rate limiter is an LLD problem in one process and an HLD problem when it's shared across 200 API servers (Redis, Lua scripts, clock skew). Say which you are solving, and mention the other briefly: "In a distributed setting I'd move the bucket state to Redis with an atomic script, but the class design stays the same." The HLD side is covered in B6 · AI System Design and C2.

What's graded

DimensionStrong signalWeak signal
Requirements clarificationAsks about scope, actors, scale, concurrency and failure cases; writes the agreed list down; states assumptionsStarts coding immediately; discovers the requirements halfway through
Entity modellingRight nouns become classes; value objects vs entities; enums for closed sets; behaviour lives with its dataAnaemic data bags plus one Manager class that does everything
AbstractionsInterfaces at the points of variation (pricing, allocation, notification channel); concrete elsewhereEither no interfaces, or an interface for every class "just in case"
ExtensibilityNew vehicle type / payment method / strategy = one new class plus registrationif type == "car" … elif … scattered across files
CorrectnessHandles edge cases: full lot, double unpark, expired hold, zero capacity, roundingHappy path only; off-by-one in time and money
ConcurrencyIdentifies shared state, picks a locking granularity, explains why there's no deadlock"I'd add synchronized everywhere" or ignores it
TestabilityInjected clock/IDs/gateways; a few focused tests; fakesdatetime.now() and network calls buried in domain logic
Code qualityClear names, small methods, types, no dead code, consistent errorsSingle 300-line function; magic numbers; swallowed exceptions
CommunicationThinks aloud, checks in at milestones, explains trade-offs, takes hints wellSilent for 20 minutes; defends a bad choice after a hint

How expectations change with seniority

LevelWhat "good" looks like
Junior / new gradWorking code for the core flow, sensible classes, basic OOP. Some prompting on extensibility is expected. Concurrency may be skipped or only discussed.
Mid-level (SDE-2)Drives requirements alone, uses two or three patterns appropriately, handles edge cases, makes core operations thread-safe when asked, writes a couple of tests.
Senior (SDE-3 / staff track)Everything above, plus: chooses what not to build, anticipates the extension questions, explains locking granularity and failure modes (what happens if payment times out after the hold expires?), connects the in-process design to its distributed version, and keeps the code simple. Over-engineering counts against seniors more than juniors.
Interview angle

Interviewers almost always have an extension question ready: "now add EV charging spots", "now support LFU", "now make it distributed", "now add undo". Your design is judged partly by how few lines change to answer it. Before you finish, ask yourself "what are the two most likely extensions?" and check your design has a seam for each.

Common mistake

Treating LLD as a pattern quiz. Opening with "I'll use the Abstract Factory, Observer and Visitor patterns" before you know the requirements signals memorisation. Start from the problem, then name the pattern you ended up with.

Go deeper

2. A step-by-step approach, with timelines

The same eight steps work for every LLD prompt. What changes with the clock is how long you spend on each, and whether the code has to run.

  1. Clarify requirements. Functional: what operations, which actors, what inputs and outputs. Non-functional: single process or distributed? concurrent callers? persistence? approximate scale (10 spots or 10,000)? latency targets? Scope: explicitly list what you will not build (payments, auth, UI).
  2. List use cases. Three to six verbs from the actor's point of view: park(vehicle), unpark(ticket), pay(ticket), display_free_spots(). These become your public API.
  3. Find entities and relationships. Underline nouns in the requirements (candidate classes) and verbs (candidate methods). Discard nouns that are just attributes (colour, name). Decide has-a vs is-a, and multiplicity (a Floor has many Spots).
  4. Define interfaces before implementations. Where will behaviour vary? Allocation policy, pricing, notification channel, storage. Those get an ABC/Protocol. Everything else can be a concrete class.
  5. Draw the class diagram. Five to ten boxes, with the main relationships. Don't draw every getter.
  6. Walk the key flows as sequence diagrams. For the two most important use cases, trace which object calls which. This is where you find misplaced responsibilities ("who computes the fee: the Ticket, the Lot, or a PricingStrategy?").
  7. Implement the core happy path first. Enums and value objects, then entities, then the service/orchestrator, then a tiny main or test that runs it.
  8. Harden and extend. Edge cases and errors, then extensibility (add the second strategy), then concurrency, then tests. Finish by talking through trade-offs and what you'd do next.

Timeline by round length

Phase45 min (whiteboard)60 min (whiteboard or light coding)90 min (machine coding)
Requirements and scope0–60–80–10
Use cases, entities, relationships6–128–1510–18
Interfaces + class diagram12–2015–2218–25
Key flow (sequence)20–2422–2625–28 (in your head or as a comment)
Core code, happy path24–36 (key methods only)26–4228–55, runnable at the end
Edge cases, extension, concurrency36–42 (discussed)42–5355–75
Tests(mention)(one or two)75–83
Wrap-up, trade-offs, Q&A42–4553–6083–90

These are guides, not rules. If the interviewer cuts requirements short ("assume whatever is reasonable"), say your assumptions out loud in one sentence each and move on.

Clarifying questions that work for almost any prompt

Scope and actors

  • Who uses it: end users, admins, other services?
  • What are the three most important operations?
  • What's explicitly out of scope (payments, auth, UI, persistence)?

Scale and environment

  • Single process in memory, or must it survive restarts?
  • Concurrent callers? Multiple threads, or multiple machines?
  • Rough sizes: entities, requests per second?

Rules and edge cases

  • What happens when it's full, expired, duplicated, cancelled?
  • Money: currency, rounding, refunds?
  • Time: time zones, ordering, TTLs?

Evolution

  • Which part is most likely to change (pricing, types, rules)?
  • Do you want me to optimise for extensibility or for getting it running?
  • Is there an interface I must conform to (input format, CLI)?

Fill-in checklist template

Copy this into the top of your file (or the corner of the whiteboard) in the first five minutes, and fill it in as you go. It keeps you and the interviewer aligned and gives you a script for the wrap-up.

PROBLEM: ____________________________________________ FUNCTIONAL (must) | OUT OF SCOPE (agreed) 1. ________________________ | - ______________________ 2. ________________________ | - ______________________ 3. ________________________ | NICE-TO-HAVE (if time): ____________________________ NON-FUNCTIONAL concurrency: none / threads / processes persistence: memory / DB scale: ______ latency: ______ consistency needs: ______ ASSUMPTIONS: ________________________________________ USE CASES (actor -> verb): ______ ______ ______ ______ ENTITIES (nouns) VALUE OBJECTS / ENUMS SERVICES / ORCHESTRATORS ______ ______ ______ ______ ______ POINTS OF VARIATION -> INTERFACE (pattern) ______ -> ______ (Strategy / State / Factory / Observer ...) SHARED MUTABLE STATE -> PROTECTION ______ -> lock per ______ / immutable / queue / CAS EDGE CASES: full · empty · duplicate · expired · invalid input · concurrent same-item LIKELY EXTENSIONS (check there's a seam): 1. ______ 2. ______ TESTS: happy path · boundary · error · concurrency invariant
Intuition

Think of the round as a conversation with a product manager who also reviews your code. The PM wants to see you pin down what is being built. The reviewer wants to see code they'd approve in a PR. The checklist covers the PM; steps 7 and 8 cover the reviewer.

Common mistake

Spending 30 minutes on a perfect class diagram and having no code when time runs out. In a machine-coding round, unrunnable code is usually a fail however good the design is. Time-box design and get something end-to-end by the halfway mark.

Go deeper

3. Foundations: OOP, SOLID and friends

The four pillars, in practice

PillarTextbookWhat it means in an LLD round
EncapsulationBundle data with the methods that operate on it; hide internalsInvariants are enforced in one place. ParkingLot.park() updates the free-spot index, the ticket map and the spot together, and nobody outside can touch _free. If callers can mutate your state directly, you can't make it thread-safe or correct.
AbstractionExpose what, hide howCallers depend on PricingStrategy.fee(), not on the hourly table. Choose abstractions at the points that will change.
InheritanceSubclass reuses and specialises a parentUse it for a genuine is-a relationship with a stable contract (EmailNotifier is a Notifier). Avoid deep hierarchies and inheriting only to reuse code.
PolymorphismOne interface, many formsThis is what removes if/elif on type: state.insert_coin() does the right thing whether the machine is Idle, HasMoney or SoldOut. In practice it's the most valuable of the four.

Composition over inheritance

Inheritance fixes behaviour at class-definition time and couples the child to the parent's internals. Composition (an object has collaborators behind interfaces) lets behaviour vary per instance and at runtime, and stops the class count from multiplying: with two independent dimensions of variation (fly × quack) inheritance needs a class per combination, while composition needs one class per behaviour. GoF puts the guideline as favouring object composition over class inheritance. GoF 1994

from typing import Protocol


# Inheritance explosion: FlyingDuck, SwimmingDuck, FlyingSwimmingQuackingDuck, ...
# Composition: a Duck HAS behaviours that can be swapped, even at runtime.
class FlyBehavior(Protocol):
    def fly(self) -> str: ...


class Wings:
    def fly(self) -> str: return "flap flap"


class NoFly:
    def fly(self) -> str: return "can't fly"


class Duck:
    def __init__(self, name: str, flyer: FlyBehavior) -> None:
        self.name, self._flyer = name, flyer

    def fly(self) -> str:
        return f"{self.name}: {self._flyer.fly()}"

    def set_flyer(self, flyer: FlyBehavior) -> None:   # behaviour change at runtime
        self._flyer = flyer


rubber = Duck("rubber", NoFly())
assert rubber.fly() == "rubber: can't fly"
rubber.set_flyer(Wings())
assert rubber.fly() == "rubber: flap flap"

Inheritance is still the right tool when (a) there's a real is-a relationship, (b) the parent's contract is stable, and (c) subclasses don't need to override things in ways that break it. Abstract base classes that define an interface (with maybe a template method) are the safe kind of inheritance.

SOLID, with bad → good code

Robert C. Martin collected these five principles; the acronym came later. Wikipedia: SOLID He has written about their continued relevance on his blog. Martin 2020

S: Single Responsibility Principle

A class should have one reason to change, meaning one stakeholder or concern whose requests would make you edit it. Martin 2014 An invoice that computes totals, renders PDFs, saves itself and sends email changes whenever finance, design, the DBA or the email team asks for something.

class Invoice:
    def __init__(self, items: list[tuple[str, int]]) -> None:
        self.items = items

    def total(self) -> int:
        return sum(price for _, price in self.items)

    def to_pdf(self) -> bytes:            # presentation concern
        ...

    def save(self, db) -> None:           # persistence concern
        db.execute("INSERT ...")

    def email(self, smtp) -> None:        # delivery concern
        smtp.send(...)
from dataclasses import dataclass, field
from typing import Protocol


@dataclass
class Invoice:
    items: list[tuple[str, int]] = field(default_factory=list)

    def total(self) -> int:               # domain logic only
        return sum(price for _, price in self.items)


class InvoiceRenderer(Protocol):
    def render(self, invoice: Invoice) -> bytes: ...


class InvoiceRepository(Protocol):
    def save(self, invoice: Invoice) -> None: ...


class InvoiceSender(Protocol):
    def send(self, invoice: Invoice, to: str) -> None: ...

"One reason to change" doesn't mean "one method". Invoice can have ten methods as long as they're all domain logic about invoices.

O: Open/Closed Principle

Open for extension, closed for modification: you add behaviour by adding code, not by editing working code. The tell-tale violation is an if/elif on a type string that grows with every feature.

def shipping_cost(order, method: str) -> float:
    if method == "standard":
        return 5.0
    elif method == "express":
        return 5.0 + order.weight * 1.5
    elif method == "drone":          # every new method edits this function
        return 20.0
    raise ValueError(method)
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Order:
    weight: float


class ShippingMethod(ABC):
    @abstractmethod
    def cost(self, order: Order) -> float: ...


class Standard(ShippingMethod):
    def cost(self, order: Order) -> float:
        return 5.0


class Express(ShippingMethod):
    def cost(self, order: Order) -> float:
        return 5.0 + order.weight * 1.5


class Drone(ShippingMethod):          # new behaviour = new class, no edits elsewhere
    def cost(self, order: Order) -> float:
        return 20.0


def shipping_cost(order: Order, method: ShippingMethod) -> float:
    return method.cost(order)


assert shipping_cost(Order(2.0), Express()) == 8.0

Pair this with a registry or factory so that adding Drone is one new class plus one registration line.

L: Liskov Substitution Principle

Anything that works with the parent type must keep working with a subtype: no stronger preconditions, no weaker postconditions, no surprising side effects. Wikipedia: LSP The classic counter-example is a mutable Square extending Rectangle.

class Rectangle:
    def __init__(self, w: int, h: int) -> None:
        self.w, self.h = w, h

    def set_width(self, w: int) -> None:
        self.w = w

    def area(self) -> int:
        return self.w * self.h


class Square(Rectangle):
    def set_width(self, w: int) -> None:   # silently changes height too
        self.w = self.h = w


def stretch(r: Rectangle) -> None:
    r.set_width(10)
    assert r.area() == 10 * r.h            # holds for Rectangle; Square breaks the caller's expectations
from abc import ABC, abstractmethod
from dataclasses import dataclass


class Shape(ABC):
    @abstractmethod
    def area(self) -> int: ...


@dataclass(frozen=True)
class Rectangle(Shape):
    w: int
    h: int

    def area(self) -> int:
        return self.w * self.h

    def with_width(self, w: int) -> "Rectangle":
        return Rectangle(w, self.h)


@dataclass(frozen=True)
class Square(Shape):
    side: int

    def area(self) -> int:
        return self.side * self.side


assert Rectangle(2, 3).with_width(10).area() == 30
assert Square(3).area() == 9

Other LSP smells to spot in your own designs: a subclass that raises NotImplementedError for an inherited method, a ReadOnlyList extending list, or an override that silently ignores an argument.

I: Interface Segregation Principle

Clients shouldn't depend on methods they don't use. Prefer several small role interfaces to one wide one.

class Worker(ABC):
    @abstractmethod
    def work(self) -> None: ...
    @abstractmethod
    def eat(self) -> None: ...
    @abstractmethod
    def sleep(self) -> None: ...


class Robot(Worker):
    def work(self) -> None: ...
    def eat(self) -> None:
        raise NotImplementedError      # forced to implement what it doesn't need
    def sleep(self) -> None:
        raise NotImplementedError
from typing import Protocol


class Workable(Protocol):
    def work(self) -> None: ...


class Feedable(Protocol):
    def eat(self) -> None: ...


class Human:
    def work(self) -> None: print("typing")
    def eat(self) -> None: print("lunch")


class Robot:
    def work(self) -> None: print("welding")


def run_shift(workers: list[Workable]) -> None:   # depends only on what it uses
    for w in workers:
        w.work()


run_shift([Human(), Robot()])

In Python, typing.Protocol gives structural ("duck") typing that a type checker can verify, which fits ISP well: a class satisfies Workable just by having work(). PEP 544

D: Dependency Inversion Principle

High-level policy shouldn't depend on low-level details; both should depend on abstractions. Practically: constructors take interfaces, and one "composition root" (your main) decides which concrete classes to wire in.

import smtplib


class OrderService:
    def __init__(self) -> None:
        self.mailer = smtplib.SMTP("smtp.internal")   # hard-wired concrete dependency

    def place(self, order) -> None:
        ...
        self.mailer.sendmail("shop@x.com", order.email, "Thanks!")
from typing import Protocol


class Notifier(Protocol):
    def notify(self, to: str, msg: str) -> None: ...


class OrderService:
    def __init__(self, notifier: Notifier) -> None:   # injected abstraction
        self._notifier = notifier

    def place(self, email: str) -> None:
        # ... persist order ...
        self._notifier.notify(email, "Thanks!")


class FakeNotifier:                                     # trivial test double
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def notify(self, to: str, msg: str) -> None:
        self.sent.append((to, msg))


fake = FakeNotifier()
OrderService(fake).place("a@b.com")
assert fake.sent == [("a@b.com", "Thanks!")]
Interview angle

"Which SOLID principle does this violate?" usually comes up when you write an if type == chain (OCP), a god class (SRP), a subclass that throws on an inherited method (LSP/ISP), or self.db = PostgresClient() inside a constructor (DIP). Fix it in the moment and name the principle afterwards. That's better than reciting definitions.

DRY, KISS, YAGNI

Law of Demeter

"Talk only to your immediate friends": a method should call methods on itself, its fields, its parameters and objects it creates, not on objects returned by those. Wikipedia: LoD Chains like a.b().c().d() couple you to the whole object graph.

# Violates Law of Demeter: caller reaches through three objects
total = order.customer.wallet.balance - order.total()

# Better: ask the object that owns the data to do the work
order.customer.can_afford(order.total())

Fluent builders (builder.method().header().build()) are not violations: every call returns the same object.

Cohesion and coupling

High cohesion: the things in a class belong together and change together. Low coupling: classes know little about each other, ideally only an interface. They tend to move together: when you split a low-cohesion class along its responsibilities, the pieces usually need fewer dependencies. A quick test in the round: "if the pricing rules change, how many classes do I touch?" One is the right answer.

Designing for testability

The code is testable if you can control its inputs, including the hidden ones: time, randomness, IDs, and anything over the network. The techniques are simple:

Immutability and value objects

A value object is defined by its value, not its identity: Money(10, USD) equals any other Money(10, USD). Make them immutable (@dataclass(frozen=True)), validate in __post_init__, and return new instances from operations. They can be shared across threads with no locks, used as dict keys, and never end up half-updated. Fowler: ValueObject Entities (a Booking, a User) have identity and a lifecycle; they're usually mutable and need protecting.

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum


class Currency(Enum):
    USD = "USD"
    INR = "INR"


@dataclass(frozen=True, slots=True)
class Money:
    amount: Decimal
    currency: Currency

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError("Money cannot be negative")

    def __add__(self, other: "Money") -> "Money":
        if other.currency != self.currency:
            raise ValueError("currency mismatch")
        return Money(self.amount + other.amount, self.currency)


a = Money(Decimal("10.50"), Currency.USD)
b = Money(Decimal("2.25"), Currency.USD)
assert a + b == Money(Decimal("12.75"), Currency.USD)   # equality by value
assert len({a, Money(Decimal("10.50"), Currency.USD)}) == 1   # hashable
Common mistake

Using float for money. 0.1 + 0.2 != 0.3 in binary floating point. Use integer minor units (cents, paise) or decimal.Decimal, and decide on a rounding rule for splits (see the Splitwise problem below).

Error handling and exceptions

class DomainError(Exception):
    """Base for all expected, business-rule failures."""


class SeatUnavailable(DomainError):
    def __init__(self, seat_id: str) -> None:
        super().__init__(f"seat {seat_id} is not available")
        self.seat_id = seat_id


class PaymentDeclined(DomainError):
    pass


def book(seat_id: str, available: set[str]) -> str:
    if seat_id not in available:
        raise SeatUnavailable(seat_id)        # fail fast, specific type
    available.remove(seat_id)
    return f"booking-for-{seat_id}"


try:
    book("A1", {"A2"})
except SeatUnavailable as e:
    assert e.seat_id == "A1"

Enums and explicit state

Closed sets (vehicle types, order statuses, directions) should be Enums, not strings: typos become errors, and the IDE can list every value. For lifecycle state, combine an enum with an explicit transition table. That gives you a state machine you can draw, test and explain. When behaviour per state grows large, move to the State pattern (§4).

from enum import Enum, auto


class OrderStatus(Enum):
    CREATED = auto()
    PAID = auto()
    SHIPPED = auto()
    DELIVERED = auto()
    CANCELLED = auto()


ALLOWED: dict[OrderStatus, set[OrderStatus]] = {
    OrderStatus.CREATED: {OrderStatus.PAID, OrderStatus.CANCELLED},
    OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
    OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
    OrderStatus.DELIVERED: set(),
    OrderStatus.CANCELLED: set(),
}


class Order:
    def __init__(self) -> None:
        self.status = OrderStatus.CREATED

    def transition(self, to: OrderStatus) -> None:
        if to not in ALLOWED[self.status]:
            raise ValueError(f"illegal transition {self.status.name} -> {to.name}")
        self.status = to


o = Order()
o.transition(OrderStatus.PAID)
try:
    o.transition(OrderStatus.DELIVERED)
except ValueError:
    pass

Mapping to Java

Many interviewers, especially for backend roles, expect Java-flavoured OOD vocabulary even if you code in Python. The mapping:

PythonJavaNote
abc.ABC + @abstractmethodabstract class / interfaceJava interfaces can have default methods. Prefer interfaces for roles, abstract classes for shared skeletons.
typing.Protocolno direct equivalentJava is nominal: a class must declare implements.
@dataclass(frozen=True)record (Java 16+), or final fields + no settersRecords give equals/hashCode/toString.
Enumenum (can have fields, constructors, methods, per-constant bodies)Java enums are a common place to put per-type behaviour; EnumMap is the efficient map.
module-level instanceSingleton via enum or holder classSee the Singleton section.
with lock:synchronized block or lock.lock(); try {…} finally {unlock();}See §6.
Optional[T] / T | NoneOptional<T> for return valuesDon't use Optional for fields or parameters in Java.
import java.util.EnumMap;
import java.util.Map;

// Interface = Python ABC/Protocol; enum with fields; constructor injection.
interface PricingStrategy {
    long fee(VehicleType type, long minutes);
}

enum VehicleType { MOTORCYCLE, CAR, TRUCK }

final class HourlyPricing implements PricingStrategy {
    private final Map<VehicleType, Long> rates = new EnumMap<>(VehicleType.class);

    HourlyPricing() {
        rates.put(VehicleType.MOTORCYCLE, 10L);
        rates.put(VehicleType.CAR, 20L);
        rates.put(VehicleType.TRUCK, 40L);
    }

    @Override
    public long fee(VehicleType type, long minutes) {
        long hours = Math.max(1, (minutes + 59) / 60);
        return hours * rates.get(type);
    }
}

// Java 16+ record = Python @dataclass(frozen=True)
record Ticket(long id, String plate, String spotId) {}

public class C1Strategy {
    public static void main(String[] args) {
        PricingStrategy p = new HourlyPricing();
        if (p.fee(VehicleType.CAR, 125) != 60) throw new AssertionError();
        System.out.println(new Ticket(1, "KA-01", "F0-M1"));
    }
}
Not compiled

The Java snippets on this page were written to be compilable on Java 17+, but no JDK was available when this page was produced, so they were not compiled or run. Check them before you rely on them. All Python snippets were executed under Python 3.13.

Go deeper

4. The design patterns that actually come up

The 1994 "Gang of Four" book catalogued 23 patterns in three groups: creational, structural, behavioural. GoF 1994 Around a dozen of them, plus two from enterprise design (Repository, Specification), account for nearly everything you'll use in an LLD round. Each entry below gives the intent, a minimal runnable Python example, and the problems where it shows up. refactoring.guru has an illustrated page for each GoF pattern. refactoring.guru

Intuition

Most behavioural patterns are one move: replace a conditional with an object. Strategy replaces "if algorithm A else B", State replaces "if state == X", Command replaces "if action == …", Chain of Responsibility replaces a cascade of ifs. If you can see the if-chain you're removing, you can justify the pattern.

Strategy

Intent: define a family of interchangeable algorithms behind one interface and choose one at runtime. refactoring.guru

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Cart:
    subtotal: float
    is_member: bool = False


class PricingStrategy(ABC):
    @abstractmethod
    def price(self, cart: Cart) -> float: ...


class RegularPricing(PricingStrategy):
    def price(self, cart: Cart) -> float:
        return cart.subtotal


class PercentOff(PricingStrategy):
    def __init__(self, pct: float) -> None:
        self.pct = pct

    def price(self, cart: Cart) -> float:
        return round(cart.subtotal * (1 - self.pct / 100), 2)


class Checkout:
    def __init__(self, strategy: PricingStrategy) -> None:
        self.strategy = strategy            # injected; swappable

    def total(self, cart: Cart) -> float:
        return self.strategy.price(cart)


assert Checkout(PercentOff(10)).total(Cart(200)) == 180.0

Shows up in: parking-lot pricing and spot allocation, elevator dispatch, rate-limiting algorithms, split types in Splitwise, cache eviction policy, payment methods, LLM provider selection, memory strategies. In Python a strategy can just be a function (Callable[[Cart], float]); use a class when it has configuration or several methods.

Factory Method and Abstract Factory

Intent: centralise object creation so callers ask for "a notifier for SMS" without naming the concrete class. A simple factory / registry (below) is what you'll usually write. The textbook Factory Method lets subclasses decide which class to instantiate. refactoring.guru

from abc import ABC, abstractmethod
from enum import Enum


class Channel(Enum):
    EMAIL = "email"
    SMS = "sms"
    PUSH = "push"


class Notifier(ABC):
    @abstractmethod
    def send(self, to: str, body: str) -> str: ...


class EmailNotifier(Notifier):
    def send(self, to: str, body: str) -> str: return f"email to {to}: {body}"


class SmsNotifier(Notifier):
    def send(self, to: str, body: str) -> str: return f"sms to {to}: {body}"


class PushNotifier(Notifier):
    def send(self, to: str, body: str) -> str: return f"push to {to}: {body}"


class NotifierFactory:
    _registry: dict[Channel, type[Notifier]] = {
        Channel.EMAIL: EmailNotifier,
        Channel.SMS: SmsNotifier,
        Channel.PUSH: PushNotifier,
    }

    @classmethod
    def register(cls, channel: Channel, impl: type[Notifier]) -> None:
        cls._registry[channel] = impl       # open for extension without editing create()

    @classmethod
    def create(cls, channel: Channel) -> Notifier:
        try:
            return cls._registry[channel]()
        except KeyError:
            raise ValueError(f"unsupported channel {channel}") from None


assert NotifierFactory.create(Channel.SMS).send("+1", "hi").startswith("sms")

Abstract Factory creates families of related objects that must be used together (dark-theme button + dark-theme checkbox; AWS queue + AWS blob store). refactoring.guru

from abc import ABC, abstractmethod


class Button(ABC):
    @abstractmethod
    def render(self) -> str: ...


class Checkbox(ABC):
    @abstractmethod
    def render(self) -> str: ...


class UIFactory(ABC):                       # creates a *family* of related products
    @abstractmethod
    def button(self) -> Button: ...
    @abstractmethod
    def checkbox(self) -> Checkbox: ...


class DarkButton(Button):
    def render(self) -> str: return "[dark button]"


class DarkCheckbox(Checkbox):
    def render(self) -> str: return "[dark checkbox]"


class DarkFactory(UIFactory):
    def button(self) -> Button: return DarkButton()
    def checkbox(self) -> Checkbox: return DarkCheckbox()


def build_form(f: UIFactory) -> list[str]:  # client never names a concrete class
    return [f.button().render(), f.checkbox().render()]


assert build_form(DarkFactory()) == ["[dark button]", "[dark checkbox]"]

Shows up in: notification service (channel → notifier), vehicle/spot creation, parsing commands in machine-coding rounds (command string → Command object), choosing an LLM provider adapter from config, chess piece creation.

Builder

Intent: construct a complex object step by step, validate once at build(), and produce an immutable result. refactoring.guru

from dataclasses import dataclass, field


@dataclass(frozen=True)
class HttpRequest:
    method: str
    url: str
    headers: dict[str, str]
    body: bytes | None
    timeout_s: float


class HttpRequestBuilder:
    def __init__(self, url: str) -> None:
        self._url = url
        self._method = "GET"
        self._headers: dict[str, str] = {}
        self._body: bytes | None = None
        self._timeout = 30.0

    def method(self, m: str) -> "HttpRequestBuilder":
        self._method = m.upper()
        return self

    def header(self, k: str, v: str) -> "HttpRequestBuilder":
        self._headers[k] = v
        return self

    def json(self, payload: str) -> "HttpRequestBuilder":
        self._body = payload.encode()
        return self.header("Content-Type", "application/json")

    def timeout(self, s: float) -> "HttpRequestBuilder":
        self._timeout = s
        return self

    def build(self) -> HttpRequest:
        if self._method == "GET" and self._body is not None:
            raise ValueError("GET with body")   # validate the whole object once
        return HttpRequest(self._method, self._url, dict(self._headers), self._body, self._timeout)


req = HttpRequestBuilder("https://api.example.com/v1").method("post").json('{"a":1}').timeout(5).build()
assert req.method == "POST" and req.headers["Content-Type"] == "application/json"

Shows up in: HTTP/LLM request objects, query builders, pizza/burger ordering prompts, configuring a game board. In Python, keyword arguments with defaults often make Builder unnecessary. Use it when construction has ordering or cross-field validation rules, or when the interviewer expects Java style.

Singleton (and why it's usually a smell)

Intent: ensure one instance and provide global access to it. refactoring.guru

import threading


class Config:
    _instance: "Config | None" = None
    _lock = threading.Lock()

    def __new__(cls) -> "Config":
        if cls._instance is None:               # fast path, no lock
            with cls._lock:
                if cls._instance is None:       # double-checked inside the lock
                    inst = super().__new__(cls)
                    inst.settings = {}
                    cls._instance = inst
        return cls._instance


assert Config() is Config()

# Usually better in Python: a module-level instance (modules are imported once),
# or simply construct one object at the composition root and inject it.

Why it's often a smell: it's global mutable state with a nicer name. It hides dependencies (nothing in a constructor signature says the class reads config), it makes tests share state and order-dependent, and it bakes "exactly one" into the class when "one per process" is really a wiring decision. The usual better answer is to create one instance at the composition root and inject it.

When it's acceptable: genuinely process-wide, stateless or read-mostly resources such as a logger, a metrics registry, or a connection pool, and even then prefer a module-level instance in Python.

Thread-safe variants: double-checked locking as above, an eagerly created module-level instance (Python modules are initialised once, under the import lock), and in Java the enum singleton or the initialization-on-demand holder idiom, which rely on the JVM's guarantees about class initialisation.

Interview angle

"Make the parking lot a Singleton" is a common prompt. A good answer: "I can, with double-checked locking, but I'd rather construct one ParkingLot in main and inject it. Then tests can create fresh lots and we could run several lots in one process later." Then do whichever the interviewer prefers.

Observer / pub-sub

Intent: when one object changes, notify a list of dependents without coupling to them. refactoring.guru Pub-sub is the decoupled version, where publishers and subscribers only share a topic name on a bus.

from collections import defaultdict
from typing import Callable

Handler = Callable[[dict], None]


class EventBus:
    def __init__(self) -> None:
        self._subs: dict[str, list[Handler]] = defaultdict(list)

    def subscribe(self, topic: str, handler: Handler) -> Callable[[], None]:
        self._subs[topic].append(handler)
        return lambda: self._subs[topic].remove(handler)   # unsubscribe handle

    def publish(self, topic: str, event: dict) -> None:
        for h in list(self._subs[topic]):                   # copy: handlers may unsubscribe
            try:
                h(event)
            except Exception as exc:                        # one bad subscriber must not break others
                print(f"handler failed: {exc!r}")


bus = EventBus()
seen: list[dict] = []
unsub = bus.subscribe("order.paid", seen.append)
bus.publish("order.paid", {"id": 1})
unsub()
bus.publish("order.paid", {"id": 2})
assert seen == [{"id": 1}]

Shows up in: parking-lot display boards, stock tickers, notification on booking confirmed, auction bids, elevator floor displays, in-memory pub-sub (§7), agent event streams. Watch for: iterating a list that handlers modify, one failing handler breaking the rest, memory leaks from never unsubscribing, and synchronous handlers slowing the publisher (offload to a queue).

State

Intent: an object changes behaviour when its internal state changes. Each state is a class implementing the same interface, and transitions replace if state == … in every method. refactoring.guru

from abc import ABC, abstractmethod


class DocState(ABC):
    @abstractmethod
    def publish(self, doc: "Document") -> None: ...
    @abstractmethod
    def edit(self, doc: "Document", text: str) -> None: ...


class Draft(DocState):
    def publish(self, doc: "Document") -> None:
        doc.state = InReview()

    def edit(self, doc: "Document", text: str) -> None:
        doc.text = text


class InReview(DocState):
    def publish(self, doc: "Document") -> None:
        doc.state = Published()

    def edit(self, doc: "Document", text: str) -> None:
        raise PermissionError("cannot edit while in review")


class Published(DocState):
    def publish(self, doc: "Document") -> None:
        pass                                    # idempotent

    def edit(self, doc: "Document", text: str) -> None:
        doc.text, doc.state = text, Draft()     # editing a published doc reopens it


class Document:
    def __init__(self) -> None:
        self.text = ""
        self.state: DocState = Draft()

    def publish(self) -> None: self.state.publish(self)
    def edit(self, text: str) -> None: self.state.edit(self, text)


d = Document()
d.edit("v1"); d.publish()
assert isinstance(d.state, InReview)

Shows up in: vending machine, ATM, elevator (moving/idle/maintenance), order lifecycle, traffic light, document workflow, TCP connection. If there are few states and little per-state behaviour, an enum plus a transition table (§3) is simpler. Switch to State classes when every method has a big switch on the state.

Command (with undo)

Intent: wrap a request as an object, so you can queue it, log it, retry it, or undo it. refactoring.guru

from abc import ABC, abstractmethod


class Command(ABC):
    @abstractmethod
    def execute(self) -> None: ...
    @abstractmethod
    def undo(self) -> None: ...


class TextBuffer:
    def __init__(self) -> None:
        self.text = ""


class Insert(Command):
    def __init__(self, buf: TextBuffer, pos: int, s: str) -> None:
        self.buf, self.pos, self.s = buf, pos, s

    def execute(self) -> None:
        t = self.buf.text
        self.buf.text = t[: self.pos] + self.s + t[self.pos:]

    def undo(self) -> None:
        t = self.buf.text
        self.buf.text = t[: self.pos] + t[self.pos + len(self.s):]


class Editor:
    def __init__(self) -> None:
        self.buf = TextBuffer()
        self._undo: list[Command] = []
        self._redo: list[Command] = []

    def run(self, cmd: Command) -> None:
        cmd.execute()
        self._undo.append(cmd)
        self._redo.clear()                     # a new action invalidates the redo history

    def undo(self) -> None:
        if self._undo:
            cmd = self._undo.pop(); cmd.undo(); self._redo.append(cmd)

    def redo(self) -> None:
        if self._redo:
            cmd = self._redo.pop(); cmd.execute(); self._undo.append(cmd)


e = Editor()
e.run(Insert(e.buf, 0, "hello"))
e.run(Insert(e.buf, 5, " world"))
e.undo()
assert e.buf.text == "hello"
e.redo()
assert e.buf.text == "hello world"

Shows up in: text editor undo/redo, machine-coding CLIs (each input line → a Command), job queues, transaction logs, remote controls, LLM tool calls (§8). The undo stack plus "clear redo on a new action" is the detail interviewers check.

Decorator

Intent: add behaviour to an object by wrapping it in another object with the same interface. Decorators stack, and each does one thing. refactoring.guru

from abc import ABC, abstractmethod


class DataSource(ABC):
    @abstractmethod
    def read(self, key: str) -> str: ...


class Database(DataSource):
    def __init__(self) -> None:
        self.calls = 0

    def read(self, key: str) -> str:
        self.calls += 1
        return f"value:{key}"


class CachingSource(DataSource):              # same interface, wraps another DataSource
    def __init__(self, inner: DataSource) -> None:
        self._inner, self._cache = inner, {}

    def read(self, key: str) -> str:
        if key not in self._cache:
            self._cache[key] = self._inner.read(key)
        return self._cache[key]


class LoggingSource(DataSource):
    def __init__(self, inner: DataSource) -> None:
        self._inner = inner

    def read(self, key: str) -> str:
        print(f"read {key}")
        return self._inner.read(key)


db = Database()
src = LoggingSource(CachingSource(db))         # stack behaviours in any order
src.read("a"); src.read("a")
assert db.calls == 1

Shows up in: caching, logging, metrics, retries, rate limiting around a client; pizza/coffee toppings pricing; I/O streams. The LLM client in §8 is a stack of decorators. Don't confuse this with Python's @decorator syntax, which wraps functions. The idea is the same, but the GoF pattern wraps objects behind an interface.

Adapter

Intent: make an existing class with the wrong interface usable where another interface is expected. refactoring.guru

from typing import Protocol


class PaymentGateway(Protocol):               # the interface our code expects
    def charge(self, user_id: str, amount_cents: int) -> str: ...


class LegacyStripeLikeClient:                 # third-party shape we can't change
    def create_charge(self, payload: dict) -> dict:
        return {"id": "ch_123", "status": "succeeded", **payload}


class LegacyClientAdapter:
    def __init__(self, client: LegacyStripeLikeClient) -> None:
        self._c = client

    def charge(self, user_id: str, amount_cents: int) -> str:
        resp = self._c.create_charge({"customer": user_id, "amount": amount_cents})
        if resp["status"] != "succeeded":
            raise RuntimeError("charge failed")
        return resp["id"]


gw: PaymentGateway = LegacyClientAdapter(LegacyStripeLikeClient())
assert gw.charge("u1", 500) == "ch_123"

Shows up in: payment gateways, third-party SMS/email providers, legacy systems, LLM vendor SDKs behind a common LLMProvider, vector-database backends.

Facade

Intent: one simple interface over a complicated subsystem. refactoring.guru

class Inventory:
    def reserve(self, sku: str) -> bool: return True


class Payments:
    def charge(self, user: str, cents: int) -> str: return "pay_1"


class Shipping:
    def schedule(self, sku: str, user: str) -> str: return "ship_1"


class OrderFacade:                            # one simple entry point over a subsystem
    def __init__(self, inv: Inventory, pay: Payments, ship: Shipping) -> None:
        self.inv, self.pay, self.ship = inv, pay, ship

    def place_order(self, user: str, sku: str, cents: int) -> dict[str, str]:
        if not self.inv.reserve(sku):
            raise RuntimeError("out of stock")
        return {"payment": self.pay.charge(user, cents), "shipment": self.ship.schedule(sku, user)}


assert OrderFacade(Inventory(), Payments(), Shipping()).place_order("u", "sku", 100)["payment"] == "pay_1"

Shows up in: the top-level service of almost every LLD answer (BookingService, ParkingLot, Retriever). Adapter changes an interface; Facade simplifies many.

Chain of Responsibility

Intent: pass a request along a chain of handlers; each either handles it, rejects it, or passes it on. refactoring.guru

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class Request:
    user: str | None
    body: str
    ip: str


class Handler(ABC):
    def __init__(self) -> None:
        self._next: "Handler | None" = None

    def then(self, nxt: "Handler") -> "Handler":
        self._next = nxt
        return nxt                            # allows a.then(b).then(c)

    def handle(self, req: Request) -> str:
        return self._next.handle(req) if self._next else "OK"


class AuthHandler(Handler):
    def handle(self, req: Request) -> str:
        return "401" if req.user is None else super().handle(req)


class SizeLimitHandler(Handler):
    def handle(self, req: Request) -> str:
        return "413" if len(req.body) > 1000 else super().handle(req)


class BlocklistHandler(Handler):
    def __init__(self, blocked: set[str]) -> None:
        super().__init__()
        self.blocked = blocked

    def handle(self, req: Request) -> str:
        return "403" if req.ip in self.blocked else super().handle(req)


head = BlocklistHandler({"6.6.6.6"})
head.then(AuthHandler()).then(SizeLimitHandler())
assert head.handle(Request("u", "x", "1.1.1.1")) == "OK"
assert head.handle(Request(None, "x", "1.1.1.1")) == "401"

Shows up in: middleware pipelines (auth → rate limit → validation), ATM cash dispensing (500s, then 200s, then 100s), logger level handling, approval workflows (manager → director → VP), LLM provider fallback, guardrail pipelines.

Template Method

Intent: a base class fixes the skeleton of an algorithm; subclasses fill in the steps. refactoring.guru

from abc import ABC, abstractmethod


class DataExporter(ABC):
    def export(self, rows: list[dict]) -> str:      # the fixed algorithm skeleton
        rows = self.filter(rows)
        out = self.header(rows) + "".join(self.format_row(r) for r in rows)
        return out

    def filter(self, rows: list[dict]) -> list[dict]:   # hook with a default
        return rows

    @abstractmethod
    def header(self, rows: list[dict]) -> str: ...

    @abstractmethod
    def format_row(self, row: dict) -> str: ...


class CsvExporter(DataExporter):
    def header(self, rows: list[dict]) -> str:
        return ",".join(rows[0]) + "\n" if rows else ""

    def format_row(self, row: dict) -> str:
        return ",".join(str(v) for v in row.values()) + "\n"


assert CsvExporter().export([{"a": 1, "b": 2}]) == "a,b\n1,2\n"

Shows up in: game loops (init → while not over: take_turn → announce_winner), data exporters and parsers, test fixtures, report generators. It's inheritance-based, so prefer Strategy when the steps vary independently.

Composite

Intent: treat individual objects and groups of objects uniformly through a tree. refactoring.guru

from abc import ABC, abstractmethod


class FsNode(ABC):
    def __init__(self, name: str) -> None:
        self.name = name

    @abstractmethod
    def size(self) -> int: ...


class File(FsNode):
    def __init__(self, name: str, size: int) -> None:
        super().__init__(name)
        self._size = size

    def size(self) -> int:
        return self._size


class Directory(FsNode):
    def __init__(self, name: str) -> None:
        super().__init__(name)
        self.children: list[FsNode] = []

    def add(self, node: FsNode) -> "Directory":
        self.children.append(node)
        return self

    def size(self) -> int:                    # treats leaves and composites uniformly
        return sum(c.size() for c in self.children)


root = Directory("/").add(File("a.txt", 10)).add(Directory("src").add(File("m.py", 32)))
assert root.size() == 42

Shows up in: file systems, org charts, menus, UI component trees, nested expense groups, boolean filter expressions (the Specification below is a composite).

Iterator

Intent: traverse a collection without exposing how it's stored, possibly in several orders. refactoring.guru In Python this is built in: implement __iter__ or write a generator.

from collections.abc import Iterator


class Playlist:
    def __init__(self, songs: list[str]) -> None:
        self._songs = songs

    def __iter__(self) -> Iterator[str]:      # default order
        return iter(self._songs)

    def shuffled(self, seed: int) -> Iterator[str]:   # alternative traversal, same collection
        import random
        order = list(range(len(self._songs)))
        random.Random(seed).shuffle(order)
        for i in order:
            yield self._songs[i]


p = Playlist(["a", "b", "c"])
assert list(p) == ["a", "b", "c"]
assert sorted(p.shuffled(seed=1)) == ["a", "b", "c"]

Shows up in: playlists, paginated APIs, tree traversal (BFS/DFS iterators), streaming LLM tokens.

Proxy

Intent: a stand-in with the same interface that controls access to the real object: lazy creation (virtual proxy), permission checks (protection proxy), remote calls (remote proxy), or caching. refactoring.guru

from typing import Protocol


class Image(Protocol):
    def display(self) -> str: ...


class RealImage:
    def __init__(self, path: str) -> None:
        self.path = path
        self.pixels = f"<decoded {path}>"     # imagine an expensive load here

    def display(self) -> str:
        return self.pixels


class LazyImageProxy:                         # virtual proxy: same interface, defers creation
    def __init__(self, path: str, user_role: str) -> None:
        self.path, self.role = path, user_role
        self._real: RealImage | None = None

    def display(self) -> str:
        if self.role != "viewer" and self.role != "admin":
            raise PermissionError("protection proxy: access denied")
        if self._real is None:
            self._real = RealImage(self.path)
        return self._real.display()


img = LazyImageProxy("cat.png", "viewer")
assert img._real is None
assert img.display() == "<decoded cat.png>"

Proxy vs Decorator: structurally identical. A proxy controls access to the subject and often manages its lifecycle; a decorator adds behaviour and is designed to stack.

Repository

Intent: a collection-like interface for loading and saving domain objects, hiding the storage technology. Fowler: Repository

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class User:
    id: str
    email: str


class UserRepository(ABC):                    # domain-facing collection-like interface
    @abstractmethod
    def get(self, user_id: str) -> User | None: ...
    @abstractmethod
    def add(self, user: User) -> None: ...
    @abstractmethod
    def find_by_email(self, email: str) -> User | None: ...


class InMemoryUserRepository(UserRepository):
    def __init__(self) -> None:
        self._rows: dict[str, User] = {}

    def get(self, user_id: str) -> User | None:
        return self._rows.get(user_id)

    def add(self, user: User) -> None:
        self._rows[user.id] = user

    def find_by_email(self, email: str) -> User | None:
        return next((u for u in self._rows.values() if u.email == email), None)


# A PostgresUserRepository would implement the same interface; services never know which.
repo = InMemoryUserRepository()
repo.add(User("1", "a@x.com"))
assert repo.find_by_email("a@x.com").id == "1"

Shows up in: any problem where the interviewer says "assume a database". Write an in-memory repository behind an interface, mention that a SQL one would implement the same interface, and move on. It also gives you a fake for tests for free.

Specification

Intent: encapsulate a business rule as a composable predicate object (and/or/not), so filters can be combined without writing a method per combination. Described by Evans and Fowler. Evans & Fowler

from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Product:
    name: str
    price: float
    color: str
    in_stock: bool


class Spec(ABC):
    @abstractmethod
    def ok(self, p: Product) -> bool: ...

    def __and__(self, other: "Spec") -> "Spec": return _And(self, other)
    def __or__(self, other: "Spec") -> "Spec": return _Or(self, other)
    def __invert__(self) -> "Spec": return _Not(self)


class _And(Spec):
    def __init__(self, a: Spec, b: Spec) -> None: self.a, self.b = a, b
    def ok(self, p: Product) -> bool: return self.a.ok(p) and self.b.ok(p)


class _Or(Spec):
    def __init__(self, a: Spec, b: Spec) -> None: self.a, self.b = a, b
    def ok(self, p: Product) -> bool: return self.a.ok(p) or self.b.ok(p)


class _Not(Spec):
    def __init__(self, a: Spec) -> None: self.a = a
    def ok(self, p: Product) -> bool: return not self.a.ok(p)


class ColorIs(Spec):
    def __init__(self, c: str) -> None: self.c = c
    def ok(self, p: Product) -> bool: return p.color == self.c


class PriceBelow(Spec):
    def __init__(self, x: float) -> None: self.x = x
    def ok(self, p: Product) -> bool: return p.price < self.x


class InStock(Spec):
    def ok(self, p: Product) -> bool: return p.in_stock


catalog = [Product("a", 10, "red", True), Product("b", 50, "red", True), Product("c", 5, "blue", False)]
spec = ColorIs("red") & PriceBelow(20) & InStock()
assert [p.name for p in catalog if spec.ok(p)] == ["a"]

Shows up in: product search filters, eligibility rules (who gets a discount), vector-store metadata filters (§8), parking-spot matching rules, alert conditions.

Pattern → problem cheat sheet

PatternProblems where it's the natural fitTell-tale sign you need it
StrategyParking pricing/allocation, elevator dispatch, rate-limit algorithm, Splitwise split types, eviction policy, LLM provider, memory strategy"There are several ways to compute X, and more will come"
Factory / registryNotification channels, vehicle/piece creation, CLI command parsing, provider adaptersA switch on a type string that returns new …
Abstract FactoryThemed UI kits, cloud-provider families, test vs prod wiringObjects must come from the same family
BuilderHTTP/LLM requests, complex orders, game setup, query constructionConstructor with 8 optional arguments; cross-field validation
SingletonLogger, config, metrics registry (prefer DI)Truly one per process and read-mostly
Observer / pub-subDisplay boards, notifications, stock ticker, auctions, pub-sub queue, event-driven agents"When X happens, also do Y and Z"
StateVending machine, ATM, elevator, order/booking lifecycle, traffic lightEvery method begins with if self.state == …
CommandUndo/redo editor, CLI input, job queue, tool calls, macro recordingNeed to queue, log, retry or undo actions
DecoratorCaching/logging/retry wrappers, coffee toppings, LLM client middlewareOptional behaviours combined in many ways
AdapterPayment/SMS gateways, LLM vendor SDKs, vector DB backendsThird-party interface doesn't match yours
FacadeThe top-level service class in most answersCallers need one entry point to many parts
Chain of ResponsibilityMiddleware, ATM dispensing, approvals, logger levels, provider fallbackCascade of "if this handler can't, try the next"
Template MethodGame turn loop, exporters/parsers, report generationSame steps in the same order, different details
CompositeFile system, menus, org chart, nested filtersTree where leaves and groups answer the same question
IteratorPlaylists, pagination, tree traversal, token streamsSeveral traversal orders over one collection
ProxyLazy image loading, access control, remote stubs, cachingNeed to control access to an expensive or sensitive object
RepositoryAnything with "assume a database"Domain code shouldn't know about SQL
SpecificationSearch filters, eligibility rules, metadata filtersCombinatorial explosion of find_by_x_and_y methods
Common mistake

Pattern-stuffing. A tic-tac-toe answer with a Factory for marks, a Singleton board, an Observer for moves and a Visitor for scoring reads as over-engineering, especially from a senior candidate. Each pattern should remove a specific piece of complexity you can point to.

Go deeper

5. UML essentials

You need about ten pieces of UML notation, and interviewers rarely care whether your arrowheads match the spec exactly. What they want is to see that you distinguish "owns" from "uses" from "is-a". uml-diagrams.org The formal specification is published by the OMG. OMG UML

Class diagram notation

ParkingLot - spots: dict[str, Spot] # pricing: PricingStrategy + park(v: Vehicle): Ticket + unpark(id: int): int name / attributes / operations + public - private # protected ~ package «interface» or italics = abstract Relationship Meaning · Python shape Inheritance (generalisation) is-a · class Car(Vehicle) Realisation (implements) implements · class Hourly(Pricing) Association knows-a, long-lived reference · self.owner: User Aggregation (hollow diamond on whole) has-a, parts outlive whole · Team ◇— Player Composition (filled diamond on whole) owns, parts die with whole · Lot ◆— Floor Dependency uses temporarily · method param / local var ParkingLot Floor 1 1..* Multiplicity: 1 · 0..1 · * (0..*) · 1..* · n..m, written at each end. Read: "one ParkingLot is composed of one or more Floors; a Floor belongs to exactly one lot". Diamond sits on the "whole" side; triangle points at the parent; dashed = weaker/ interface relationship.
The class-diagram notation worth knowing. In practice, the distinctions interviewers check are inheritance vs realisation, composition vs aggregation, and association vs dependency, plus multiplicities on the main relationships.
RelationshipTest questionExample
CompositionIf the whole is destroyed, do the parts go too? Can a part belong to only one whole?Order ◆— OrderLine; ParkingLot ◆— Floor
AggregationThe whole groups parts that exist independentlyDepartment ◇— Employee; Playlist ◇— Song
AssociationA long-lived reference with no ownershipTicket → Spot; Booking → User
DependencyUsed only inside a method (parameter, local, return)ParkingLot.unpark() uses Clock
InheritanceIs-a, and every parent contract holds (LSP)Car → Vehicle
RealisationImplements an interfaceTokenBucket ⇢ RateLimiter

Sequence diagrams

A sequence diagram shows objects as columns (lifelines) and messages as arrows going down in time order. Solid arrows are calls, dashed arrows are returns, and boxes labelled alt/opt/loop show branches and repetition. uml-diagrams.org In an interview, ASCII is fine. Here is the "book seats" flow from §7:

User BookingService Show (lock) PaymentGateway | hold(show, seats) | | | |------------------->| acquire show.lock | | | |---------------------->| | | | check all AVAILABLE | | | | mark HELD, set TTL | | | |<- - - - - - - - - - - | release lock | |<- - - - Hold - - - | | | | confirm(hold_id) | | | |------------------->| charge(user, amt, idempotency_key=hold_id) | | |----------------------------------------------->| | |<- - - - - - - - - - - - - - - - - - ok - - - - | | | acquire show.lock | | | |---------------------->| | | | alt [hold still ours] mark BOOKED | | | [hold lost] raise -> refund | |<- - - Booking - - -| | |
Interview angle

Drawing a sequence diagram for the main flow is the fastest way to find misplaced responsibilities and to show you've thought about where locks begin and end. In the diagram above, the payment call is visibly outside both lock regions, which is the main point of the booking design.

Go deeper

6. Concurrency for LLD

Almost every LLD problem ends with "now make it thread-safe". The method is always the same: (1) list the shared mutable state, (2) write down the invariants that span it ("a seat is in at most one hold", "free + occupied = total"), (3) protect each invariant with one lock, or remove the sharing (immutability, confinement, queues), (4) check for deadlock and for slow work inside locks.

Race conditions and locks

A race condition happens when the result depends on how threads interleave. The classic case is read-modify-write: two threads read value = 5, both write 6, and one increment is lost. Check-then-act is the other common shape: if seat.free: seat.book() lets two threads both see "free". The fix is to make the whole compound operation a critical section under a mutex. Python docs: threading

import threading


class Counter:
    def __init__(self) -> None:
        self.value = 0
        self._lock = threading.Lock()

    def unsafe_incr(self) -> None:
        v = self.value          # read
        v += 1                  # modify
        self.value = v          # write: another thread may have written in between

    def safe_incr(self) -> None:
        with self._lock:        # read-modify-write is now one critical section
            self.value += 1


c = Counter()
threads = [threading.Thread(target=lambda: [c.safe_incr() for _ in range(10_000)]) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
assert c.value == 80_000

RLock (re-entrant lock)

A plain Lock deadlocks if the thread holding it tries to take it again. An RLock counts acquisitions by the owning thread. It's useful when a locked public method calls another locked public method. It's often a sign you should split out a private, unlocked helper, but it's the pragmatic answer in an interview.

import threading


class Account:
    def __init__(self, balance: int) -> None:
        self.balance = balance
        self._lock = threading.RLock()     # re-entrant: the same thread may acquire it again

    def withdraw(self, amt: int) -> None:
        with self._lock:
            if amt > self.balance:
                raise ValueError("insufficient funds")
            self.balance -= amt

    def withdraw_with_fee(self, amt: int, fee: int) -> None:
        with self._lock:                   # holds the lock...
            self.withdraw(amt)             # ...and calls a method that takes it again
            self.withdraw(fee)             # with a plain Lock this would deadlock


a = Account(100)
a.withdraw_with_fee(50, 1)
assert a.balance == 49

Condition variables and producer–consumer

A condition variable lets threads sleep until some predicate on shared state becomes true, releasing the lock while they wait. Two rules: always wait in a while loop that re-checks the predicate (spurious wakeups, and another thread may get there first), and notify after changing the state the predicate depends on.

import threading
from collections import deque
from typing import Generic, TypeVar

T = TypeVar("T")


class BoundedBuffer(Generic[T]):
    """Classic producer-consumer with one lock and two condition variables."""

    def __init__(self, capacity: int) -> None:
        self._items: deque[T] = deque()
        self._cap = capacity
        lock = threading.Lock()
        self._not_full = threading.Condition(lock)
        self._not_empty = threading.Condition(lock)

    def put(self, item: T) -> None:
        with self._not_full:
            while len(self._items) >= self._cap:     # always re-check in a while loop
                self._not_full.wait()
            self._items.append(item)
            self._not_empty.notify()

    def get(self) -> T:
        with self._not_empty:
            while not self._items:
                self._not_empty.wait()
            item = self._items.popleft()
            self._not_full.notify()
            return item


buf: BoundedBuffer[int] = BoundedBuffer(2)
out: list[int] = []
consumer = threading.Thread(target=lambda: [out.append(buf.get()) for _ in range(5)])
consumer.start()
for i in range(5):
    buf.put(i)
consumer.join()
assert out == [0, 1, 2, 3, 4]

In real Python code you'd use queue.Queue, which is exactly this bounded buffer, already thread-safe. A bounded queue gives you back-pressure: producers block when consumers fall behind instead of using unbounded memory. Python docs: queue

import queue
import threading

SENTINEL = object()


def worker(q: "queue.Queue[object]", results: list[int], lock: threading.Lock) -> None:
    while True:
        job = q.get()
        try:
            if job is SENTINEL:
                return
            with lock:
                results.append(job * job)      # list.append is atomic in CPython, but don't rely on it
        finally:
            q.task_done()


q: "queue.Queue[object]" = queue.Queue(maxsize=100)   # bounded = back-pressure on producers
results: list[int] = []
lock = threading.Lock()
pool = [threading.Thread(target=worker, args=(q, results, lock)) for _ in range(4)]
for t in pool: t.start()
for n in range(20): q.put(n)
for _ in pool: q.put(SENTINEL)                       # one poison pill per worker
q.join()
for t in pool: t.join()
assert sorted(results) == [n * n for n in range(20)]

Semaphores

A semaphore is a counter of permits: acquire takes one (blocking at zero), release returns one. It's the tool for "at most N concurrent X": connection pools, concurrent LLM calls per provider, parking-lot gate capacity. BoundedSemaphore raises if you release more than you acquired, which catches bugs.

import threading
import time


class ConnectionPool:
    def __init__(self, size: int) -> None:
        self._sem = threading.BoundedSemaphore(size)   # at most `size` concurrent holders
        self.max_seen = 0
        self._active = 0
        self._lock = threading.Lock()

    def query(self) -> None:
        with self._sem:
            with self._lock:
                self._active += 1
                self.max_seen = max(self.max_seen, self._active)
            time.sleep(0.01)
            with self._lock:
                self._active -= 1


pool = ConnectionPool(3)
ts = [threading.Thread(target=pool.query) for _ in range(10)]
for t in ts: t.start()
for t in ts: t.join()
assert pool.max_seen <= 3

Read-write locks

When reads vastly outnumber writes (a config cache, a catalogue), a read-write lock lets many readers in at once but gives writers exclusive access. Python's standard library doesn't include one; here's a writer-preferring version built on a condition variable. Java has ReentrantReadWriteLock. Java docs

import threading
from contextlib import contextmanager
from collections.abc import Iterator


class RWLock:
    """Many readers OR one writer. Writer-preferring so writers don't starve."""

    def __init__(self) -> None:
        self._cond = threading.Condition()
        self._readers = 0
        self._writer = False
        self._waiting_writers = 0

    @contextmanager
    def read(self) -> Iterator[None]:
        with self._cond:
            while self._writer or self._waiting_writers:
                self._cond.wait()
            self._readers += 1
        try:
            yield
        finally:
            with self._cond:
                self._readers -= 1
                if self._readers == 0:
                    self._cond.notify_all()

    @contextmanager
    def write(self) -> Iterator[None]:
        with self._cond:
            self._waiting_writers += 1
            while self._writer or self._readers:
                self._cond.wait()
            self._waiting_writers -= 1
            self._writer = True
        try:
            yield
        finally:
            with self._cond:
                self._writer = False
                self._cond.notify_all()


class Catalog:
    def __init__(self) -> None:
        self._data: dict[str, int] = {}
        self._rw = RWLock()

    def get(self, k: str) -> int | None:
        with self._rw.read():
            return self._data.get(k)

    def put(self, k: str, v: int) -> None:
        with self._rw.write():
            self._data[k] = v


cat = Catalog()
cat.put("x", 1)
assert cat.get("x") == 1

Starvation: a reader-preferring lock can starve writers under constant reads. Writer preference (above) fixes that but can starve readers under constant writes. Say which you chose and why.

Atomic operations and thread-safe collections

Deadlock

Deadlock needs all four Coffman conditions at once: mutual exclusion (resources can't be shared), hold and wait (holding one lock while waiting for another), no preemption (locks can't be taken away), and circular wait (A waits for B, B waits for A). Wikipedia: Deadlock Break any one to prevent it. In practice:

import threading
from dataclasses import dataclass, field


@dataclass
class Account:
    id: int
    balance: int
    lock: threading.Lock = field(default_factory=threading.Lock, repr=False)


def transfer(a: Account, b: Account, amt: int) -> None:
    # Always lock in a global order (by id) so two opposite transfers can't deadlock.
    first, second = (a, b) if a.id < b.id else (b, a)
    with first.lock, second.lock:
        if a.balance < amt:
            raise ValueError("insufficient funds")
        a.balance -= amt
        b.balance += amt


x, y = Account(1, 1000), Account(2, 1000)
ts = [threading.Thread(target=transfer, args=(x, y, 1)) for _ in range(200)]
ts += [threading.Thread(target=transfer, args=(y, x, 1)) for _ in range(200)]
for t in ts: t.start()
for t in ts: t.join()
assert x.balance + y.balance == 2000

Optimistic vs pessimistic locking

Pessimistic

Lock first, then read and modify (SELECT … FOR UPDATE, a mutex). Simple and correct; best when conflicts are frequent or retries are expensive. Costs throughput, and locks held across slow operations hurt everyone.

Optimistic

Read with a version number, compute, then write only if the version hasn't changed (compare-and-set: UPDATE … WHERE id=? AND version=?). If it has, retry. Best when conflicts are rare. Under heavy contention, retries waste work. Fowler: Optimistic Offline Lock

import threading
from dataclasses import dataclass


class ConcurrentModification(Exception):
    pass


@dataclass
class Row:
    value: int
    version: int


class VersionedStore:
    """Simulates `UPDATE t SET value=?, version=version+1 WHERE id=? AND version=?`."""

    def __init__(self) -> None:
        self._rows: dict[str, Row] = {}
        self._lock = threading.Lock()          # stands in for the DB's atomic row update

    def read(self, key: str) -> Row:
        with self._lock:
            r = self._rows.setdefault(key, Row(0, 0))
            return Row(r.value, r.version)     # snapshot copy

    def compare_and_set(self, key: str, expected_version: int, new_value: int) -> None:
        with self._lock:
            r = self._rows[key]
            if r.version != expected_version:
                raise ConcurrentModification(key)
            r.value, r.version = new_value, r.version + 1


def increment_with_retry(store: VersionedStore, key: str, retries: int = 50) -> None:
    for _ in range(retries):
        snap = store.read(key)
        try:
            store.compare_and_set(key, snap.version, snap.value + 1)
            return
        except ConcurrentModification:
            continue                           # someone else won; re-read and retry
    raise RuntimeError("too much contention")


s = VersionedStore()
ts = [threading.Thread(target=increment_with_retry, args=(s, "k", 1000)) for _ in range(20)]
for t in ts: t.start()
for t in ts: t.join()
assert s.read("k").value == 20

Idempotency

Networks fail after the server has done the work but before the client sees the response, so clients retry. An operation is idempotent if doing it twice has the same effect as doing it once. For non-idempotent operations (charge a card, create a booking), the client sends an idempotency key; the server stores the result under that key and returns the stored result on a replay. Stripe's API is the well-known public example. Stripe docs

import threading
import uuid


class PaymentService:
    def __init__(self) -> None:
        self._results: dict[str, str] = {}
        self._lock = threading.Lock()
        self.charges = 0

    def charge(self, idempotency_key: str, user: str, cents: int) -> str:
        with self._lock:                       # in production: unique constraint on the key
            if idempotency_key in self._results:
                return self._results[idempotency_key]   # replay returns the original result
            self.charges += 1
            receipt = f"rcpt-{uuid.uuid4().hex[:8]}"
            self._results[idempotency_key] = receipt
            return receipt


svc = PaymentService()
key = "order-42-attempt"
assert svc.charge(key, "u", 500) == svc.charge(key, "u", 500)   # client retry is safe
assert svc.charges == 1

Python's GIL, threads, processes and asyncio

In the standard CPython build, the Global Interpreter Lock lets only one thread execute Python bytecode at a time. Threads still help with I/O-bound work (the GIL is released while waiting on sockets and files) but not with CPU-bound work, where you'd use multiprocessing or concurrent.futures.ProcessPoolExecutor. Python docs: concurrent.futures The GIL does not make your code thread-safe: a thread can be switched out between any two bytecodes, so compound operations still race.

PEP 703 introduced an optional free-threaded build without the GIL, available from Python 3.13 as a separate build. PEP 703 Python docs: free threading On it, data races that the GIL used to hide become much more likely, which is another reason to lock properly.

May be out of date

The free-threaded build's status (experimental vs supported, default or not) has been changing from release to release. Check the current Python docs before making claims about it in an interview.

asyncio is single-threaded cooperative concurrency: coroutines yield control only at await. That removes most data races (no preemption between awaits), but you still need asyncio.Lock when a critical section contains an await. asyncio primitives are not thread-safe, and a blocking call inside a coroutine stalls the whole event loop. Python docs: asyncio

import asyncio


async def fetch(i: int, sem: asyncio.Semaphore) -> int:
    async with sem:                     # cap concurrency, e.g. per-provider connection limit
        await asyncio.sleep(0.01)       # I/O wait yields to the event loop
        return i * 2


async def main() -> list[int]:
    sem = asyncio.Semaphore(5)
    lock = asyncio.Lock()               # asyncio primitives are NOT thread-safe; use within one loop
    total = 0

    async def add(x: int) -> None:
        nonlocal total
        async with lock:                # needed only if there is an await inside the critical section
            total += x

    results = await asyncio.gather(*(fetch(i, sem) for i in range(20)))
    await asyncio.gather(*(add(r) for r in results))
    assert total == sum(results)
    return results


assert asyncio.run(main())[:3] == [0, 2, 4]
ThreadsasyncioProcesses
Best forBlocking I/O, existing sync librariesMany concurrent network calls (LLM APIs, HTTP fan-out)CPU-bound work
SwitchingPreemptive (anywhere)Cooperative (only at await)OS processes, separate memory
Shared-state riskHigh: lock compound opsLow: lock only across awaitsNone by default; share via queues/pipes
In an LLD roundThe default when asked "thread-safe"Natural for LLM clients and agent loopsRarely needed

Java equivalents, briefly

ConceptPythonJava
Mutexthreading.Locksynchronized (intrinsic lock), ReentrantLock
Re-entrantRLockBoth synchronized and ReentrantLock are re-entrant
Conditionthreading.Conditionwait/notifyAll on a monitor, or lock.newCondition()
Semaphorethreading.Semaphorejava.util.concurrent.Semaphore
RW lock(roll your own)ReentrantReadWriteLock, StampedLock
Atomic counterlock + intAtomicInteger, LongAdder
Concurrent maplock + dictConcurrentHashMap
Blocking queuequeue.QueueArrayBlockingQueue, LinkedBlockingQueue
Thread poolThreadPoolExecutorExecutorService (Executors.newFixedThreadPool)
Visibility(GIL / locks)volatile, happens-before via locks
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public class C1Concurrency {
    // 1. synchronized method: intrinsic lock on `this`
    static class TokenBucket {
        private final double capacity, ratePerSec;
        private double tokens;
        private long lastNanos = System.nanoTime();

        TokenBucket(double capacity, double ratePerSec) {
            this.capacity = capacity; this.ratePerSec = ratePerSec; this.tokens = capacity;
        }

        synchronized boolean tryAcquire() {
            long now = System.nanoTime();
            tokens = Math.min(capacity, tokens + (now - lastNanos) / 1e9 * ratePerSec);
            lastNanos = now;
            if (tokens >= 1) { tokens -= 1; return true; }
            return false;
        }
    }

    // 2. ReentrantLock: explicit lock, tryLock with timeout, always unlock in finally
    static class Seat {
        private final ReentrantLock lock = new ReentrantLock();
        private String heldBy;

        boolean hold(String user) throws InterruptedException {
            if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return false;
            try {
                if (heldBy != null) return false;
                heldBy = user;
                return true;
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // 3. ConcurrentHashMap + AtomicInteger: per-key counters without a global lock
        Map<String, AtomicInteger> hits = new ConcurrentHashMap<>();
        // 4. ExecutorService instead of raw threads
        ExecutorService pool = Executors.newFixedThreadPool(8);
        Seat seat = new Seat();
        AtomicInteger winners = new AtomicInteger();
        TokenBucket bucket = new TokenBucket(5, 0);
        AtomicInteger allowed = new AtomicInteger();
        for (int i = 0; i < 100; i++) {
            final String user = "u" + i;
            pool.submit(() -> {
                hits.computeIfAbsent("/api", k -> new AtomicInteger()).incrementAndGet();
                if (bucket.tryAcquire()) allowed.incrementAndGet();
                try { if (seat.hold(user)) winners.incrementAndGet(); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
        if (hits.get("/api").get() != 100 || winners.get() != 1 || allowed.get() != 5)
            throw new AssertionError();
        System.out.println("ok");
    }
}

One Java-specific point interviewers like: without volatile or a lock, one thread's write may never become visible to another (the Java Memory Model's happens-before rules). Locks give you both mutual exclusion and visibility. Oracle Java tutorial: Concurrency

Interview angle

"How would you make this thread-safe?" A strong answer goes: "The shared state is X and Y; the invariant is Z. I'll guard it with one lock per [lot / show / key] because operations on different ones don't interact. Nothing slow happens inside the lock. There's only one lock per operation, so no deadlock. If contention became a problem I'd stripe the locks or move to optimistic CAS with versions."

Common mistake

Locking each method separately but not the compound operation. if cache.contains(k): return cache.get(k) with two individually-locked calls still races, because the entry can be evicted between them. The lock must cover the whole check-then-act.

Go deeper

7. Worked problems

Each problem follows the same outline: requirements and clarifying questions, entities, a class diagram, the core code (runnable, with its own small test at the bottom), patterns used, concurrency, the extensions interviewers ask for, and common mistakes. The first four (parking lot, LRU, rate limiter, booking) get the most depth because they come up most often and contain the most traps.

7.1 Parking lot

Prompt: "Design a parking lot system."

Clarifying questions

  • Vehicle types? (motorcycle, car, truck; EV later?)
  • Spot sizes, and can a small vehicle use a bigger spot?
  • Multiple floors? Multiple entry/exit gates (concurrency)?
  • Pricing: hourly, per vehicle type, first hour minimum?
  • Payment in scope? Display boards of free spots?
  • Lost tickets, duplicate entries, overstay?

Agreed scope (typical)

  • park(vehicle) → Ticket, choosing the smallest fitting spot on the lowest floor
  • unpark(ticket_id) → fee
  • Free-spot counts per size
  • Several gates calling concurrently
  • Out: payment processing, reservations, persistence

Entities: ParkingLot (facade), Spot, Vehicle (value object), Ticket (value object), enums VehicleType and SpotSize, strategies SpotAllocationStrategy and PricingStrategy, and an injected Clock. Floor and Gate are often drawn too; here the floor is an attribute of the spot, which is enough for allocation.

ParkingLot - _free: dict[SpotSize, dict[id, Spot]] - _active: dict[int, Ticket] - _lock: Lock + park(v: Vehicle): Ticket + unpark(ticket_id: int): int Spot id, floor: int size: SpotSize vehicle: Vehicle | None Vehicle «value» plate, type: VehicleType Ticket «value» id, plate, spot_id entered_at «interface» SpotAllocationStrategy LowestFloorFirst «interface» PricingStrategy HourlyPricing «interface» Clock 1 … * 0..* 0..1
Parking lot: the lot composes its spots and holds active tickets; the variable policies (allocation, pricing) and the clock are injected interfaces. Dashed triangles are realisation.
from __future__ import annotations

import itertools
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from math import ceil


class VehicleType(Enum):
    MOTORCYCLE = 1
    CAR = 2
    TRUCK = 3


class SpotSize(Enum):
    SMALL = 1
    MEDIUM = 2
    LARGE = 3


# Which spot sizes each vehicle may use, smallest first (prefer the tightest fit).
FITS: dict[VehicleType, list[SpotSize]] = {
    VehicleType.MOTORCYCLE: [SpotSize.SMALL, SpotSize.MEDIUM, SpotSize.LARGE],
    VehicleType.CAR: [SpotSize.MEDIUM, SpotSize.LARGE],
    VehicleType.TRUCK: [SpotSize.LARGE],
}


@dataclass(frozen=True)
class Vehicle:
    plate: str
    type: VehicleType


@dataclass
class Spot:
    id: str
    floor: int
    size: SpotSize
    vehicle: Vehicle | None = None

    @property
    def free(self) -> bool:
        return self.vehicle is None


@dataclass(frozen=True)
class Ticket:
    id: int
    plate: str
    spot_id: str
    entered_at: datetime


class NoSpotAvailable(Exception):
    pass


# ---- Strategies -------------------------------------------------------------
class SpotAllocationStrategy(ABC):
    @abstractmethod
    def choose(self, candidates: list[Spot]) -> Spot | None: ...


class LowestFloorFirst(SpotAllocationStrategy):
    def choose(self, candidates: list[Spot]) -> Spot | None:
        return min(candidates, key=lambda s: (s.floor, s.id), default=None)


class PricingStrategy(ABC):
    @abstractmethod
    def fee(self, vehicle_type: VehicleType, duration: timedelta) -> int: ...


class HourlyPricing(PricingStrategy):
    RATES = {VehicleType.MOTORCYCLE: 10, VehicleType.CAR: 20, VehicleType.TRUCK: 40}

    def fee(self, vehicle_type: VehicleType, duration: timedelta) -> int:
        hours = max(1, ceil(duration.total_seconds() / 3600))   # first hour always charged
        return hours * self.RATES[vehicle_type]


class Clock(ABC):
    @abstractmethod
    def now(self) -> datetime: ...


class SystemClock(Clock):
    def now(self) -> datetime:
        return datetime.now()


# ---- The lot -------------------------------------------------------------------
class ParkingLot:
    def __init__(self, spots: list[Spot], allocator: SpotAllocationStrategy,
                 pricing: PricingStrategy, clock: Clock | None = None) -> None:
        # index free spots by size so allocation doesn't scan the whole lot
        self._free: dict[SpotSize, dict[str, Spot]] = {s: {} for s in SpotSize}
        self._spots = {s.id: s for s in spots}
        for s in spots:
            self._free[s.size][s.id] = s
        self._active: dict[int, Ticket] = {}
        self._plates: set[str] = set()
        self._allocator, self._pricing = allocator, pricing
        self._clock = clock or SystemClock()
        self._ids = itertools.count(1)
        self._lock = threading.Lock()          # one lock: simple and correct for one process

    def park(self, v: Vehicle) -> Ticket:
        with self._lock:
            if v.plate in self._plates:
                raise ValueError(f"{v.plate} is already parked")
            for size in FITS[v.type]:
                spot = self._allocator.choose(list(self._free[size].values()))
                if spot:
                    del self._free[size][spot.id]
                    spot.vehicle = v
                    t = Ticket(next(self._ids), v.plate, spot.id, self._clock.now())
                    self._active[t.id] = t
                    self._plates.add(v.plate)
                    return t
            raise NoSpotAvailable(v.type.name)

    def unpark(self, ticket_id: int) -> int:
        with self._lock:
            t = self._active.pop(ticket_id, None)
            if t is None:
                raise KeyError(f"unknown or already-used ticket {ticket_id}")
            spot = self._spots[t.spot_id]
            assert spot.vehicle is not None
            fee = self._pricing.fee(spot.vehicle.type, self._clock.now() - t.entered_at)
            self._plates.discard(spot.vehicle.plate)
            spot.vehicle = None
            self._free[spot.size][spot.id] = spot
            return fee

    def free_count(self, size: SpotSize) -> int:
        with self._lock:
            return len(self._free[size])


class FakeClock(Clock):
    def __init__(self, t: datetime) -> None:
        self.t = t

    def now(self) -> datetime:
        return self.t


clock = FakeClock(datetime(2026, 1, 1, 9, 0))
spots = [Spot("F0-S1", 0, SpotSize.SMALL), Spot("F0-M1", 0, SpotSize.MEDIUM), Spot("F1-L1", 1, SpotSize.LARGE)]
lot = ParkingLot(spots, LowestFloorFirst(), HourlyPricing(), clock)
t1 = lot.park(Vehicle("KA-01", VehicleType.CAR))
assert t1.spot_id == "F0-M1"
t2 = lot.park(Vehicle("KA-02", VehicleType.CAR))      # medium full -> falls back to large
assert t2.spot_id == "F1-L1"
clock.t += timedelta(hours=2, minutes=5)
assert lot.unpark(t1.id) == 60                         # 3 started hours x 20
try:
    lot.park(Vehicle("TR-1", VehicleType.TRUCK))
except NoSpotAvailable:
    pass

Design decisions worth saying out loud:

Patterns: Strategy (allocation, pricing), Facade (ParkingLot), value objects, optional Singleton (resist it), Observer for display boards, Factory for creating spots from a layout config.

Concurrency: several gates call park at once. The invariant spans the free index, the spot and the ticket map, so one lock around each operation is correct and simple. Allocation is pure in-memory work, so the critical section is microseconds long. If asked to scale: one lock per SpotSize (cars and trucks don't contend), but then park may need to try several sizes, and the duplicate-plate set needs its own lock. Take locks in a fixed order (size enum order) to avoid deadlock.

Extensions interviewers ask for:

Common mistake

Deep inheritance (CompactSpot, LargeSpot, HandicappedSpot, ElectricCompactSpot…) and a Vehicle hierarchy where subclasses differ only in a size field. Spot size and features are data, so use enums and fields. Keep subclasses for real behavioural differences.

7.2 LRU cache (and LFU)

Prompt: "Implement an LRU cache with O(1) get and put." This is half data-structures question, half LLD: the interviewer watches for clean encapsulation, generics, edge cases and thread safety as much as the algorithm.

Clarifying questions

  • Capacity by entry count or by bytes?
  • Does get count as a use? (Yes for LRU.) Does put on an existing key?
  • What does a miss return: None, a sentinel, raise?
  • TTL per entry? Concurrent access? Eviction callbacks?
  • Is using the language's ordered map allowed?

The core idea

A hash map gives O(1) lookup but no order. A doubly linked list gives O(1) move-to-front and remove-from-tail, but O(n) lookup. Combine them: the map stores key → node, and the list keeps nodes in recency order. Sentinel head/tail nodes remove every None special case. Each node stores its key so eviction can delete it from the map.

dict "b" → "a" → "c" → head b:2 most recent a:1 c:3 evict next tail get(k): map lookup → unlink node → push after head. put(new) when full: unlink tail.prev, delete its key from the map.
LRU = hash map for O(1) lookup + doubly linked list for O(1) recency updates. Sentinels mean every real node always has a prev and a next.
from __future__ import annotations

import threading
from typing import Generic, Hashable, TypeVar

K = TypeVar("K", bound=Hashable)
V = TypeVar("V")


class _Node(Generic[K, V]):
    __slots__ = ("key", "val", "prev", "next")

    def __init__(self, key: K | None = None, val: V | None = None) -> None:
        self.key, self.val = key, val
        self.prev: _Node[K, V] | None = None
        self.next: _Node[K, V] | None = None


class LRUCache(Generic[K, V]):
    """O(1) get/put: hash map for lookup + doubly linked list for recency.

    head <-> [most recent] <-> ... <-> [least recent] <-> tail   (sentinels)
    """

    def __init__(self, capacity: int) -> None:
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self._cap = capacity
        self._map: dict[K, _Node[K, V]] = {}
        self._head: _Node[K, V] = _Node()
        self._tail: _Node[K, V] = _Node()
        self._head.next, self._tail.prev = self._tail, self._head
        self._lock = threading.Lock()
        self.hits = self.misses = 0

    # -- linked-list helpers (caller holds the lock) --
    def _unlink(self, n: _Node[K, V]) -> None:
        assert n.prev and n.next
        n.prev.next, n.next.prev = n.next, n.prev

    def _push_front(self, n: _Node[K, V]) -> None:
        first = self._head.next
        assert first
        n.prev, n.next = self._head, first
        self._head.next = first.prev = n

    # -- public API --
    def get(self, key: K) -> V | None:
        with self._lock:
            n = self._map.get(key)
            if n is None:
                self.misses += 1
                return None
            self._unlink(n)
            self._push_front(n)            # a read counts as "use"
            self.hits += 1
            return n.val

    def put(self, key: K, val: V) -> None:
        with self._lock:
            n = self._map.get(key)
            if n:
                n.val = val
                self._unlink(n)
                self._push_front(n)
                return
            if len(self._map) >= self._cap:
                lru = self._tail.prev
                assert lru and lru is not self._head
                self._unlink(lru)
                del self._map[lru.key]      # why the node stores its key
            n = _Node(key, val)
            self._map[key] = n
            self._push_front(n)

    def __len__(self) -> int:
        return len(self._map)


c: LRUCache[str, int] = LRUCache(2)
c.put("a", 1); c.put("b", 2)
assert c.get("a") == 1          # a is now most recent
c.put("c", 3)                   # evicts b
assert c.get("b") is None and c.get("c") == 3 and len(c) == 2

If the interviewer allows library structures, OrderedDict gives you the same thing in a few lines: move_to_end and popitem(last=False) are both O(1). Python docs: collections Ask first, and be ready to write the linked-list version. (The standard library's functools.lru_cache memoises function calls; it isn't a general key-value cache object. Python docs: functools)

from collections import OrderedDict
from typing import Hashable


class LRU:
    def __init__(self, cap: int) -> None:
        self.cap, self.d = cap, OrderedDict()

    def get(self, k: Hashable):
        if k not in self.d:
            return None
        self.d.move_to_end(k)            # mark most-recent
        return self.d[k]

    def put(self, k: Hashable, v) -> None:
        self.d[k] = v
        self.d.move_to_end(k)
        if len(self.d) > self.cap:
            self.d.popitem(last=False)   # pop least-recent


c = LRU(1); c.put(1, 1); c.put(2, 2)
assert c.get(1) is None and c.get(2) == 2

In Java, the equivalent shortcut is LinkedHashMap with accessOrder=true and an overridden removeEldestEntry:

import java.util.LinkedHashMap;
import java.util.Map;

// accessOrder=true makes LinkedHashMap move entries to the tail on get();
// overriding removeEldestEntry turns it into an LRU cache.
class LruCache<K, V> {
    private final Map<K, V> map;

    LruCache(int capacity) {
        this.map = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > capacity;
            }
        };
    }

    public synchronized V get(K key) { return map.get(key); }   // get mutates order: needs the lock too
    public synchronized void put(K key, V value) { map.put(key, value); }
}

public class C1Lru {
    public static void main(String[] args) {
        LruCache<String, Integer> c = new LruCache<>(2);
        c.put("a", 1); c.put("b", 2); c.get("a"); c.put("c", 3);
        if (c.get("b") != null || c.get("a") != 1) throw new AssertionError();
        System.out.println("ok");
    }
}

Extension: LFU

Least Frequently Used evicts the key with the lowest access count, breaking ties by least recent. The O(1) design keeps key → value, key → freq, and freq → ordered set of keys, plus a min_freq pointer. On access, move the key from bucket f to f+1 and bump min_freq if bucket f was the minimum and is now empty. On insert, the new key has frequency 1, so min_freq resets to 1. This construction is described in a short paper by Shah, Mitra and Matani. Shah, Mitra, Matani 2010

from collections import OrderedDict, defaultdict
from typing import Hashable


class LFUCache:
    """O(1) LFU with LRU tie-break: key->(val,freq) and freq->OrderedDict of keys."""

    def __init__(self, capacity: int) -> None:
        self.cap = capacity
        self.vals: dict[Hashable, object] = {}
        self.freq: dict[Hashable, int] = {}
        self.buckets: defaultdict[int, OrderedDict[Hashable, None]] = defaultdict(OrderedDict)
        self.min_freq = 0

    def _touch(self, k: Hashable) -> None:
        f = self.freq[k]
        del self.buckets[f][k]
        if not self.buckets[f]:
            del self.buckets[f]
            if self.min_freq == f:
                self.min_freq = f + 1
        self.freq[k] = f + 1
        self.buckets[f + 1][k] = None

    def get(self, k: Hashable):
        if k not in self.vals:
            return None
        self._touch(k)
        return self.vals[k]

    def put(self, k: Hashable, v: object) -> None:
        if self.cap <= 0:
            return
        if k in self.vals:
            self.vals[k] = v
            self._touch(k)
            return
        if len(self.vals) >= self.cap:
            victim, _ = self.buckets[self.min_freq].popitem(last=False)   # least freq, oldest
            if not self.buckets[self.min_freq]:
                del self.buckets[self.min_freq]
            del self.vals[victim], self.freq[victim]
        self.vals[k], self.freq[k] = v, 1
        self.buckets[1][k] = None
        self.min_freq = 1                 # a brand-new key always has the minimum frequency


c = LFUCache(2)
c.put("a", 1); c.put("b", 2)
c.get("a")                 # a: freq 2, b: freq 1
c.put("c", 3)              # evicts b
assert c.get("b") is None and c.get("a") == 1 and c.get("c") == 3

Patterns: Strategy for the eviction policy if asked to support both (EvictionPolicy with on_access, on_insert, victim), Decorator for adding TTL, metrics or write-through behaviour to a cache.

Concurrency: get mutates the list, so a read-write lock doesn't help: every operation is a write. One lock per cache is the standard answer. To scale, shard into N independent LRU caches by hash(key) % N, each with its own lock (approximate global LRU, much less contention).

Extensions: TTL (store expires_at in the node, check lazily on get, optionally sweep), size-in-bytes capacity (evict in a loop until under budget), eviction listeners (Observer), write-through or write-back to a backing store (Decorator plus Repository), distributed cache (consistent hashing, which is HLD territory).

Common mistake

Forgetting to store the key in the node (so eviction can't remove it from the map), not updating recency on put of an existing key, not handling capacity 0 or 1, and calling a read-write lock "optimal" when get modifies the list.

7.3 Rate limiter

Prompt: "Design a rate limiter: each client may make N requests per time window."

Clarifying questions

  • Keyed by what: user, API key, IP, endpoint, or a combination?
  • Allow bursts, or strictly smooth?
  • Exact or approximate counts? Memory budget per key?
  • Single process or shared across servers?
  • What happens on rejection: error (HTTP 429) with a retry-after hint, or queue?
  • Different limits per tier? Weighted requests (cost > 1)?

Algorithms

  • Token bucket: bucket of capacity C refills at R tokens/s; each request takes one. Allows bursts up to C, sustained rate R. Wikipedia
  • Leaky bucket: requests queue and drain at a fixed rate; smooths output.
  • Fixed window counter: count per calendar window; simple, but allows 2N at a window boundary.
  • Sliding window log: keep timestamps; exact; O(N) memory per key.
  • Sliding window counter: weight the previous window's count by its overlap; O(1) memory, approximate. Cloudflare has described using this approach. Cloudflare blog
«interface» RateLimiter + allow(key, cost=1) -> bool ^ (realisation) +-------------+------------------+---------------------------+ TokenBucketLimiter SlidingWindowLogLimiter SlidingWindowCounterLimiter - capacity, rate - limit, window - limit, window - _buckets: key-> - _logs: key->deque - _state: key->(idx, prev, curr) (tokens, updated) - _lock - _lock - _lock - clock (injected) - clock - clock Composed by: RateLimitMiddleware(limiter, key_fn) -- a Chain-of-Responsibility handler
from __future__ import annotations

import threading
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass
from typing import Callable

Clock = Callable[[], float]


class RateLimiter(ABC):
    @abstractmethod
    def allow(self, key: str, cost: float = 1.0) -> bool: ...


# ---------------- Token bucket ----------------
@dataclass
class _Bucket:
    tokens: float
    updated: float


class TokenBucketLimiter(RateLimiter):
    """Capacity = burst size; refill_rate = sustained tokens/second. Lazy refill, no timer thread."""

    def __init__(self, capacity: float, refill_rate: float, clock: Clock = time.monotonic) -> None:
        self.capacity, self.rate, self.clock = capacity, refill_rate, clock
        self._buckets: dict[str, _Bucket] = {}
        self._lock = threading.Lock()

    def allow(self, key: str, cost: float = 1.0) -> bool:
        now = self.clock()
        with self._lock:
            b = self._buckets.get(key)
            if b is None:
                b = self._buckets[key] = _Bucket(self.capacity, now)
            elapsed = max(0.0, now - b.updated)     # clock read outside the lock may be slightly stale
            b.tokens = min(self.capacity, b.tokens + elapsed * self.rate)
            b.updated = max(b.updated, now)
            if b.tokens >= cost:
                b.tokens -= cost
                return True
            return False


# ---------------- Sliding window log ----------------
class SlidingWindowLogLimiter(RateLimiter):
    """Exact: keep a timestamp per accepted request. Memory O(limit) per key."""

    def __init__(self, limit: int, window_s: float, clock: Clock = time.monotonic) -> None:
        self.limit, self.window, self.clock = limit, window_s, clock
        self._logs: dict[str, deque[float]] = {}
        self._lock = threading.Lock()

    def allow(self, key: str, cost: float = 1.0) -> bool:
        now = self.clock()
        with self._lock:
            log = self._logs.setdefault(key, deque())
            while log and log[0] <= now - self.window:
                log.popleft()
            if len(log) + cost <= self.limit:
                log.extend([now] * int(cost))
                return True
            return False


# ---------------- Sliding window counter ----------------
class SlidingWindowCounterLimiter(RateLimiter):
    """Approximate: weight the previous fixed window by how much of it still overlaps. O(1) memory."""

    def __init__(self, limit: int, window_s: float, clock: Clock = time.monotonic) -> None:
        self.limit, self.window, self.clock = limit, window_s, clock
        self._state: dict[str, tuple[int, float, float]] = {}   # key -> (window_idx, prev, curr)
        self._lock = threading.Lock()

    def allow(self, key: str, cost: float = 1.0) -> bool:
        now = self.clock()
        idx = int(now // self.window)
        with self._lock:
            w, prev, curr = self._state.get(key, (idx, 0.0, 0.0))
            if idx == w + 1:
                prev, curr = curr, 0.0
            elif idx > w + 1:
                prev, curr = 0.0, 0.0
            elapsed = (now % self.window) / self.window
            estimated = prev * (1 - elapsed) + curr
            if estimated + cost <= self.limit:
                self._state[key] = (idx, prev, curr + cost)
                return True
            self._state[key] = (idx, prev, curr)
            return False


# ---------------- tests with a fake clock ----------------
class FakeClock:
    def __init__(self) -> None:
        self.t = 1000.0

    def __call__(self) -> float:
        return self.t


clk = FakeClock()
tb = TokenBucketLimiter(capacity=3, refill_rate=1.0, clock=clk)
assert [tb.allow("u") for _ in range(4)] == [True, True, True, False]   # burst of 3
clk.t += 1.0
assert tb.allow("u") and not tb.allow("u")                                # 1 token refilled
assert tb.allow("other")                                                  # per-key isolation

clk2 = FakeClock()
sw = SlidingWindowLogLimiter(limit=2, window_s=10, clock=clk2)
assert sw.allow("u") and sw.allow("u") and not sw.allow("u")
clk2.t += 10
assert sw.allow("u")

clk3 = FakeClock()
sc = SlidingWindowCounterLimiter(limit=10, window_s=60, clock=clk3)
assert sum(sc.allow("u") for _ in range(15)) == 10

# thread-safety smoke test: 50 threads x 10 calls against capacity 100 -> exactly 100 allowed
frozen = FakeClock()
tb2 = TokenBucketLimiter(capacity=100, refill_rate=0.0, clock=frozen)
allowed: list[bool] = []
lk = threading.Lock()


def hammer() -> None:
    for _ in range(10):
        r = tb2.allow("k")
        with lk:
            allowed.append(r)


ts = [threading.Thread(target=hammer) for _ in range(50)]
for t in ts: t.start()
for t in ts: t.join()
assert sum(allowed) == 100

Design decisions worth saying out loud:

AlgorithmMemory per keyBurstsAccuracyPick when
Token bucketO(1)Allowed up to capacityExact for its modelAPIs that tolerate short bursts; the usual default
Leaky bucket (queue)O(queue)SmoothedExactProtecting a downstream that needs a steady rate
Fixed windowO(1)Up to 2N at boundariesCoarseSimple quotas (daily limits)
Sliding logO(N)None beyond N per windowExactLow limits, strict fairness
Sliding counterO(1)Small overshoot possibleApproximateHigh volume, many keys

Patterns: Strategy (algorithm), Chain of Responsibility or Decorator (middleware around a handler or client), Factory (limiter per tier from config).

Concurrency: refill-then-consume is a read-modify-write, so it must be atomic per key. One global lock is fine for an interview; per-key locks (a dict of locks guarded by a lock, or striping) reduce contention. The code reads the clock outside the lock to keep the critical section short. Two threads can then apply their timestamps out of order, so it clamps negative elapsed time to zero and never moves updated backwards.

Extensions:

Common mistake

Using time.time() (wall clock) for intervals, refilling with a background thread per key, forgetting to cap tokens at capacity, and not locking the refill-and-consume sequence. Another subtle one: in a composite limiter, consuming from the first limiter before checking the second.

7.4 Elevator system

Prompt: "Design an elevator system for a building with N floors and M elevators."

Clarifying questions

  • Hall calls (up/down buttons on floors) vs car calls (floor buttons inside)?
  • What is the dispatch goal: minimum wait, minimum travel, energy?
  • Capacity/weight limits, maintenance mode, emergency stop?
  • Is this a simulation with discrete ticks, or real-time?

Key insight

Separate the two decisions: which car answers a hall call (dispatch strategy, controller level) and in what order a car visits its stops (per-car scheduling, usually SCAN/LOOK: keep going in the current direction while there are stops ahead, then reverse). Wikipedia: Elevator algorithm

ElevatorController ◆----- 1..* Elevator + hall_call(floor, dir) - floor, direction: Direction + car_call(car_id, floor) - stops: set[int] + tick() + add_stop(floor) / step() | +----> «interface» DispatchStrategy <|.. NearestCar, ZoneBased, RoundRobin + pick(cars, HallCall) -> Elevator Enums: Direction{UP, DOWN, IDLE} Value: HallCall(floor, direction) Optional State: Idle / MovingUp / MovingDown / DoorsOpen / Maintenance
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum


class Direction(Enum):
    UP = 1
    DOWN = -1
    IDLE = 0


@dataclass
class Elevator:
    id: int
    floor: int = 0
    direction: Direction = Direction.IDLE
    stops: set[int] = field(default_factory=set)

    def add_stop(self, floor: int) -> None:
        if floor != self.floor:
            self.stops.add(floor)
        if self.direction is Direction.IDLE and self.stops:
            self.direction = Direction.UP if floor > self.floor else Direction.DOWN

    def step(self) -> None:
        """Advance one floor using SCAN/LOOK: keep going while stops remain ahead, else reverse."""
        if not self.stops:
            self.direction = Direction.IDLE
            return
        ahead = [s for s in self.stops if (s - self.floor) * self.direction.value > 0]
        if not ahead:
            self.direction = Direction.DOWN if self.direction is Direction.UP else Direction.UP
        self.floor += self.direction.value
        self.stops.discard(self.floor)          # open doors here
        if not self.stops:
            self.direction = Direction.IDLE


@dataclass(frozen=True)
class HallCall:
    floor: int
    direction: Direction


class DispatchStrategy(ABC):
    @abstractmethod
    def pick(self, elevators: list[Elevator], call: HallCall) -> Elevator: ...


class NearestCar(DispatchStrategy):
    """Prefer an idle car or one already moving toward the call in the same direction."""

    def pick(self, elevators: list[Elevator], call: HallCall) -> Elevator:
        def cost(e: Elevator) -> tuple[int, int]:
            dist = abs(e.floor - call.floor)
            if e.direction is Direction.IDLE:
                return (0, dist)
            moving_toward = (call.floor - e.floor) * e.direction.value >= 0
            if moving_toward and e.direction is call.direction:
                return (0, dist)
            return (1, dist + len(e.stops))     # penalise cars that must turn around first
        return min(elevators, key=cost)


class ElevatorController:
    def __init__(self, n: int, strategy: DispatchStrategy) -> None:
        self.cars = [Elevator(i) for i in range(n)]
        self.strategy = strategy

    def hall_call(self, floor: int, direction: Direction) -> int:
        car = self.strategy.pick(self.cars, HallCall(floor, direction))
        car.add_stop(floor)
        return car.id

    def car_call(self, car_id: int, floor: int) -> None:    # button pressed inside the car
        self.cars[car_id].add_stop(floor)

    def tick(self) -> None:
        for c in self.cars:
            c.step()


ctl = ElevatorController(2, NearestCar())
ctl.cars[1].floor = 9
assert ctl.hall_call(2, Direction.UP) == 0      # car 0 at floor 0 is nearest
ctl.car_call(0, 5)
for _ in range(5):
    ctl.tick()
assert ctl.cars[0].floor == 5 and ctl.cars[0].direction is Direction.IDLE

Scheduling strategies to discuss: FCFS (simple, terrible travel), SSTF / nearest (good average, can starve far floors), SCAN/LOOK (no starvation, the standard answer), and zoning (cars assigned to floor bands in tall buildings). Destination dispatch (passengers enter their target floor in the lobby) lets the controller group riders. Mention it as an extension.

Patterns: Strategy (dispatch), State (car states, especially door and maintenance handling), Observer (floor displays, the controller listening to car arrivals), Command (requests as objects in a queue).

Concurrency: in real time, button presses arrive on many threads while each car runs its own loop. Give each car a thread-safe request inbox (a queue.Queue or a lock around stops), and have the controller read car positions as snapshots. Dispatch decisions can be slightly stale; that's acceptable.

Extensions: capacity (skip hall calls when full), maintenance mode (State; dispatcher excludes the car), VIP/fire-service override (a priority command that clears stops), energy-saving idle parking (strategy picks a home floor).

Common mistake

Putting all the logic in one Elevator.move() method with nested ifs on direction, door state and requests. Separate dispatch from per-car scheduling, and make direction and state explicit enums or State classes.

7.5 Movie ticket booking (BookMyShow)

Prompt: "Design a movie ticket booking system." The interviewer is mostly interested in one thing: two users must never get the same seat, and seats must not stay blocked forever when users abandon checkout.

Clarifying questions

  • Browse: city → movie → theatre → show → seat map? (Usually only briefly.)
  • Can a user book several seats atomically? (Yes: all or nothing.)
  • Hold seats during payment? For how long?
  • Seat categories and pricing? Cancellation and refunds?
  • Single server in memory, or a database shared by many servers?

Entities

  • Movie, Theatre, Screen, Seat (physical, static)
  • Show (movie on a screen at a time) with ShowSeat (seat × show: state, price)
  • Hold (temporary lock: seats, user, expiry)
  • Booking, Payment, User
  • BookingService (facade), PaymentGateway (interface)
AVAILABLE HELD hold_id, expires_at BOOKED hold() under show lock confirm() after payment release() / TTL expiry (lazy) cancel booking (refund policy)
Each show-seat is a small state machine. The hold with a TTL is what stops abandoned checkouts from blocking seats; the lock is what stops double booking.
from __future__ import annotations

import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Callable


class SeatState(Enum):
    AVAILABLE = "available"
    HELD = "held"
    BOOKED = "booked"


@dataclass
class ShowSeat:
    seat_id: str
    price: int
    state: SeatState = SeatState.AVAILABLE
    hold_id: str | None = None
    hold_expires: datetime | None = None


@dataclass(frozen=True)
class Hold:
    id: str
    show_id: str
    user_id: str
    seat_ids: tuple[str, ...]
    expires_at: datetime
    amount: int


class BookingStatus(Enum):
    CONFIRMED = "confirmed"
    CANCELLED = "cancelled"


@dataclass
class Booking:
    id: str
    show_id: str
    user_id: str
    seat_ids: tuple[str, ...]
    amount: int
    status: BookingStatus = BookingStatus.CONFIRMED


class SeatsUnavailable(Exception):
    pass


class HoldExpired(Exception):
    pass


class Show:
    """One screening. Owns its seat map and a lock: shows never contend with each other."""

    def __init__(self, show_id: str, seats: list[ShowSeat]) -> None:
        self.id = show_id
        self.seats = {s.seat_id: s for s in seats}
        self.lock = threading.Lock()


class PaymentGateway:
    def charge(self, user_id: str, amount: int, idempotency_key: str) -> bool:
        return True


class BookingService:
    HOLD_TTL = timedelta(minutes=10)

    def __init__(self, payments: PaymentGateway, now: Callable[[], datetime] = datetime.now) -> None:
        self._shows: dict[str, Show] = {}
        self._holds: dict[str, Hold] = {}
        self._bookings: dict[str, Booking] = {}
        self._payments, self._now = payments, now

    def add_show(self, show: Show) -> None:
        self._shows[show.id] = show

    def _release_if_expired(self, seat: ShowSeat, now: datetime) -> None:
        if seat.state is SeatState.HELD and seat.hold_expires and seat.hold_expires <= now:
            seat.state, seat.hold_id, seat.hold_expires = SeatState.AVAILABLE, None, None

    def hold(self, show_id: str, user_id: str, seat_ids: list[str]) -> Hold:
        show = self._shows[show_id]
        now = self._now()
        with show.lock:                                 # all-or-nothing across the requested seats
            seats = [show.seats[s] for s in seat_ids]
            for s in seats:
                self._release_if_expired(s, now)        # lazy expiry: no sweeper thread required
            taken = [s.seat_id for s in seats if s.state is not SeatState.AVAILABLE]
            if taken:
                raise SeatsUnavailable(taken)
            hold = Hold(uuid.uuid4().hex, show_id, user_id, tuple(seat_ids),
                        now + self.HOLD_TTL, sum(s.price for s in seats))
            for s in seats:
                s.state, s.hold_id, s.hold_expires = SeatState.HELD, hold.id, hold.expires_at
            self._holds[hold.id] = hold
            return hold

    def confirm(self, hold_id: str) -> Booking:
        hold = self._holds[hold_id]
        show = self._shows[hold.show_id]
        # Pay OUTSIDE the lock: never hold a lock across a slow network call.
        if self._now() >= hold.expires_at:
            raise HoldExpired(hold_id)
        if not self._payments.charge(hold.user_id, hold.amount, idempotency_key=hold_id):
            self.release(hold_id)
            raise RuntimeError("payment declined")
        with show.lock:
            seats = [show.seats[s] for s in hold.seat_ids]
            if any(s.hold_id != hold_id for s in seats):    # expired and re-taken meanwhile
                raise HoldExpired(f"{hold_id}: refund required")
            for s in seats:
                s.state, s.hold_id, s.hold_expires = SeatState.BOOKED, None, None
            booking = Booking(uuid.uuid4().hex, show.id, hold.user_id, hold.seat_ids, hold.amount)
            self._bookings[booking.id] = booking
            del self._holds[hold_id]
            return booking

    def release(self, hold_id: str) -> None:
        hold = self._holds.pop(hold_id, None)
        if not hold:
            return
        show = self._shows[hold.show_id]
        with show.lock:
            for sid in hold.seat_ids:
                s = show.seats[sid]
                if s.hold_id == hold_id:
                    s.state, s.hold_id, s.hold_expires = SeatState.AVAILABLE, None, None


# --- race test: 20 users fight for the same 2 seats; exactly one wins ---
svc = BookingService(PaymentGateway())
svc.add_show(Show("s1", [ShowSeat(f"A{i}", 250) for i in range(1, 11)]))
wins: list[str] = []
lk = threading.Lock()


def attempt(user: str) -> None:
    try:
        h = svc.hold("s1", user, ["A1", "A2"])
        svc.confirm(h.id)
        with lk:
            wins.append(user)
    except SeatsUnavailable:
        pass


ts = [threading.Thread(target=attempt, args=(f"u{i}",)) for i in range(20)]
for t in ts: t.start()
for t in ts: t.join()
assert len(wins) == 1

# --- expiry test with a controllable clock ---
now = [datetime(2026, 1, 1, 18, 0)]
svc2 = BookingService(PaymentGateway(), now=lambda: now[0])
svc2.add_show(Show("s2", [ShowSeat("B1", 300)]))
svc2.hold("s2", "alice", ["B1"])
now[0] += timedelta(minutes=11)
h_bob = svc2.hold("s2", "bob", ["B1"])          # alice's hold lapsed lazily
assert svc2.confirm(h_bob.id).user_id == "bob"

Design decisions worth saying out loud:

With a relational database shared by many app servers, the same logic becomes either a pessimistic transaction or a single conditional update:

# The same idea against a relational DB (pseudo-code, psycopg-style placeholders).
# Pessimistic: lock the rows, then check and update inside one transaction.
HOLD_PESSIMISTIC = """
BEGIN;
SELECT seat_id, state FROM show_seats
 WHERE show_id = %(show)s AND seat_id = ANY(%(seats)s)
 FOR UPDATE;                         -- row locks until COMMIT
-- application checks every row is AVAILABLE (or its hold has expired), else ROLLBACK
UPDATE show_seats SET state = 'HELD', hold_id = %(hold)s, hold_expires = now() + interval '10 minutes'
 WHERE show_id = %(show)s AND seat_id = ANY(%(seats)s);
COMMIT;
"""

# Optimistic / conditional: one atomic statement, check the affected row count.
HOLD_CONDITIONAL = """
UPDATE show_seats SET state = 'HELD', hold_id = %(hold)s, hold_expires = now() + interval '10 minutes'
 WHERE show_id = %(show)s AND seat_id = ANY(%(seats)s)
   AND (state = 'AVAILABLE' OR (state = 'HELD' AND hold_expires < now()));
-- if rowcount != len(seats): ROLLBACK (someone else got at least one seat)
"""

Patterns: Facade (BookingService), State (seat lifecycle, as an enum here), Strategy (pricing by category/time, payment method), Observer (send the confirmation email/SMS on booking.confirmed), Repository (shows, bookings).

Extensions: dynamic pricing (Strategy), coupons (Decorator over pricing or Chain of discount rules), waitlist when sold out (queue + Observer on release), seat-adjacency suggestions ("best 4 together"), cancellation with refund policy (Strategy by time-before-show), and a distributed version (DB conditional updates or a Redis SET key value NX PX ttl-style hold, idempotent payment webhooks).

Common mistake

Holding the lock while calling the payment gateway (one slow payment blocks every user of that show), check-then-act without a lock (if seat.available: seat.book()), per-seat locks taken in request order (deadlock between two multi-seat requests), and no expiry on holds.

Interview angle

"What if the payment succeeds but the hold already expired and someone else booked the seat?" The strong answer is the re-validate-then-refund path shown above, plus how to reduce how often it happens: TTL comfortably longer than typical payment time, extend the hold when the user enters payment, and pass an idempotency key to the gateway.

7.6 Splitwise / expense sharing

Prompt: "Design an app where friends record shared expenses and see who owes whom."

from __future__ import annotations

import heapq
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass


@dataclass(frozen=True)
class User:
    id: str
    name: str


class SplitStrategy(ABC):
    @abstractmethod
    def shares(self, amount: int, participants: list[str]) -> dict[str, int]: ...


class EqualSplit(SplitStrategy):
    def shares(self, amount: int, participants: list[str]) -> dict[str, int]:
        base, rem = divmod(amount, len(participants))          # integer paise/cents, no floats
        return {u: base + (1 if i < rem else 0) for i, u in enumerate(participants)}


class ExactSplit(SplitStrategy):
    def __init__(self, amounts: dict[str, int]) -> None:
        self.amounts = amounts

    def shares(self, amount: int, participants: list[str]) -> dict[str, int]:
        if sum(self.amounts.values()) != amount:
            raise ValueError("exact amounts must add up to the total")
        return dict(self.amounts)


class PercentSplit(SplitStrategy):
    def __init__(self, pct: dict[str, float]) -> None:
        if abs(sum(pct.values()) - 100) > 1e-9:
            raise ValueError("percentages must add up to 100")
        self.pct = pct

    def shares(self, amount: int, participants: list[str]) -> dict[str, int]:
        raw = {u: amount * p / 100 for u, p in self.pct.items()}
        out = {u: int(v) for u, v in raw.items()}
        leftover = amount - sum(out.values())
        for u in sorted(raw, key=lambda u: raw[u] - out[u], reverse=True)[:leftover]:
            out[u] += 1                                          # largest-remainder rounding
        return out


@dataclass(frozen=True)
class Expense:
    id: str
    paid_by: str
    amount: int
    shares: dict[str, int]


class ExpenseManager:
    def __init__(self) -> None:
        self.expenses: list[Expense] = []
        self.net: defaultdict[str, int] = defaultdict(int)       # +ve: is owed, -ve: owes

    def add(self, eid: str, paid_by: str, amount: int, participants: list[str],
            strategy: SplitStrategy) -> Expense:
        shares = strategy.shares(amount, participants)
        e = Expense(eid, paid_by, amount, shares)
        self.expenses.append(e)
        self.net[paid_by] += amount
        for u, s in shares.items():
            self.net[u] -= s
        return e

    def simplify(self) -> list[tuple[str, str, int]]:
        """Greedy: match the largest creditor with the largest debtor. At most n-1 transfers.
        (Finding the true minimum number of transfers is NP-hard; this heuristic is the usual answer.)"""
        creditors = [(-v, u) for u, v in self.net.items() if v > 0]
        debtors = [(v, u) for u, v in self.net.items() if v < 0]
        heapq.heapify(creditors); heapq.heapify(debtors)
        out: list[tuple[str, str, int]] = []
        while creditors and debtors:
            c_amt, c = heapq.heappop(creditors)
            d_amt, d = heapq.heappop(debtors)
            pay = min(-c_amt, -d_amt)
            out.append((d, c, pay))
            if -c_amt > pay: heapq.heappush(creditors, (c_amt + pay, c))
            if -d_amt > pay: heapq.heappush(debtors, (d_amt + pay, d))
        return out


m = ExpenseManager()
m.add("e1", "A", 3000, ["A", "B", "C"], EqualSplit())         # B, C owe A 1000 each
m.add("e2", "B", 1000, ["B", "C"], ExactSplit({"B": 0, "C": 1000}))
assert sum(m.net.values()) == 0
# net: A +2000, B 0, C -2000  ->  one transfer instead of two
assert m.simplify() == [("C", "A", 2000)]
assert PercentSplit({"A": 33.34, "B": 33.33, "C": 33.33}).shares(100, []) == {"A": 34, "B": 33, "C": 33}

Debt simplification: compute each person's net balance, then repeatedly match the largest creditor with the largest debtor. This needs at most n−1 transfers. Finding the true minimum number of transfers is NP-hard in general (it contains a subset-sum-style problem), so say "greedy, at most n−1, and optimal minimum is NP-hard" rather than claiming optimality.

Money: integer minor units; equal splits distribute the remainder one unit at a time; percentage splits use largest-remainder rounding so shares always add up to the total exactly.

Patterns: Strategy (split types), Factory (parse "EXACT 100 200" into a strategy in a machine-coding CLI), Observer (notify participants), Command (each CLI line).

Concurrency: adding an expense updates several balances, so lock per group (or per ledger) to keep the sum-to-zero invariant. Balances are derivable from the expense list, so you can rebuild them after a crash.

Extensions: multiple currencies (store per-currency balances; convert only at settlement), edit/delete expense (reverse the old shares, apply the new ones; the immutable list makes this auditable), recurring expenses, groups within groups.

Common mistake

Floats for money, shares that don't sum to the total because of rounding, and a balances[a][b] matrix that has to be updated in two places per expense and drifts out of sync.

7.7 Vending machine (State pattern)

Prompt: "Design a vending machine." This is the textbook State-pattern problem: the same buttons do different things depending on whether money has been inserted or the machine is sold out.

insert(coin) select(code) ok, stock left [Idle] ------------------> [HasMoney] ------------------------------> [Idle] ^ | ^ insert(coin): add to balance | cancel(): refund | | +--------------------------+ +-- select(): not enough money -> error, stay select(code) ok and last item sold -> [SoldOut] (all actions rejected until restock)
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import IntEnum


class Coin(IntEnum):
    FIVE = 5
    TEN = 10
    TWENTY_FIVE = 25
    HUNDRED = 100


@dataclass
class Slot:
    name: str
    price: int
    qty: int


class VMState(ABC):
    def insert(self, vm: VendingMachine, coin: Coin) -> None:
        raise RuntimeError(f"cannot insert coin in {type(self).__name__}")

    def select(self, vm: VendingMachine, code: str) -> None:
        raise RuntimeError(f"cannot select in {type(self).__name__}")

    def cancel(self, vm: VendingMachine) -> list[Coin]:
        return []


class Idle(VMState):
    def insert(self, vm: VendingMachine, coin: Coin) -> None:
        vm.balance += coin
        vm.state = HasMoney()


class HasMoney(VMState):
    def insert(self, vm: VendingMachine, coin: Coin) -> None:
        vm.balance += coin

    def select(self, vm: VendingMachine, code: str) -> None:
        slot = vm.slots.get(code)
        if slot is None or slot.qty == 0:
            raise ValueError("sold out or invalid code")
        if vm.balance < slot.price:
            raise ValueError(f"insert {slot.price - vm.balance} more")
        change = vm.make_change(vm.balance - slot.price)      # check change BEFORE dispensing
        slot.qty -= 1
        vm.balance = 0
        vm.dispensed.append(slot.name)
        vm.change_tray.extend(change)
        vm.state = Idle() if any(s.qty for s in vm.slots.values()) else SoldOut()

    def cancel(self, vm: VendingMachine) -> list[Coin]:
        refund = vm.make_change(vm.balance)
        vm.balance = 0
        vm.state = Idle()
        return refund


class SoldOut(VMState):
    pass                                   # every action rejected via base-class defaults


class VendingMachine:
    def __init__(self, slots: dict[str, Slot], float_coins: dict[Coin, int]) -> None:
        self.slots = slots
        self.coins = dict(float_coins)       # coins available for change
        self.balance = 0
        self.state: VMState = Idle()
        self.dispensed: list[str] = []
        self.change_tray: list[Coin] = []

    def make_change(self, amount: int) -> list[Coin]:
        out: list[Coin] = []
        for c in sorted(Coin, reverse=True):  # greedy works for this canonical coin set
            while amount >= c and self.coins.get(c, 0) > 0:
                amount -= c
                self.coins[c] -= 1
                out.append(c)
        if amount:
            for c in out:                    # roll back
                self.coins[c] += 1
            raise ValueError("exact change only")
        return out

    def insert(self, coin: Coin) -> None:
        self.state.insert(self, coin)
        self.coins[coin] = self.coins.get(coin, 0) + 1

    def select(self, code: str) -> None: self.state.select(self, code)
    def cancel(self) -> list[Coin]: return self.state.cancel(self)


vm = VendingMachine({"A1": Slot("chips", 65, 1)}, {Coin.TEN: 5, Coin.FIVE: 5})
vm.insert(Coin.HUNDRED)
vm.select("A1")
assert vm.dispensed == ["chips"] and sum(vm.change_tray) == 35 and isinstance(vm.state, SoldOut)

Two details interviewers look for: check you can make change before dispensing (otherwise the customer loses money or you give away product), and roll back the coin counts if change can't be made. Greedy change-making is correct for canonical coin systems like the one above, but not for arbitrary denominations, where you'd need dynamic programming. Say so.

Patterns: State (core), Strategy (change-making, payment method), Singleton (often requested; resist or accept), Observer (low-stock alerts to the operator).

Concurrency: a physical machine has one user at a time, so concurrency is usually out of scope. If a remote app can also buy, guard each state transition with one lock.

Common mistake

A single class with if self.state == "IDLE" in every method. That's exactly what the State pattern is for. Also: dispensing before confirming change can be given.

7.8 In-memory pub-sub / message queue

Prompt: "Design an in-memory message queue like a mini Kafka" or "a pub-sub system with topics and subscribers."

from __future__ import annotations

import threading
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Callable
import itertools
import time


@dataclass(frozen=True)
class Message:
    offset: int
    key: str | None
    payload: dict
    ts: float = field(default_factory=time.time)


class Topic:
    """Append-only log; consumers track their own offsets (Kafka-style, single partition)."""

    def __init__(self, name: str) -> None:
        self.name = name
        self.log: list[Message] = []
        self.cond = threading.Condition()

    def append(self, key: str | None, payload: dict) -> int:
        with self.cond:
            msg = Message(len(self.log), key, payload)
            self.log.append(msg)
            self.cond.notify_all()
            return msg.offset


class Broker:
    def __init__(self) -> None:
        self.topics: dict[str, Topic] = {}
        self.group_offsets: defaultdict[tuple[str, str], int] = defaultdict(int)  # (group, topic)
        self._lock = threading.Lock()

    def topic(self, name: str) -> Topic:
        with self._lock:
            return self.topics.setdefault(name, Topic(name))

    def publish(self, topic: str, payload: dict, key: str | None = None) -> int:
        return self.topic(topic).append(key, payload)

    def poll(self, group: str, topic: str, max_msgs: int = 10, timeout: float = 0.5) -> list[Message]:
        t = self.topic(topic)
        with t.cond:
            start = self.group_offsets[(group, topic)]
            if start >= len(t.log):
                t.cond.wait(timeout)                     # long-poll
            return t.log[start:start + max_msgs]

    def commit(self, group: str, topic: str, next_offset: int) -> None:
        with self._lock:                                 # at-least-once: commit after processing
            cur = self.group_offsets[(group, topic)]
            self.group_offsets[(group, topic)] = max(cur, next_offset)


class Consumer(threading.Thread):
    def __init__(self, broker: Broker, group: str, topic: str, handler: Callable[[Message], None]) -> None:
        super().__init__(daemon=True)
        self.b, self.g, self.t, self.h = broker, group, topic, handler
        self.stop_evt = threading.Event()

    def run(self) -> None:
        while not self.stop_evt.is_set():
            batch = self.b.poll(self.g, self.t)
            for m in batch:
                self.h(m)                                # if this crashes, the batch is redelivered
            if batch:
                self.b.commit(self.g, self.t, batch[-1].offset + 1)


broker = Broker()
got_a: list[int] = []
got_b: list[int] = []
ca = Consumer(broker, "billing", "orders", lambda m: got_a.append(m.payload["id"]))
cb = Consumer(broker, "email", "orders", lambda m: got_b.append(m.payload["id"]))
ca.start(); cb.start()
for i in range(5):
    broker.publish("orders", {"id": i}, key=f"user-{i % 2}")
deadline = time.time() + 3
while (len(got_a) < 5 or len(got_b) < 5) and time.time() < deadline:
    time.sleep(0.01)
ca.stop_evt.set(); cb.stop_evt.set()
assert got_a == got_b == [0, 1, 2, 3, 4]       # each group sees every message, in order

Patterns: Observer/pub-sub (core), producer–consumer with condition variables (long-polling), Strategy (partitioner: hash of key → partition), Command (messages as commands for workers).

Concurrency: each topic's condition variable guards its log; the broker lock guards topic creation and offsets. Consumers block in poll without busy-waiting. Since commits only move forward (max), a slow duplicate commit can't rewind a group.

Extensions: partitions (N logs per topic, key-hash routing, one consumer per partition per group, which is how per-key ordering survives parallelism), retention by size/time (drop the log prefix, keep a base offset), dead-letter topic after K failed deliveries, back-pressure (bounded topics), push delivery via a dispatcher thread per subscriber with its own queue.

Common mistake

Removing a message from a shared queue on delivery (so a second consumer group can't see it and a crashed consumer loses it), calling subscriber callbacks while holding the broker lock (one slow subscriber stalls all publishers), and busy-wait polling loops.

7.9 Logger / notification service

Prompt: "Design a logging library" (or the close cousin: "a notification service that sends email, SMS and push"). Both are about fan-out to pluggable sinks with filtering, formatting and non-blocking delivery.

from __future__ import annotations

import queue
import sys
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import IntEnum
from typing import TextIO


class Level(IntEnum):
    DEBUG = 10
    INFO = 20
    WARN = 30
    ERROR = 40


@dataclass(frozen=True)
class LogRecord:
    level: Level
    msg: str
    logger: str
    ts: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    extra: dict = field(default_factory=dict)


class Formatter(ABC):
    @abstractmethod
    def format(self, r: LogRecord) -> str: ...


class TextFormatter(Formatter):
    def format(self, r: LogRecord) -> str:
        return f"{r.ts:%H:%M:%S} {r.level.name:<5} [{r.logger}] {r.msg}"


class Sink(ABC):                               # a.k.a. Appender / Handler
    def __init__(self, min_level: Level, fmt: Formatter) -> None:
        self.min_level, self.fmt = min_level, fmt

    def handle(self, r: LogRecord) -> None:
        if r.level >= self.min_level:
            self.emit(self.fmt.format(r))

    @abstractmethod
    def emit(self, line: str) -> None: ...


class StreamSink(Sink):
    def __init__(self, min_level: Level, fmt: Formatter, stream: TextIO = sys.stdout) -> None:
        super().__init__(min_level, fmt)
        self.stream = stream

    def emit(self, line: str) -> None:
        print(line, file=self.stream)


class MemorySink(Sink):
    def __init__(self, min_level: Level, fmt: Formatter) -> None:
        super().__init__(min_level, fmt)
        self.lines: list[str] = []

    def emit(self, line: str) -> None:
        self.lines.append(line)


class AsyncDispatcher:
    """Callers enqueue and return immediately; one background thread fans out to sinks."""

    def __init__(self, sinks: list[Sink], maxsize: int = 10_000) -> None:
        self.sinks = sinks
        self.q: queue.Queue[LogRecord | None] = queue.Queue(maxsize)
        self.dropped = 0
        self.t = threading.Thread(target=self._run, daemon=True)
        self.t.start()

    def submit(self, r: LogRecord) -> None:
        try:
            self.q.put_nowait(r)               # never block the request path on logging
        except queue.Full:
            self.dropped += 1

    def _run(self) -> None:
        while (r := self.q.get()) is not None:
            for s in self.sinks:
                try:
                    s.handle(r)
                except Exception:
                    pass                        # a broken sink must not kill the logger

    def close(self) -> None:
        self.q.put(None)
        self.t.join()


class Logger:
    def __init__(self, name: str, dispatcher: AsyncDispatcher, level: Level = Level.INFO) -> None:
        self.name, self.d, self.level = name, dispatcher, level

    def log(self, level: Level, msg: str, **extra: object) -> None:
        if level >= self.level:                 # cheap early filter before building a record
            self.d.submit(LogRecord(level, msg, self.name, extra=extra))

    def info(self, msg: str, **kw: object) -> None: self.log(Level.INFO, msg, **kw)
    def error(self, msg: str, **kw: object) -> None: self.log(Level.ERROR, msg, **kw)
    def debug(self, msg: str, **kw: object) -> None: self.log(Level.DEBUG, msg, **kw)


mem = MemorySink(Level.WARN, TextFormatter())
disp = AsyncDispatcher([mem])
log = Logger("checkout", disp, Level.DEBUG)
log.debug("cart loaded"); log.info("paying"); log.error("card declined", order=42)
disp.close()
assert len(mem.lines) == 1 and "card declined" in mem.lines[0]

Patterns: Strategy (formatter), Chain of Responsibility or a list of sinks with level filters (Observer-like fan-out), Singleton/registry for named loggers (Python's own logging.getLogger(name) returns the same logger per name), Decorator (add context fields), producer–consumer (async dispatch). For notifications: Factory (channel → sender), Template Method (render → send → record), Strategy (retry/backoff), Observer (domain events trigger notifications).

Concurrency: the request path only enqueues (put_nowait); one background thread owns the sinks, so sinks need no locking. Decide the full-buffer policy explicitly: drop and count (shown), block, or drop DEBUG first. Flush on shutdown (close() enqueues a sentinel and joins).

Extensions: structured JSON logs, sampling of DEBUG, per-module levels, file rotation, a remote sink with batching and retry. For notifications: idempotency key per notification (so retries don't double-send), per-user rate limits, priority queues (OTP before marketing), and a dead-letter store.

Common mistake

Synchronous network sinks on the request path, building the formatted string before checking the level (wasted work for filtered DEBUG logs), and letting one sink's exception kill the dispatcher thread.

7.10 Games: tic-tac-toe, snake-and-ladder (and a chess template)

Game problems all share one skeleton: Game (turn loop, status, history) → Board (state, legal-move check, win detection) → Player (abstract, with Human/Bot implementations supplying moves) → Move (value object) → rule components (dice, win checker) behind interfaces. Get the skeleton right and the specific game is a few methods.

Game ◆--- Board «abstract» Player - players: list[Player] - cells + next_move(board) -> Move - status: GameStatus + place(move, mark) ^ ^ - history: list[Move] + is_full() HumanPlayer BotPlayer(strategy) + play_turn() / play() Value objects: Move(row, col) Enums: Mark{X,O}, GameStatus{IN_PROGRESS, WON, DRAW} Rules behind interfaces: WinChecker, Dice, MoveValidator (chess: per-piece Strategy)
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum


class Mark(Enum):
    X = "X"
    O = "O"


class GameStatus(Enum):
    IN_PROGRESS = "in_progress"
    WON = "won"
    DRAW = "draw"


@dataclass(frozen=True)
class Move:
    row: int
    col: int


class Player(ABC):
    def __init__(self, name: str, mark: Mark) -> None:
        self.name, self.mark = name, mark

    @abstractmethod
    def next_move(self, board: Board) -> Move: ...


class ScriptedPlayer(Player):              # stands in for HumanPlayer / BotPlayer
    def __init__(self, name: str, mark: Mark, moves: list[Move]) -> None:
        super().__init__(name, mark)
        self._moves = iter(moves)

    def next_move(self, board: Board) -> Move:
        return next(self._moves)


class Board:
    def __init__(self, n: int = 3) -> None:
        self.n = n
        self.cells: list[list[Mark | None]] = [[None] * n for _ in range(n)]
        # O(1) win check: running sums per row/col/diagonal (+1 for X, -1 for O)
        self.rows, self.cols = [0] * n, [0] * n
        self.diag = self.anti = 0
        self.filled = 0

    def place(self, m: Move, mark: Mark) -> bool:
        if not (0 <= m.row < self.n and 0 <= m.col < self.n) or self.cells[m.row][m.col]:
            raise ValueError(f"illegal move {m}")
        self.cells[m.row][m.col] = mark
        self.filled += 1
        d = 1 if mark is Mark.X else -1
        self.rows[m.row] += d
        self.cols[m.col] += d
        if m.row == m.col: self.diag += d
        if m.row + m.col == self.n - 1: self.anti += d
        return self.n in (abs(self.rows[m.row]), abs(self.cols[m.col]), abs(self.diag), abs(self.anti))

    @property
    def full(self) -> bool:
        return self.filled == self.n * self.n


class Game:
    def __init__(self, board: Board, players: list[Player]) -> None:
        self.board, self.players = board, players
        self.turn = 0
        self.status = GameStatus.IN_PROGRESS
        self.winner: Player | None = None
        self.history: list[tuple[str, Move]] = []

    def play_turn(self) -> None:
        if self.status is not GameStatus.IN_PROGRESS:
            raise RuntimeError("game over")
        p = self.players[self.turn % len(self.players)]
        mv = p.next_move(self.board)
        won = self.board.place(mv, p.mark)
        self.history.append((p.name, mv))
        if won:
            self.status, self.winner = GameStatus.WON, p
        elif self.board.full:
            self.status = GameStatus.DRAW
        self.turn += 1

    def play(self) -> GameStatus:
        while self.status is GameStatus.IN_PROGRESS:
            self.play_turn()
        return self.status


x = ScriptedPlayer("ann", Mark.X, [Move(0, 0), Move(1, 1), Move(2, 2)])
o = ScriptedPlayer("bob", Mark.O, [Move(0, 1), Move(0, 2)])
g = Game(Board(3), [x, o])
assert g.play() is GameStatus.WON and g.winner is x

The O(1) win check (running row/column/diagonal sums) is a detail interviewers like: it works for any N×N board without rescanning. Snake-and-ladder reuses the same skeleton with a Dice interface (injectable for tests) and the observation that snakes and ladders are the same thing: a jump table.

from __future__ import annotations

import random
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass


class Dice(ABC):
    @abstractmethod
    def roll(self) -> int: ...


class RandomDice(Dice):
    def __init__(self, faces: int = 6, seed: int | None = None) -> None:
        self.faces, self.rng = faces, random.Random(seed)

    def roll(self) -> int:
        return self.rng.randint(1, self.faces)


@dataclass
class Player:
    name: str
    pos: int = 0


class SnakeLadderBoard:
    def __init__(self, size: int, jumps: dict[int, int]) -> None:
        # snakes and ladders are the same thing: a jump from one cell to another
        if any(a in (0, size) for a in jumps):
            raise ValueError("no jump on start/end cells")
        self.size, self.jumps = size, jumps

    def resolve(self, pos: int) -> int:
        return self.jumps.get(pos, pos)


class SnakeLadderGame:
    def __init__(self, board: SnakeLadderBoard, players: list[Player], dice: Dice) -> None:
        self.board, self.players, self.dice = board, deque(players), dice
        self.winner: Player | None = None

    def play_turn(self) -> None:
        p = self.players[0]
        target = p.pos + self.dice.roll()
        if target <= self.board.size:            # rule choice: must land exactly on the last cell
            p.pos = self.board.resolve(target)
        if p.pos == self.board.size:
            self.winner = p
        self.players.rotate(-1)

    def play(self, max_turns: int = 10_000) -> Player:
        for _ in range(max_turns):
            self.play_turn()
            if self.winner:
                return self.winner
        raise RuntimeError("no winner within turn limit")


game = SnakeLadderGame(SnakeLadderBoard(100, {3: 22, 5: 8, 27: 1, 99: 10}),
                       [Player("a"), Player("b")], RandomDice(seed=7))
assert game.play().pos == 100

Chess template: Piece (abstract) with legal_moves(board, from_sq) per subclass (or a movement Strategy per piece type), Board of 64 squares, Move with special flags (castle, en passant, promotion), Game with a MoveValidator that rejects moves leaving your own king in check, and Command for moves so undo works. Don't try to implement every rule. Agree on a subset and show where the rest would go.

Patterns: Template Method (turn loop), Strategy (bot move choice, dice type, win rule), Factory (pieces from a starting layout), Command (moves, for undo/replay), State (game status).

Extensions: N×N board with K-in-a-row, more players, undo (Command), save/replay (history of moves), networked play (each turn becomes a message; validate on the server).

Common mistake

Mixing I/O (input()/print) into Board and Game, which makes them untestable. Keep players as move sources and the CLI as a thin shell around Game.

Go deeper

8. AI-flavoured LLD problems

AI-engineering loops increasingly replace "design a parking lot" with "design the LLM client your team would use" or "build a tool-calling agent loop". The craft is the same: entities, interfaces at the points of variation, correct state handling, concurrency, tests. The domain adds a few concerns of its own: unreliable and rate-limited providers, token budgets, non-deterministic outputs, streaming, cost, and multi-tenancy. All code below is provider-agnostic and runs against fakes. It does not reproduce any vendor SDK's API; a real implementation would put each vendor behind an Adapter. For the product-level view of these components, see B1, B3 · Memory, B4 · Agents and B9 · Harness engineering.

Interview angle

These prompts test whether you treat the LLM as an unreliable remote dependency with a cost meter, not a function call. Interviewers listen for: an error taxonomy (retryable vs not), timeouts everywhere, idempotency, budgets (tokens, steps, dollars), tenant isolation, and testability with a fake model.

8a. LLM client library: providers, retries, fallback, streaming, cost

Requirements: one interface over several providers; retries with exponential backoff and jitter on transient errors; respect a server's retry-after hint; fall back to another provider (possibly with a different model) when one is down; timeouts; streaming; per-tenant token and cost accounting. Non-goals: prompt management, caching (8h), scheduling (8d).

Entities: value objects ChatMessage, CompletionRequest, CompletionResponse, Usage; an error taxonomy LLMError → RateLimited / ProviderUnavailable (retryable) / BadRequest (not); the LLMProvider interface; wrappers RetryingProvider, MeteredProvider; FallbackChain; UsageLedger.

App code MeteredProvider (Decorator): records usage + cost per tenant FallbackChain (Chain of Responsibility): try each provider in order; BadRequest stops the chain RetryingProvider (Decorator) backoff = U(0, min(cap, base·2^n)) Provider A adapter (Strategy / Adapter) vendor SDK + error mapping RetryingProvider (Decorator) honours retry_after Provider B adapter (different model id) via model_map fails
The client is a stack of objects that all implement LLMProvider. Each layer does one job, and the order is a wiring decision in one place: metering outside the fallback (bill what actually ran), retries inside it (retry a provider a few times before giving up on it).
from __future__ import annotations

import random
import time
from abc import ABC, abstractmethod
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Callable, Literal


# ---------- value objects ----------
@dataclass(frozen=True)
class ChatMessage:
    role: Literal["system", "user", "assistant", "tool"]
    content: str


@dataclass(frozen=True)
class CompletionRequest:
    model: str
    messages: tuple[ChatMessage, ...]
    max_tokens: int = 512
    temperature: float = 0.0
    timeout_s: float = 30.0


@dataclass(frozen=True)
class Usage:
    input_tokens: int
    output_tokens: int


@dataclass(frozen=True)
class CompletionResponse:
    text: str
    model: str
    provider: str
    usage: Usage
    finish_reason: str = "stop"


# ---------- error taxonomy: decides retry vs fallback vs fail ----------
class LLMError(Exception):
    retryable = False


class RateLimited(LLMError):
    retryable = True

    def __init__(self, retry_after: float | None = None) -> None:
        super().__init__("rate limited")
        self.retry_after = retry_after


class ProviderUnavailable(LLMError):     # 5xx, connection reset, timeout
    retryable = True


class BadRequest(LLMError):              # 4xx: retrying the same payload won't help
    retryable = False


# ---------- Strategy: one interface, many providers ----------
class LLMProvider(ABC):
    name: str

    @abstractmethod
    def complete(self, req: CompletionRequest) -> CompletionResponse: ...

    @abstractmethod
    def stream(self, req: CompletionRequest) -> Iterator[str]: ...


class FakeProvider(LLMProvider):
    """Test double. Real adapters translate CompletionRequest into a vendor SDK call
    and map vendor errors into the taxonomy above."""

    def __init__(self, name: str, fail_times: int = 0, error: type[LLMError] = ProviderUnavailable) -> None:
        self.name, self.fail_times, self.error, self.calls = name, fail_times, error, 0

    def complete(self, req: CompletionRequest) -> CompletionResponse:
        self.calls += 1
        if self.calls <= self.fail_times:
            raise self.error()
        text = f"[{self.name}] answer"
        return CompletionResponse(text, req.model, self.name, Usage(sum(len(m.content) // 4 for m in req.messages), 5))

    def stream(self, req: CompletionRequest) -> Iterator[str]:
        yield from self.complete(req).text.split(" ")


# ---------- Decorator: retries with exponential backoff + full jitter ----------
class RetryingProvider(LLMProvider):
    def __init__(self, inner: LLMProvider, max_attempts: int = 4, base: float = 0.5, cap: float = 20.0,
                 sleep: Callable[[float], None] = time.sleep, rng: random.Random | None = None) -> None:
        self.inner, self.max_attempts, self.base, self.cap = inner, max_attempts, base, cap
        self.name, self._sleep, self._rng = inner.name, sleep, rng or random.Random()

    def _delay(self, attempt: int, err: LLMError) -> float:
        if isinstance(err, RateLimited) and err.retry_after is not None:
            return err.retry_after                                  # honour the server's hint
        return self._rng.uniform(0, min(self.cap, self.base * 2 ** attempt))   # "full jitter"

    def complete(self, req: CompletionRequest) -> CompletionResponse:
        for attempt in range(self.max_attempts):
            try:
                return self.inner.complete(req)
            except LLMError as e:
                if not e.retryable or attempt == self.max_attempts - 1:
                    raise
                self._sleep(self._delay(attempt, e))
        raise AssertionError("unreachable")

    def stream(self, req: CompletionRequest) -> Iterator[str]:
        # Only retry before the first chunk; once tokens reach the caller, a retry would duplicate them.
        for attempt in range(self.max_attempts):
            it = self.inner.stream(req)
            try:
                first = next(it)
            except StopIteration:
                return
            except LLMError as e:
                if not e.retryable or attempt == self.max_attempts - 1:
                    raise
                self._sleep(self._delay(attempt, e))
                continue
            yield first
            yield from it
            return


# ---------- Decorator: token + cost accounting ----------
@dataclass
class UsageLedger:
    prices_per_mtok: dict[str, tuple[float, float]]               # model -> (input $, output $) per 1M tokens
    totals: dict[str, float] = field(default_factory=dict)

    def record(self, tenant: str, resp: CompletionResponse) -> float:
        pin, pout = self.prices_per_mtok.get(resp.model, (0.0, 0.0))
        cost = (resp.usage.input_tokens * pin + resp.usage.output_tokens * pout) / 1_000_000
        self.totals[tenant] = self.totals.get(tenant, 0.0) + cost
        return cost


class MeteredProvider(LLMProvider):
    def __init__(self, inner: LLMProvider, ledger: UsageLedger, tenant: str) -> None:
        self.inner, self.ledger, self.tenant, self.name = inner, ledger, tenant, inner.name

    def complete(self, req: CompletionRequest) -> CompletionResponse:
        resp = self.inner.complete(req)
        self.ledger.record(self.tenant, resp)
        return resp

    def stream(self, req: CompletionRequest) -> Iterator[str]:
        yield from self.inner.stream(req)     # real impl: read usage from the final stream event


# ---------- Chain of Responsibility: fallback across providers ----------
class AllProvidersFailed(LLMError):
    def __init__(self, errors: list[tuple[str, Exception]]) -> None:
        super().__init__("; ".join(f"{n}: {e!r}" for n, e in errors))
        self.errors = errors


class FallbackChain(LLMProvider):
    def __init__(self, providers: list[LLMProvider], model_map: dict[str, str] | None = None) -> None:
        self.providers, self.name = providers, "fallback"
        self.model_map = model_map or {}      # provider name -> model id to use on that provider

    def complete(self, req: CompletionRequest) -> CompletionResponse:
        errors: list[tuple[str, Exception]] = []
        for p in self.providers:
            r = CompletionRequest(self.model_map.get(p.name, req.model), req.messages,
                                  req.max_tokens, req.temperature, req.timeout_s)
            try:
                return p.complete(r)
            except BadRequest:
                raise                         # our bug, not theirs: don't burn the fallback chain
            except LLMError as e:
                errors.append((p.name, e))
        raise AllProvidersFailed(errors)

    def stream(self, req: CompletionRequest) -> Iterator[str]:
        yield from self.providers[0].stream(req)


# ---------- composition root ----------
no_sleep: Callable[[float], None] = lambda s: None
primary = FakeProvider("primary", fail_times=10)                 # hard down
secondary = FakeProvider("secondary", fail_times=1)              # one transient blip
ledger = UsageLedger({"small-model": (0.5, 1.5)})
client = MeteredProvider(
    FallbackChain([RetryingProvider(primary, sleep=no_sleep), RetryingProvider(secondary, sleep=no_sleep)],
                  model_map={"secondary": "small-model"}),
    ledger, tenant="acme")
resp = client.complete(CompletionRequest("big-model", (ChatMessage("user", "hello there, model"),)))
assert resp.provider == "secondary" and primary.calls == 4 and secondary.calls == 2
assert ledger.totals["acme"] > 0
assert list(RetryingProvider(FakeProvider("s", fail_times=2), sleep=no_sleep).stream(
    CompletionRequest("m", (ChatMessage("user", "hi"),)))) == ["[s]", "answer"]

Design decisions worth saying out loud:

Extensions: circuit breaker per provider (after K failures, skip it for T seconds, then half-open: let one probe through), hedged requests (send to a second provider if the first hasn't responded by p95 latency; cancel the loser), async version (async def complete, asyncio.timeout), structured output with validation and repair-retry, request/response hooks for tracing.

Common mistake

Retrying every exception (including 400s and your own bugs), retrying without jitter, nesting retries at several layers (3 × 3 × 3 = 27 calls), and counting cost on the request side with your own token estimate instead of reported usage.

8b. Tool registry and tool-calling agent loop

Requirements: register tools with schemas; expose schemas to the model; execute the model's tool calls with argument validation; feed results and errors back; stop on a final answer or a max-steps guard; optional approval for side-effecting tools. Entities: ToolSpec (name, description, parameter schema), Tool (Command), ToolRegistry, ToolCall/ModelTurn (provider-neutral value objects), Agent, AgentResult.

Agent.run(user_msg) loop step = 1..max_steps: turn = model(messages, registry.schemas()) # Strategy: any provider, or a scripted fake if no tool_calls: return final answer for call in turn.tool_calls: # Command objects approve(call)? -> registry.execute(name, args) -> validate -> tool.run(**args) append {"role": "tool", tool_call_id, content, ok} # errors are data, not crashes return "step budget exhausted" (stopped_reason = max_steps)
from __future__ import annotations

import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Callable


class ToolError(Exception):
    pass


@dataclass(frozen=True)
class ToolSpec:
    name: str
    description: str
    params: dict[str, type]                 # minimal schema: name -> python type
    required: tuple[str, ...] = ()

    def validate(self, args: dict[str, Any]) -> None:
        missing = [k for k in self.required if k not in args]
        unknown = [k for k in args if k not in self.params]
        wrong = [k for k, v in args.items() if k in self.params and not isinstance(v, self.params[k])]
        if missing or unknown or wrong:
            raise ToolError(f"invalid args: missing={missing} unknown={unknown} wrong_type={wrong}")

    def to_json_schema(self) -> dict[str, Any]:
        tmap = {str: "string", int: "integer", float: "number", bool: "boolean"}
        return {"name": self.name, "description": self.description,
                "input_schema": {"type": "object",
                                 "properties": {k: {"type": tmap[t]} for k, t in self.params.items()},
                                 "required": list(self.required)}}


class Tool(ABC):                            # Command pattern: a request wrapped as an object
    spec: ToolSpec
    side_effecting: bool = False

    @abstractmethod
    def run(self, **kwargs: Any) -> str: ...


class Calculator(Tool):
    spec = ToolSpec("add", "Add two integers", {"a": int, "b": int}, ("a", "b"))

    def run(self, a: int, b: int) -> str:
        return str(a + b)


class ToolRegistry:
    def __init__(self) -> None:
        self._tools: dict[str, Tool] = {}

    def register(self, tool: Tool) -> None:
        if tool.spec.name in self._tools:
            raise ValueError(f"duplicate tool {tool.spec.name}")
        self._tools[tool.spec.name] = tool

    def schemas(self) -> list[dict[str, Any]]:
        return [t.spec.to_json_schema() for t in self._tools.values()]

    def execute(self, name: str, args: dict[str, Any]) -> str:
        tool = self._tools.get(name)
        if tool is None:
            raise ToolError(f"unknown tool {name!r}; available: {sorted(self._tools)}")
        tool.spec.validate(args)
        return tool.run(**args)


# ---- the model's turn, provider-agnostic ----
@dataclass(frozen=True)
class ToolCall:
    id: str
    name: str
    args: dict[str, Any]


@dataclass(frozen=True)
class ModelTurn:
    text: str = ""
    tool_calls: tuple[ToolCall, ...] = ()


Model = Callable[[list[dict[str, Any]], list[dict[str, Any]]], ModelTurn]   # (messages, tool schemas) -> turn


@dataclass
class AgentResult:
    answer: str
    steps: int
    transcript: list[dict[str, Any]] = field(default_factory=list)
    stopped_reason: str = "final_answer"


class Agent:
    def __init__(self, model: Model, registry: ToolRegistry, max_steps: int = 8,
                 approve: Callable[[ToolCall], bool] = lambda c: True) -> None:
        self.model, self.reg, self.max_steps, self.approve = model, registry, max_steps, approve

    def run(self, user_msg: str) -> AgentResult:
        msgs: list[dict[str, Any]] = [{"role": "user", "content": user_msg}]
        for step in range(1, self.max_steps + 1):
            turn = self.model(msgs, self.reg.schemas())
            msgs.append({"role": "assistant", "content": turn.text,
                         "tool_calls": [c.__dict__ for c in turn.tool_calls]})
            if not turn.tool_calls:
                return AgentResult(turn.text, step, msgs)
            for call in turn.tool_calls:
                try:
                    if not self.approve(call):
                        raise ToolError("denied by policy")
                    out = self.reg.execute(call.name, call.args)
                    ok = True
                except ToolError as e:
                    out, ok = str(e), False    # feed the error back so the model can self-correct
                except Exception as e:         # tool bug: report, don't crash the loop
                    out, ok = f"tool crashed: {type(e).__name__}", False
                msgs.append({"role": "tool", "tool_call_id": call.id, "content": out, "ok": ok})
        return AgentResult("I couldn't finish within the step budget.", self.max_steps, msgs, "max_steps")


# ---- a scripted fake model for tests ----
def scripted(turns: list[ModelTurn]) -> Model:
    it = iter(turns)
    return lambda msgs, tools: next(it)


reg = ToolRegistry()
reg.register(Calculator())
model = scripted([
    ModelTurn(tool_calls=(ToolCall("c1", "add", {"a": 2, "b": "3"}),)),   # wrong type -> error fed back
    ModelTurn(tool_calls=(ToolCall("c2", "add", {"a": 2, "b": 3}),)),
    ModelTurn(text="2 + 3 = 5"),
])
res = Agent(model, reg).run("what is 2+3?")
assert res.answer == "2 + 3 = 5" and res.steps == 3
assert res.transcript[2]["ok"] is False and res.transcript[4]["content"] == "5"
looping = Agent(lambda m, t: ModelTurn(tool_calls=(ToolCall("x", "add", {"a": 1, "b": 1}),)), reg, max_steps=3)
assert looping.run("loop forever").stopped_reason == "max_steps"
json.dumps(reg.schemas())

Design decisions: errors from validation, policy denial and tool crashes are returned to the model as tool results so it can correct itself (the first scripted turn passes a string for an int and recovers). The max-steps guard is non-negotiable: models can loop. Tool calls are Command objects, which gives you logging, replay, approval gates and (for reversible tools) undo. The model is injected as a plain callable, so tests use a scripted fake.

Extensions: real JSON Schema validation (a library like jsonschema or Pydantic models instead of the minimal type map), parallel tool calls (run independent calls in a thread pool or asyncio.gather, preserving tool_call_id pairing), per-tool timeouts, a token budget as well as a step budget, truncating large tool outputs before they go back into context, human-in-the-loop approval for side-effecting tools (side_effecting=True → approve callback), idempotency keys for side-effecting tools so a retried step doesn't double-send an email.

Common mistake

Letting a tool exception crash the loop, trusting model-produced arguments without validation, no step limit, and a registry that allows two tools with the same name (the model can't disambiguate).

8c. Conversation memory with pluggable strategies

Requirements: store conversation turns; build a context that fits a token budget; support strategies (full buffer, last-k window, running summary); allocate the window between system prompt, memory, user message and reserved output. Entities: Msg, TokenCounter (Protocol), MemoryStrategy (Strategy), BufferMemory, WindowMemory, SummaryMemory, PromptAssembler.

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Protocol


@dataclass(frozen=True)
class Msg:
    role: str
    content: str


class TokenCounter(Protocol):
    def count(self, text: str) -> int: ...


class ApproxCounter:
    def count(self, text: str) -> int:
        return max(1, len(text) // 4)          # rough heuristic; use the provider's tokenizer in prod


class MemoryStrategy(ABC):
    @abstractmethod
    def add(self, m: Msg) -> None: ...

    @abstractmethod
    def context(self, budget_tokens: int) -> list[Msg]: ...


class BufferMemory(MemoryStrategy):
    """Keep everything; trim oldest at read time to fit the budget."""

    def __init__(self, counter: TokenCounter) -> None:
        self.msgs: list[Msg] = []
        self.counter = counter

    def add(self, m: Msg) -> None:
        self.msgs.append(m)

    def context(self, budget_tokens: int) -> list[Msg]:
        out: list[Msg] = []
        used = 0
        for m in reversed(self.msgs):              # newest first
            t = self.counter.count(m.content)
            if used + t > budget_tokens:
                break
            out.append(m)
            used += t
        return list(reversed(out))


class WindowMemory(BufferMemory):
    """Last k messages only (then budget-trimmed)."""

    def __init__(self, counter: TokenCounter, k: int) -> None:
        super().__init__(counter)
        self.k = k

    def add(self, m: Msg) -> None:
        super().add(m)
        self.msgs = self.msgs[-self.k:]


class SummaryMemory(MemoryStrategy):
    """Recent turns verbatim; older turns folded into a running summary by an injected summarizer."""

    def __init__(self, counter: TokenCounter, summarize: Callable[[str, list[Msg]], str],
                 keep_recent: int = 4) -> None:
        self.counter, self.summarize, self.keep = counter, summarize, keep_recent
        self.summary = ""
        self.recent: list[Msg] = []

    def add(self, m: Msg) -> None:
        self.recent.append(m)
        if len(self.recent) > self.keep * 2:       # fold in batches, not every turn (cost)
            old, self.recent = self.recent[:-self.keep], self.recent[-self.keep:]
            self.summary = self.summarize(self.summary, old)

    def context(self, budget_tokens: int) -> list[Msg]:
        head = [Msg("system", f"Conversation so far: {self.summary}")] if self.summary else []
        used = sum(self.counter.count(m.content) for m in head)
        tail: list[Msg] = []
        for m in reversed(self.recent):
            t = self.counter.count(m.content)
            if used + t > budget_tokens:
                break
            tail.append(m)
            used += t
        return head + list(reversed(tail))


class PromptAssembler:
    """Allocates the context window: system prompt + memory + reserved output tokens."""

    def __init__(self, window: int, reserve_output: int, counter: TokenCounter) -> None:
        self.window, self.reserve, self.counter = window, reserve_output, counter

    def build(self, system: str, memory: MemoryStrategy, user: str) -> list[Msg]:
        fixed = self.counter.count(system) + self.counter.count(user)
        budget = self.window - self.reserve - fixed
        if budget < 0:
            raise ValueError("system prompt + user message exceed the window")
        return [Msg("system", system), *memory.context(budget), Msg("user", user)]


fake_summarizer = lambda prev, msgs: (prev + " | " if prev else "") + f"{len(msgs)} older msgs"
mem = SummaryMemory(ApproxCounter(), fake_summarizer, keep_recent=2)
for i in range(6):
    mem.add(Msg("user", f"message number {i}"))
ctx = PromptAssembler(window=60, reserve_output=20, counter=ApproxCounter()).build("be brief", mem, "and now?")
assert ctx[0].role == "system" and ctx[1].content.startswith("Conversation so far") and ctx[-1].content == "and now?"
w = WindowMemory(ApproxCounter(), k=2)
for i in range(5):
    w.add(Msg("user", str(i)))
assert [m.content for m in w.context(100)] == ["3", "4"]

Design decisions: the token counter is injected (the 4-characters-per-token heuristic is only a placeholder; use the provider's tokenizer or token-counting endpoint for real budgets); the summariser is injected (it's an LLM call, so tests use a fake); the summary memory folds old turns in batches rather than every turn to control cost; the assembler reserves output tokens before filling memory, so long histories can't squeeze out the answer.

Extensions: retrieval-based memory (embed turns, retrieve relevant past turns via the vector store in 8f), entity/fact memory (extract facts into a key-value store), per-user persistence via a Repository, thread safety when one conversation can receive concurrent messages (lock per conversation ID). B3 covers memory architectures in depth.

Common mistake

Truncating by message count instead of tokens, dropping the system prompt when trimming, cutting a tool call apart from its tool result (many providers reject an orphaned tool result), and summarising synchronously on every turn.

8d. Token-aware rate limiter / request scheduler with per-tenant quotas

Requirements: providers limit requests per minute and tokens per minute; your platform has tenants on different tiers; higher-priority tenants go first; one noisy tenant mustn't block others; you don't know output tokens until the response arrives. Entities: TenantQuota, token buckets per tenant (RPM and TPM) plus a global provider TPM bucket, a priority queue of requests, TokenAwareScheduler.

from __future__ import annotations

import heapq
import itertools
import threading
import time
from dataclasses import dataclass, field
from typing import Callable


@dataclass
class TenantQuota:
    rpm: int                    # requests per minute
    tpm: int                    # tokens per minute (input + max output)
    priority: int = 1           # lower = more important


class _Bucket:
    def __init__(self, capacity: float, per_second: float, now: float) -> None:
        self.cap, self.rate, self.tokens, self.t = capacity, per_second, capacity, now

    def refill(self, now: float) -> None:
        self.tokens = min(self.cap, self.tokens + (now - self.t) * self.rate)
        self.t = now

    def wait_time(self, need: float) -> float:
        return 0.0 if self.tokens >= need else (need - self.tokens) / self.rate


@dataclass(order=True)
class _Queued:
    priority: int
    seq: int
    tenant: str = field(compare=False)
    est_tokens: int = field(compare=False)
    job: Callable[[], object] = field(compare=False)


class TokenAwareScheduler:
    """Per-tenant RPM + TPM buckets, a global provider TPM bucket, and a priority queue.
    Charges an *estimate* up front (prompt tokens + max_tokens) and refunds the unused part."""

    def __init__(self, quotas: dict[str, TenantQuota], global_tpm: int,
                 clock: Callable[[], float] = time.monotonic) -> None:
        self.clock = clock
        now = clock()
        self.quotas = quotas
        self.rpm = {t: _Bucket(q.rpm, q.rpm / 60, now) for t, q in quotas.items()}
        self.tpm = {t: _Bucket(q.tpm, q.tpm / 60, now) for t, q in quotas.items()}
        self.global_tpm = _Bucket(global_tpm, global_tpm / 60, now)
        self.q: list[_Queued] = []
        self.seq = itertools.count()
        self.lock = threading.Lock()

    def submit(self, tenant: str, est_tokens: int, job: Callable[[], object]) -> None:
        q = self.quotas[tenant]
        if est_tokens > q.tpm:
            raise ValueError("request can never fit this tenant's TPM quota")
        with self.lock:
            heapq.heappush(self.q, _Queued(q.priority, next(self.seq), tenant, est_tokens, job))

    def try_dispatch(self) -> tuple[str, int] | None:
        """Pop the highest-priority request whose buckets all have room. Returns (tenant, tokens) or None."""
        with self.lock:
            now = self.clock()
            for b in (*self.rpm.values(), *self.tpm.values(), self.global_tpm):
                b.refill(now)
            skipped: list[_Queued] = []
            chosen = None
            while self.q:
                item = heapq.heappop(self.q)
                t = item.tenant
                if (self.rpm[t].tokens >= 1 and self.tpm[t].tokens >= item.est_tokens
                        and self.global_tpm.tokens >= item.est_tokens):
                    self.rpm[t].tokens -= 1
                    self.tpm[t].tokens -= item.est_tokens
                    self.global_tpm.tokens -= item.est_tokens
                    chosen = item
                    break
                skipped.append(item)           # tenant over quota: don't let it block others
            for s in skipped:
                heapq.heappush(self.q, s)
        if chosen is None:
            return None
        chosen.job()
        return chosen.tenant, chosen.est_tokens

    def settle(self, tenant: str, estimated: int, actual: int) -> None:
        """After the response arrives, refund over-estimation (never below zero usage)."""
        refund = max(0, estimated - actual)
        with self.lock:
            for b in (self.tpm[tenant], self.global_tpm):
                b.tokens = min(b.cap, b.tokens + refund)


now = [0.0]
s = TokenAwareScheduler({"free": TenantQuota(rpm=60, tpm=1_000, priority=2),
                         "pro": TenantQuota(rpm=600, tpm=10_000, priority=1)},
                        global_tpm=8_000, clock=lambda: now[0])
ran: list[str] = []
s.submit("free", 800, lambda: ran.append("free-1"))
s.submit("free", 800, lambda: ran.append("free-2"))      # exceeds free TPM until refill
s.submit("pro", 2_000, lambda: ran.append("pro-1"))
while s.try_dispatch():
    pass
assert ran == ["pro-1", "free-1"]                        # priority first; free-2 waits for quota
s.settle("free", estimated=800, actual=300)              # refund 500 unused tokens
now[0] += 6.0                                            # +100 tokens refill for free tier
s.try_dispatch()
assert ran[-1] == "free-2"

Design decisions: charge an estimate up front (prompt tokens + max_tokens) so concurrent requests can't jointly overshoot, then settle with actual usage and refund the difference. A request is only dispatched if all its buckets have room, and only then are they all debited (no partial consumption). Items from over-quota tenants are skipped and re-queued, so they don't block the head of the queue. Reject at submit time any request that can never fit.

Extensions: weighted fair queuing across tenants instead of strict priority (strict priority can starve the free tier), deadlines (drop requests whose caller has given up), adaptive limits from provider rate-limit response headers, a waiting dispatcher thread that sleeps until the earliest bucket has room (wait_time), distributed state in Redis.

Common mistake

Limiting only requests per minute (one huge prompt blows the token quota), debiting the tenant bucket and then finding the global bucket empty (leaked tokens), and never refunding over-estimates (tenants effectively get a fraction of their quota).

8e. Prompt template engine with versioning

Requirements: named templates with variables; strict rendering (missing or extra variables are errors); immutable versions; labels like prod and canary that can move (deploy, rollback) without code changes; a content hash for traceability in logs. Entities: PromptTemplate (immutable value object), PromptRegistry.

from __future__ import annotations

import hashlib
import string
from dataclasses import dataclass, field
from datetime import datetime, timezone


class TemplateError(Exception):
    pass


@dataclass(frozen=True)
class PromptTemplate:
    name: str
    version: int
    body: str                                      # uses $var placeholders (string.Template)
    model_hint: str | None = None
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    @property
    def variables(self) -> set[str]:
        return {m.group("named") or m.group("braced")
                for m in string.Template.pattern.finditer(self.body)
                if m.group("named") or m.group("braced")}

    @property
    def content_hash(self) -> str:
        return hashlib.sha256(self.body.encode()).hexdigest()[:12]

    def render(self, **values: object) -> str:
        missing = self.variables - values.keys()
        extra = values.keys() - self.variables
        if missing or extra:
            raise TemplateError(f"{self.name}@v{self.version}: missing={sorted(missing)} extra={sorted(extra)}")
        return string.Template(self.body).substitute(**{k: str(v) for k, v in values.items()})


class PromptRegistry:
    """Immutable versions + movable labels (e.g. 'prod', 'canary'). Rollback = move a label."""

    def __init__(self) -> None:
        self._versions: dict[str, list[PromptTemplate]] = {}
        self._labels: dict[tuple[str, str], int] = {}

    def publish(self, name: str, body: str, model_hint: str | None = None) -> PromptTemplate:
        versions = self._versions.setdefault(name, [])
        if versions and versions[-1].body == body:
            return versions[-1]                    # idempotent re-publish
        t = PromptTemplate(name, len(versions) + 1, body, model_hint)
        versions.append(t)
        return t

    def set_label(self, name: str, label: str, version: int) -> None:
        if not 1 <= version <= len(self._versions.get(name, [])):
            raise KeyError(f"{name}@v{version} does not exist")
        self._labels[(name, label)] = version

    def get(self, name: str, *, version: int | None = None, label: str = "prod") -> PromptTemplate:
        v = version or self._labels.get((name, label))
        if v is None:
            raise KeyError(f"no '{label}' label for {name}")
        return self._versions[name][v - 1]


reg = PromptRegistry()
v1 = reg.publish("summarize", "Summarize for a $audience reader:\n$text")
v2 = reg.publish("summarize", "You are concise. Summarize for a ${audience} reader in $n bullets:\n$text")
reg.set_label("summarize", "prod", 1)
reg.set_label("summarize", "canary", 2)
assert reg.get("summarize").version == 1 and reg.get("summarize", label="canary").variables == {"audience", "n", "text"}
assert "3 bullets" in reg.get("summarize", label="canary").render(audience="exec", n=3, text="...")
try:
    v1.render(audience="exec")
except TemplateError as e:
    assert "missing=['text']" in str(e)

Design decisions: versions are append-only and never edited, so a log line saying "summarize@v2 (hash abc…)" always means the same text. Labels are pointers, so rollback is moving a pointer. Rendering is strict, because a silently empty variable produces a subtly wrong prompt that's hard to debug. The standard library's string.Template is enough here and avoids executing arbitrary template logic; reach for a full template engine only if you need loops and conditionals.

Extensions: A/B routing by tenant hash between two labels, evaluation results attached to each version (block promotion to prod if evals regress), per-model variants of one template, persistence via a Repository, escaping of user-supplied values (delimit untrusted input clearly to reduce prompt-injection risk).

Common mistake

Prompts as f-strings scattered through the codebase (no versioning, no audit), mutable "latest" templates that change under running experiments, and lenient rendering that leaves $text in the final prompt.

8f. Vector-store interface with pluggable backends and metadata filtering

Requirements: upsert documents with embeddings and metadata; top-k similarity search with filters; delete; swap the backend (in-memory, a Postgres extension, a managed vector DB) without touching application code; tenant isolation. Entities: Document, Hit, a small filter AST (Eq, In, And), the VectorStore interface, InMemoryVectorStore, Retriever (facade).

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Callable


@dataclass(frozen=True)
class Document:
    id: str
    text: str
    embedding: tuple[float, ...]
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class Hit:
    doc: Document
    score: float


# ---- a tiny, backend-neutral filter language (translate to each backend's syntax) ----
@dataclass(frozen=True)
class Eq:
    field: str
    value: Any


@dataclass(frozen=True)
class In:
    field: str
    values: tuple[Any, ...]


@dataclass(frozen=True)
class And:
    clauses: tuple["Filter", ...]


Filter = Eq | In | And


def matches(f: Filter | None, md: dict[str, Any]) -> bool:
    if f is None:
        return True
    if isinstance(f, Eq):
        return md.get(f.field) == f.value
    if isinstance(f, In):
        return md.get(f.field) in f.values
    return all(matches(c, md) for c in f.clauses)


class VectorStore(ABC):
    @abstractmethod
    def upsert(self, docs: list[Document]) -> None: ...

    @abstractmethod
    def query(self, vector: tuple[float, ...], k: int = 5, where: Filter | None = None) -> list[Hit]: ...

    @abstractmethod
    def delete(self, ids: list[str]) -> None: ...


def cosine(a: tuple[float, ...], b: tuple[float, ...]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    na, nb = math.sqrt(sum(x * x for x in a)), math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0


class InMemoryVectorStore(VectorStore):
    """Brute-force exact search. A pgvector/managed-DB adapter implements the same interface."""

    def __init__(self, dim: int) -> None:
        self.dim = dim
        self._docs: dict[str, Document] = {}

    def upsert(self, docs: list[Document]) -> None:
        for d in docs:
            if len(d.embedding) != self.dim:
                raise ValueError(f"{d.id}: expected dim {self.dim}, got {len(d.embedding)}")
            self._docs[d.id] = d

    def query(self, vector: tuple[float, ...], k: int = 5, where: Filter | None = None) -> list[Hit]:
        hits = [Hit(d, cosine(vector, d.embedding)) for d in self._docs.values() if matches(where, d.metadata)]
        return sorted(hits, key=lambda h: h.score, reverse=True)[:k]   # filter BEFORE top-k

    def delete(self, ids: list[str]) -> None:
        for i in ids:
            self._docs.pop(i, None)


class Retriever:
    """Facade the app uses: embeds the query, enforces tenant isolation, calls the store."""

    def __init__(self, store: VectorStore, embed: Callable[[str], tuple[float, ...]]) -> None:
        self.store, self.embed = store, embed

    def search(self, tenant: str, query: str, k: int = 3, where: Filter | None = None) -> list[Hit]:
        scope: Filter = Eq("tenant", tenant)
        f = And((scope, where)) if where else scope        # tenant filter is never optional
        return self.store.query(self.embed(query), k, f)


def toy_embed(text: str) -> tuple[float, ...]:
    return (text.count("cat") + 0.1, text.count("dog") + 0.1, len(text) / 100)


store = InMemoryVectorStore(dim=3)
store.upsert([Document(str(i), t, toy_embed(t), {"tenant": tn, "lang": lg})
              for i, (t, tn, lg) in enumerate([("cat cat", "a", "en"), ("dog", "a", "en"),
                                               ("cat", "b", "en"), ("le cat", "a", "fr")])])
r = Retriever(store, toy_embed)
hits = r.search("a", "cat", k=5, where=In("lang", ("en",)))
assert [h.doc.id for h in hits] == ["0", "1"]       # tenant b's doc and the fr doc never appear

Design decisions: the filter is a backend-neutral AST (a Specification/Composite); each backend adapter translates it into its own filter syntax. That avoids leaking one vendor's query language into application code. Filtering happens before taking top-k. With approximate indexes, post-filtering can return fewer than k results, so real backends need pre-filtering or over-fetching, which is worth mentioning. The Retriever always adds the tenant filter, so a caller can't forget it. Dimension checks catch the classic bug of mixing embedding models.

Extensions: hybrid search (keyword BM25 + vector, fused with reciprocal rank fusion), batch upserts, namespaces per tenant, re-ranking stage, embedding-model version stored on each document so you can re-embed incrementally. B2 · Retrieval goes deeper.

Common mistake

Tenant isolation as an optional filter argument, mixing embeddings from different models or dimensions in one index, and assuming the backend's similarity metric (cosine vs dot product vs L2) matches how the embeddings were normalised.

8g. Workflow / DAG executor for agent steps

Requirements: steps with dependencies; run independent steps in parallel; pass each step the outputs of its dependencies; retry failing steps; skip steps whose upstream failed; detect cycles and unknown dependencies before running. Entities: Step, StepStatus, RunResult, DagExecutor.

Python's standard library includes graphlib.TopologicalSorter, which supports exactly this incremental pattern: prepare(), then loop on get_ready() and done() while is_active(), and it raises CycleError for cycles. Python docs: graphlib In an interview, be ready to write Kahn's algorithm yourself: compute in-degrees, start with the zero in-degree nodes, and decrement neighbours as each node finishes; if nodes remain at the end, there's a cycle.

from __future__ import annotations

import time
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from enum import Enum
from graphlib import CycleError, TopologicalSorter
from typing import Any, Callable


class StepStatus(Enum):
    PENDING = "pending"
    SUCCEEDED = "succeeded"
    FAILED = "failed"
    SKIPPED = "skipped"         # an upstream dependency failed


@dataclass
class Step:
    name: str
    fn: Callable[[dict[str, Any]], Any]          # receives outputs of its dependencies
    deps: tuple[str, ...] = ()
    max_retries: int = 2
    backoff_s: float = 0.0


@dataclass
class RunResult:
    status: dict[str, StepStatus] = field(default_factory=dict)
    outputs: dict[str, Any] = field(default_factory=dict)
    errors: dict[str, str] = field(default_factory=dict)


class DagExecutor:
    def __init__(self, steps: list[Step], max_workers: int = 4) -> None:
        self.steps = {s.name: s for s in steps}
        unknown = {d for s in steps for d in s.deps if d not in self.steps}
        if unknown:
            raise ValueError(f"unknown dependencies: {unknown}")
        self.max_workers = max_workers
        self._graph = {s.name: set(s.deps) for s in steps}
        try:
            tuple(TopologicalSorter(self._graph).static_order())    # validate up front
        except CycleError as e:
            raise ValueError(f"cycle detected: {e.args[1]}") from None

    def _run_step(self, step: Step, inputs: dict[str, Any]) -> Any:
        for attempt in range(step.max_retries + 1):
            try:
                return step.fn(inputs)
            except Exception:
                if attempt == step.max_retries:
                    raise
                time.sleep(step.backoff_s * 2 ** attempt)

    def run(self) -> RunResult:
        res = RunResult({n: StepStatus.PENDING for n in self.steps})
        ts = TopologicalSorter(self._graph)
        ts.prepare()
        running: dict[Future[Any], str] = {}
        with ThreadPoolExecutor(self.max_workers) as pool:
            while ts.is_active():
                for name in ts.get_ready():                       # all deps are done
                    step = self.steps[name]
                    if any(res.status[d] is not StepStatus.SUCCEEDED for d in step.deps):
                        res.status[name] = StepStatus.SKIPPED
                        ts.done(name)
                        continue
                    inputs = {d: res.outputs[d] for d in step.deps}
                    running[pool.submit(self._run_step, step, inputs)] = name
                if not running:
                    continue                                       # only skips this round
                finished, _ = wait(running, return_when=FIRST_COMPLETED)
                for fut in finished:
                    name = running.pop(fut)
                    try:
                        res.outputs[name] = fut.result()
                        res.status[name] = StepStatus.SUCCEEDED
                    except Exception as e:
                        res.status[name] = StepStatus.FAILED
                        res.errors[name] = repr(e)
                    ts.done(name)
        return res


flaky_calls = {"n": 0}


def flaky(_: dict[str, Any]) -> str:
    flaky_calls["n"] += 1
    if flaky_calls["n"] < 2:
        raise TimeoutError("transient")
    return "web results"


dag = DagExecutor([
    Step("plan", lambda i: "plan"),
    Step("search_web", flaky, deps=("plan",)),
    Step("search_docs", lambda i: "doc results", deps=("plan",)),
    Step("broken", lambda i: 1 / 0, deps=("plan",), max_retries=0),
    Step("after_broken", lambda i: "never", deps=("broken",)),
    Step("synthesize", lambda i: f"{i['search_web']} + {i['search_docs']}", deps=("search_web", "search_docs")),
])
out = dag.run()
assert out.outputs["synthesize"] == "web results + doc results"
assert out.status["broken"] is StepStatus.FAILED and out.status["after_broken"] is StepStatus.SKIPPED
try:
    DagExecutor([Step("a", lambda i: 1, deps=("b",)), Step("b", lambda i: 1, deps=("a",))])
except ValueError as e:
    assert "cycle" in str(e)

Design decisions: validate the graph at construction (fail fast), then schedule dynamically: a step starts as soon as its dependencies are done, not in fixed "levels", which maximises parallelism. Failure propagates as SKIPPED downstream rather than aborting unrelated branches. Retries live in the step runner with exponential backoff.

Extensions: per-step timeouts, cancellation of the whole run, conditional edges (run a branch only if a predicate on upstream output holds), persistence of step results so a crashed run resumes from the last completed step (this is what durable workflow engines provide), idempotent steps so resumption is safe, and an async version for I/O-bound LLM steps.

Common mistake

Running steps level by level (one slow step in a level blocks unrelated work in the next), not detecting cycles until runtime hangs, retrying non-idempotent steps that have side effects, and letting one failure crash the executor instead of being recorded.

8h. Semantic cache

Requirements: avoid paying for an LLM call when an equivalent prompt was answered recently; exact match first, then semantic similarity above a threshold; scope entries so different tenants, models, prompt versions or parameters never share answers; TTL; bounded size; opt out for personal or time-sensitive prompts. Entities: CacheEntry, SemanticCache, CachedLLM (Decorator/Proxy over the model).

from __future__ import annotations

import math
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Callable


@dataclass
class CacheEntry:
    prompt: str
    embedding: tuple[float, ...]
    response: str
    created: float
    scope: str                  # tenant + model + prompt-template version + params


def _cos(a: tuple[float, ...], b: tuple[float, ...]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    n = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
    return dot / n if n else 0.0


class SemanticCache:
    """Exact-match fast path, then nearest-neighbour over embeddings within the same scope.
    Linear scan for clarity; a real one delegates the similarity search to a VectorStore."""

    def __init__(self, embed: Callable[[str], tuple[float, ...]], threshold: float = 0.92,
                 ttl_s: float = 3600, max_entries: int = 10_000, clock: Callable[[], float] = time.time) -> None:
        self.embed, self.threshold, self.ttl, self.max = embed, threshold, ttl_s, max_entries
        self.clock = clock
        self._exact: OrderedDict[tuple[str, str], CacheEntry] = OrderedDict()
        self._lock = threading.Lock()
        self.hits = self.semantic_hits = self.misses = 0

    @staticmethod
    def _norm(p: str) -> str:
        return " ".join(p.lower().split())

    def get(self, prompt: str, scope: str) -> str | None:
        now = self.clock()
        key = (scope, self._norm(prompt))
        with self._lock:
            e = self._exact.get(key)
            if e and now - e.created < self.ttl:
                self._exact.move_to_end(key)
                self.hits += 1
                return e.response
        q = self.embed(prompt)                                      # embed outside the lock (slow)
        with self._lock:
            best, best_score = None, -1.0
            for e in self._exact.values():
                if e.scope != scope or now - e.created >= self.ttl:
                    continue
                s = _cos(q, e.embedding)
                if s > best_score:
                    best, best_score = e, s
            if best and best_score >= self.threshold:
                self.semantic_hits += 1
                return best.response
            self.misses += 1
            return None

    def put(self, prompt: str, scope: str, response: str) -> None:
        e = CacheEntry(prompt, self.embed(prompt), response, self.clock(), scope)
        with self._lock:
            self._exact[(scope, self._norm(prompt))] = e
            while len(self._exact) > self.max:
                self._exact.popitem(last=False)                     # LRU eviction


class CachedLLM:
    def __init__(self, llm: Callable[[str], str], cache: SemanticCache,
                 cacheable: Callable[[str], bool] = lambda p: True) -> None:
        self.llm, self.cache, self.cacheable = llm, cache, cacheable

    def ask(self, prompt: str, scope: str) -> str:
        if not self.cacheable(prompt):                              # e.g. personal or time-sensitive
            return self.llm(prompt)
        if (hit := self.cache.get(prompt, scope)) is not None:
            return hit
        out = self.llm(prompt)
        self.cache.put(prompt, scope, out)
        return out


def toy_embed(t: str) -> tuple[float, ...]:
    words = set(t.lower().replace("?", "").split())
    vocab = ["refund", "policy", "return", "shipping", "time", "what", "is", "your"]
    return tuple(1.0 if w in words else 0.0 for w in vocab)


calls: list[str] = []
llm = CachedLLM(lambda p: calls.append(p) or f"answer to {p}", SemanticCache(toy_embed, threshold=0.85))
llm.ask("What is your refund policy?", "acme:v3")
llm.ask("what is your  REFUND policy?", "acme:v3")      # exact hit after normalisation
llm.ask("your refund policy is what", "acme:v3")        # semantic hit
llm.ask("What is your refund policy?", "globex:v3")     # different scope: miss
assert len(calls) == 2

Design decisions: the scope key is the safety feature: it includes tenant, model, prompt-template version and generation parameters. The threshold is a precision/recall knob, and a false hit returns a wrong answer confidently, so tune it on labelled pairs and start strict. The embedding call happens outside the lock (it's slow). Normalised exact matching catches the cheap cases before any embedding. The linear scan is for clarity; a real implementation delegates nearest-neighbour search to the vector store from 8f.

Extensions: invalidation when source documents change (tag entries with the document IDs used to answer), per-route thresholds, caching only for temperature=0 requests, hit-rate and false-hit monitoring (sample hits for human or LLM-judge review), and provider-side prompt caching, which is a different thing (it caches the prompt prefix computation, not the answer).

Common mistake

A global cache across tenants (data leak), ignoring the system prompt or model in the key, caching personalised or time-dependent answers ("what's my balance?", "what's today's date?"), and choosing a threshold without measuring false hits.

Go deeper

9. Testing your design

You rarely have time for a full test suite in the round, but two or three well-chosen tests are a strong signal, and a design that's easy to test is a design with good seams. Aim for: one happy-path test, one boundary test (exactly at capacity, exactly at TTL), one error test, and, if concurrency is in scope, one invariant test under contention.

Unit tests in the round

Use whatever is fastest: plain assert statements in a main block (as every snippet on this page does) are fine in a machine-coding round; unittest or pytest look more professional if you have a minute. The key enabler is the injected clock.

import unittest
from datetime import datetime, timedelta


class FakeClock:
    def __init__(self, t: datetime) -> None:
        self.t = t

    def now(self) -> datetime:
        return self.t

    def advance(self, **kw: float) -> None:
        self.t += timedelta(**kw)


class Session:
    def __init__(self, clock: FakeClock, ttl: timedelta) -> None:
        self.clock, self.ttl = clock, ttl
        self.started = clock.now()

    def expired(self) -> bool:
        return self.clock.now() - self.started >= self.ttl


class SessionTest(unittest.TestCase):
    def setUp(self) -> None:
        self.clock = FakeClock(datetime(2026, 1, 1))
        self.s = Session(self.clock, timedelta(minutes=10))

    def test_not_expired_before_ttl(self) -> None:
        self.clock.advance(minutes=9, seconds=59)
        self.assertFalse(self.s.expired())

    def test_expired_exactly_at_ttl(self) -> None:          # boundary cases are where bugs live
        self.clock.advance(minutes=10)
        self.assertTrue(self.s.expired())


if __name__ == "__main__":
    unittest.main(argv=["x"], exit=False)

Fakes vs mocks (vs stubs)

DoubleWhat it isUse it for
StubReturns canned answersForcing a branch: "payment declined"
FakeA working, simplified implementation (in-memory repository, fake clock, scripted LLM)State-based tests: run the real logic, then assert on the resulting state. Usually more robust.
MockRecords calls so you can assert on interactionsVerifying a side effect happened (or didn't): "no notification was sent when payment failed"
SpyWraps a real object and records callsChecking calls while keeping real behaviour

Fowler's essay is the standard explanation of the difference between state verification (fakes/stubs) and behaviour verification (mocks), and of the trade-offs. Fowler: Mocks Aren't Stubs In LLD rounds, prefer fakes for your own interfaces (they double as documentation of the contract) and mocks for verifying calls to external collaborators. Use Mock(spec=RealClass) so a typo or a renamed method fails the test instead of silently passing. Python docs: unittest.mock

from unittest.mock import Mock


class OrderService:
    def __init__(self, payments, notifier) -> None:
        self.payments, self.notifier = payments, notifier

    def checkout(self, user: str, cents: int) -> bool:
        if not self.payments.charge(user, cents):
            return False
        self.notifier.notify(user, "paid")
        return True


# Mock: verify an interaction happened (or didn't)
payments = Mock()
payments.charge.return_value = False
notifier = Mock()
assert OrderService(payments, notifier).checkout("u1", 500) is False
payments.charge.assert_called_once_with("u1", 500)
notifier.notify.assert_not_called()

# Mock(spec=...) catches typos and calls to methods that don't exist on the real class
class RealNotifier:
    def notify(self, to: str, msg: str) -> None: ...

strict = Mock(spec=RealNotifier)
try:
    strict.notfy("u", "x")          # typo -> AttributeError instead of a silently passing test
except AttributeError:
    pass

Testing concurrency

Concurrency bugs are probabilistic, so tests can show the presence of races but never prove their absence. Make them as sharp as you can:

import threading


def run_concurrently(fn, n_threads: int, iterations: int) -> None:
    """Release all threads at once with a Barrier to maximise interleaving."""
    barrier = threading.Barrier(n_threads)
    errors: list[BaseException] = []

    def body() -> None:
        try:
            barrier.wait()
            for _ in range(iterations):
                fn()
        except BaseException as e:            # surface failures from worker threads
            errors.append(e)

    ts = [threading.Thread(target=body) for _ in range(n_threads)]
    for t in ts: t.start()
    for t in ts: t.join(timeout=10)
    assert not any(t.is_alive() for t in ts), "possible deadlock: threads still running"
    if errors:
        raise errors[0]


class Inventory:
    def __init__(self, stock: int) -> None:
        self.stock, self.sold = stock, 0
        self._lock = threading.Lock()

    def buy(self) -> bool:
        with self._lock:
            if self.stock == 0:
                return False
            self.stock -= 1
            self.sold += 1
            return True


inv = Inventory(stock=500)
run_concurrently(inv.buy, n_threads=16, iterations=100)
assert inv.sold == 500 and inv.stock == 0      # invariant: never oversell
Common mistake

Using time.sleep() in tests to "wait for the other thread" (slow and flaky), testing private methods, and over-mocking so the test just restates the implementation.

Go deeper

10. Machine-coding round tactics

Project skeleton

Keep it small and conventional. One file is fine for 60 minutes; for 90–120 minutes a package like this makes extension and review easy:

booking/ models.py # enums + dataclasses (value objects, entities). No logic beyond invariants. strategies.py # interfaces + implementations (pricing, allocation, ...) repository.py # in-memory repositories behind interfaces service.py # the facade: use cases, locking, orchestration exceptions.py # DomainError hierarchy cli.py # parse input lines -> Commands -> call service -> print output main.py # composition root: build objects, wire dependencies, run demo/CLI tests/ test_service.py # happy path, boundary, error, concurrency invariant

What to build first

  1. Models (enums, dataclasses): 5 minutes. They force decisions about state.
  2. The service with the one or two core use cases, with no strategies yet (hard-code the first pricing rule inline if needed).
  3. A demo driver in main.py that exercises the core use case and prints the expected output. Now you have something runnable. Commit mentally: this is your safety net.
  4. Extract the interfaces where variation was mentioned, and move the hard-coded rule into the first strategy.
  5. Remaining use cases, in order of importance from the problem statement.
  6. Edge cases and errors: custom exceptions, input validation.
  7. Concurrency if in scope, then tests.
  8. Bonus requirements only after everything above works.

Time management

Using an AI assistant or IDE in the round

Some companies now allow, or even expect, AI coding assistants in machine-coding rounds; many still forbid them. Ask at the start. If allowed, use it for boilerplate (dataclasses, test scaffolding), keep the design decisions visibly yours, and read every line it writes, because you'll be asked to explain and extend it. B7 covers working with coding agents in general.

May be out of date

Policies on AI assistants in interviews vary widely between companies and have been changing quickly. Confirm the rules with your recruiter before the round.

Presenting at the end

Reserve 5–7 minutes. A good walkthrough takes about three minutes and follows this structure:

  1. Run the demo (or tests) first, so they see it works.
  2. Scope recap: "I built X, Y, Z; I left out W as agreed."
  3. Structure tour: models → interfaces → service, one sentence each.
  4. Two design decisions with their trade-offs: "Pricing is a Strategy, so the weekend rule is one new class. One lock per show, because…"
  5. Known limitations and next steps: "Holds expire lazily; with more time I'd add a sweeper and persistence via the repository interface." Raising these yourself scores better than having them pointed out.
  6. Invite the extension question. Then answer it by pointing at the seam you prepared.
Interview angle

In "extend this codebase" rounds, the first 10 minutes should be reading: entry point, main data flow, existing tests, and where the requested change naturally lives. Say what you've found before editing. Match the existing style, even if you'd have designed it differently, and run the existing tests before and after.

11. Interview question bank

Composition vs inheritance: when do you use each?

Inheritance models a stable is-a relationship where every subclass honours the parent's contract (LSP). It's good for an interface with a shared skeleton, like a Template Method base class. Composition models has-a or uses-a: the object delegates to collaborators behind interfaces, which can vary per instance and at runtime. I default to composition because inheritance couples the child to the parent's implementation, multiplies classes when there are several independent dimensions of variation, and is fixed at class-definition time. Concretely: Duck composes a FlyBehavior rather than having FlyingDuck/RubberDuck subclasses; ParkingLot composes a PricingStrategy. I still use inheritance for ABC-style interfaces and genuinely substitutable subtypes.

When is a Singleton acceptable, and how do you make one thread-safe?

It's acceptable for truly process-wide, stateless or read-mostly resources: a logger, a metrics registry, a connection pool, configuration loaded once. It's a smell when it's used for convenience to avoid passing dependencies, because it hides dependencies, makes tests share state, and makes "only one" a property of the class instead of a wiring decision. Thread-safe options in Python: a module-level instance (modules are initialised once), or double-checked locking in __new__. In Java: an enum singleton, or the initialization-on-demand holder idiom, both relying on the JVM's class-initialisation guarantees. My preferred answer is usually to create one instance at the composition root and inject it.

How would you make your LRU cache thread-safe? Why not a read-write lock?

Wrap get and put in one mutex. Both mutate the linked list, since get moves the node to the front, so every operation is a write and a read-write lock gives no extra concurrency, only overhead. For higher throughput, shard: N independent LRU caches selected by hash(key) % N, each with its own lock. That gives approximate global LRU with roughly N times less contention. Another option is to sample recency (as some production caches do) or batch recency updates, trading exactness for throughput.

How would you extend your parking lot for EV charging?

Model capability as data: add a features: frozenset[SpotFeature] to Spot with EV_CHARGER, and add needs_charging to the park request. The allocator filters candidates with a predicate (or a Specification: size_fits & has_feature(EV_CHARGER)), with a policy for whether EVs may take regular spots when chargers are full and whether non-EVs may take charger spots. Pricing gets a Decorator, ChargingFee(HourlyPricing()), that adds energy cost (kWh from the charger, or time-based). Charger state (available / charging / faulty) is a small state machine per charger, and an Observer can notify the driver when charging completes. Nothing in park/unpark needs restructuring, which is the point of the original seams.

Two users click "book" on the same seat at the same millisecond. Walk me through what happens.

Both call hold(show, [seat]). Both try to take the show's lock; one wins, sees the seat AVAILABLE, marks it HELD with its hold ID and a TTL, and releases the lock. The second then gets the lock, sees HELD and not expired, and gets SeatsUnavailable. The winner pays outside the lock with the hold ID as the idempotency key, then re-takes the lock to confirm, re-checking that the seat is still held by its hold ID. With a database, the same thing is a conditional UPDATE … WHERE state='AVAILABLE' checked by affected-row count, or SELECT … FOR UPDATE in a transaction. Either way, the database's row-level atomicity is what serialises the two requests.

Token bucket vs sliding window: which would you choose for a public API?

Token bucket by default: O(1) memory per key, allows short bursts up to capacity (which matches how real clients behave), easy to explain, and easy to compute a retry-after ((cost − tokens)/rate). Sliding-window log is exact but stores a timestamp per request, so it suits low limits where fairness matters. Sliding-window counter is the O(1) approximation used at high volume when you want "N per rolling minute" semantics without boundary bursts. Fixed window is fine for coarse daily quotas but allows 2N across a window boundary. I'd make the algorithm a Strategy behind a RateLimiter interface and choose per endpoint.

What's the difference between the Strategy and State patterns? They look identical.

Structurally they are nearly the same: a context delegates to an interchangeable object. The difference is who changes it and why. A Strategy is chosen by the client (or configuration) and usually stays fixed for the operation; strategies don't know about each other. A State is changed by the context or by the states themselves as a result of operations (Idle → HasMoney after a coin is inserted), and states know their possible successors. If the swap is driven by the object's own lifecycle, it's State; if it's an externally chosen algorithm, it's Strategy.

Decorator vs Proxy vs Adapter?

All three wrap an object. An Adapter changes the interface: it makes a third-party class fit the interface your code expects. A Decorator keeps the interface and adds behaviour (caching, logging, retry), and decorators are designed to stack. A Proxy keeps the interface and controls access to the subject (lazy creation, permission checks, remote calls), often managing the subject's lifecycle. In code, Decorator and Proxy can look identical; the difference is intent. My LLM client's RetryingProvider is a decorator, the vendor wrapper is an adapter, and a lazily-initialised client that only connects on first use would be a proxy.

Explain the Open/Closed Principle with an example from your design.

Classes should be open for extension and closed for modification: new behaviour comes from new code, not from editing tested code. In the parking lot, pricing is a PricingStrategy interface; adding weekend pricing means writing WeekendPricing and wiring it in main, with no change to ParkingLot. The violation would be if day in ("Sat", "Sun"): … inside unpark, which grows with every rule and risks breaking existing ones. The practical limit is that you can only be "closed" against the kinds of change you anticipated, so put the seams where the interviewer or the domain hints at variation.

Give an example of a Liskov Substitution violation you might accidentally write in an LLD round.

A ReadOnlyRepository subclass of Repository whose save raises NotImplementedError. Any code that accepts a Repository and calls save now breaks. Others: a mutable Square(Rectangle), a FreeParkingPricing that returns None instead of 0 (weakened postcondition), or a subclass that requires a non-empty list where the parent accepted any list (strengthened precondition). The fix is usually interface segregation: split Reader and Writer interfaces so the read-only class implements only Reader.

How do you prevent deadlock when a booking needs seats from two shows (or a transfer touches two accounts)?

Deadlock needs mutual exclusion, hold-and-wait, no preemption and circular wait; the practical one to break is circular wait. Always acquire locks in a global order, such as sorted by show ID or account ID, so two transactions touching the same pair can't each hold one and wait for the other. Alternatives: try-lock with a timeout and back off (breaks hold-and-wait), or one coarser lock covering both (less concurrency, no ordering problem). Also never call external code, especially network calls, while holding a lock, because that's where hidden lock cycles and long waits come from.

Optimistic vs pessimistic locking: when would you use each in a booking system?

Pessimistic (SELECT … FOR UPDATE, mutexes) when contention is high and retries are expensive: a hot show's seats at release time. Optimistic (version column, UPDATE … WHERE version = ?, retry on conflict) when conflicts are rare and you want throughput without holding locks: user profile edits, most seats most of the time. A single conditional update (WHERE state='AVAILABLE') is effectively optimistic and needs no explicit lock at all. It's my default for seat holds in a database because it's one round trip and can't deadlock.

What is idempotency and where does it matter in your designs?

An operation is idempotent if repeating it has the same effect as doing it once. It matters wherever retries happen: clients retrying after timeouts, at-least-once message delivery, workflow steps resumed after a crash, agent tools re-invoked by a retried step. For non-idempotent operations like charges or bookings, the client supplies an idempotency key; the server stores the result keyed by it (under a unique constraint) and returns the stored result on replay. In the booking design, the hold ID is the payment idempotency key; in the pub-sub design, consumers must be idempotent because delivery is at-least-once.

Does Python's GIL make my code thread-safe?

No. The GIL ensures only one thread runs Python bytecode at a time in standard CPython, but threads can be switched between any two bytecodes, so compound operations like counter += 1 or check-then-act still race. Some single operations on built-in types happen to be atomic, but relying on that is an implementation detail and breaks on the free-threaded build introduced via PEP 703. Use locks for compound operations, or queue.Queue to pass data. The GIL mainly affects performance: threads help with I/O-bound work, not CPU-bound work, where you use processes.

Threads or asyncio for an LLM client that fans out many requests?

asyncio is the natural fit: LLM calls are long network waits, and one event loop can hold thousands of in-flight requests cheaply. Concurrency is capped with an asyncio.Semaphore per provider, timeouts come from asyncio.timeout or the HTTP client, and streaming maps onto async iterators. Shared state needs locks only around critical sections that contain an await. Threads are fine if the codebase or SDK is synchronous; a ThreadPoolExecutor with bounded workers gives similar fan-out at higher per-request overhead. Never call blocking code inside a coroutine, because it stalls the whole loop.

Design prompt: a URL shortener at LLD level. Sketch it.

Entities: ShortLink (code, long URL, owner, created/expiry, click count), CodeGenerator interface (base-62 counter, random with collision retry, or hash-based: Strategy), LinkRepository (in-memory dict behind an interface), ShortenerService (facade: shorten(url, custom_alias=None, ttl=None), resolve(code)), and an analytics Observer that records clicks asynchronously via a queue so redirects stay fast. Validation: URL format, alias charset and uniqueness, reserved words. Concurrency: the counter is an atomic increment under a lock; custom aliases use put-if-absent under the repository lock. Extensions: expiry (lazy check on resolve), per-user rate limits (Decorator with a token bucket), and the distributed version (ID ranges per server), which is where it turns into HLD.

Design prompt: an ATM. Which patterns, and where?

State for the session: Idle → CardInserted → Authenticated → TransactionSelected → Dispensing → Idle, with invalid actions rejected per state. Chain of Responsibility for cash dispensing: a 2000-note handler takes what it can and passes the remainder to the 500 handler, then 200, then 100; if a remainder is left, roll back. Strategy or Command for transaction types (withdraw, deposit, balance, transfer). An Adapter for the bank's backend. Concurrency: the ATM itself serves one user at a time, but the account balance is shared with other channels, so the debit must be an atomic, idempotent operation on the bank side (keyed by transaction ID), and the cash is dispensed only after the debit succeeds, with a reversal if dispensing fails.

Design prompt: a library management system in 10 minutes.

Clarify: members borrow and return copies, reserve when none are available, fines for late returns, librarian adds books. Entities: Book (ISBN, title, authors: the catalogue entry), BookCopy (barcode, status: AVAILABLE/BORROWED/RESERVED/LOST), Member, Loan (copy, member, due date, returned at), Reservation (queue per book), FinePolicy (Strategy), LibraryService facade, Catalog search with Specifications (author, title, availability). Key rules: borrow limit per member, a returned copy goes to the head of the reservation queue (Observer notifies them), fine computed on return via the injected clock. Book vs BookCopy is the modelling point interviewers look for, as is the copy-status state machine.

Design prompt: a task scheduler that runs jobs at given times (like a mini cron).

Entities: Job (id, callable or command, schedule), Schedule interface (one-shot at time T, fixed interval, cron expression: Strategy with next_run_after(t)), and a Scheduler with a min-heap of (next_run, seq, job_id). A dispatcher thread waits on a condition variable until the earliest run time or until a new job is added (notify wakes it to re-check the heap head), then hands due jobs to a worker pool so a slow job doesn't delay others. Cancellation marks the job cancelled and is checked lazily when it's popped. Concerns: injected clock for tests, misfire policy (skip or catch up after downtime), overlapping runs of the same job (allow, skip or queue), retries with backoff, and persistence of next-run times if it must survive restarts.

How do you decide which classes deserve an interface?

Put an interface where there's real or likely variation, or where you need a test seam. Variation: multiple algorithms (pricing, allocation, eviction), multiple external providers (payment, SMS, LLM vendors), multiple storage backends. Test seams: anything non-deterministic or slow (clock, RNG, network, file system). Value objects, enums and plain entities don't need interfaces. Neither does the service facade, usually. An interface with one implementation is fine when it's a seam for a named extension or a test double; an interface per class "just in case" is over-engineering and reads badly from a senior candidate.

Design prompt: an LLM gateway used by several internal teams. What classes would you start with?

At LLD level: an LLMProvider interface with one Adapter per vendor; a stack of Decorators around it for retries with jittered backoff, circuit breaking, metering and logging; a FallbackChain for cross-provider failover; a TokenAwareScheduler enforcing per-team RPM/TPM quotas with estimate-then-settle accounting; a PromptRegistry if teams share prompts; a SemanticCache scoped by team, model and prompt version; and a UsageLedger for chargeback. Value objects for requests, responses and usage keep everything provider-neutral. Then I'd mention the HLD side (stateless gateway instances, shared quota state in Redis, async logging pipeline) and point to which classes would change: only the bucket storage and the ledger sink.

How would you test an agent loop without calling a real model?

Inject the model as an interface (or a plain callable) and use a scripted fake that returns a fixed sequence of turns: a tool call with bad arguments, then a corrected one, then a final answer. Assert on the transcript: the validation error was fed back as a tool result, the tool ran with the right arguments, and the loop stopped with the right reason. Add a fake that always calls a tool, to test the max-steps guard, and a tool that raises, to check the loop records the failure and continues. Tools themselves get ordinary unit tests. Real-model behaviour is then covered separately by evals, not unit tests, because it's non-deterministic.

What would you do differently if your in-memory design had to run on 50 servers?

Separate what's per-process from what must be shared. The class structure mostly survives; the storage and synchronisation change. Shared mutable state moves to a store with atomic operations: seat holds become conditional updates or set-if-absent-with-TTL keys, rate-limit buckets become atomic server-side scripts, counters become atomic increments. In-process locks become database row locks, optimistic versioning or, sparingly, distributed locks with fencing tokens. Idempotency keys become essential because retries now cross the network. Observers become a message broker with at-least-once delivery and idempotent consumers. Because the domain code depends on repository and limiter interfaces, most of these changes are new implementations of existing interfaces rather than rewrites.

You're 50 minutes into a 60-minute round and the code doesn't run. What do you do?

Stop adding features. Cut scope to the single core use case, stub anything blocking it with the simplest possible version (hard-coded strategy, no persistence), and get one end-to-end path running. Then use the remaining minutes to explain the design you intended: point at the interfaces, say where the stubbed pieces go, and name the edge cases and concurrency handling you'd add. Interviewers weigh a narrow working slice plus a clear explanation far above a broad design that never executed, and saying calmly what you're cutting and why is itself a positive signal.

Go deeper