Low-Level Design

All 33 notes on one page

Foundations

1

What is Low-Level Design?

beginner 0-2 YOE lld interview-prep basics

Low-Level Design (LLD) is about designing the code structure of a system. We’re talking about classes, interfaces, methods, relationships between objects — basically, how we’d actually write the code before we start coding.

In simple language, if High-Level Design is the blueprint of a building, LLD is the detailed plan for each room — where the wiring goes, where the plumbing fits, what material each wall uses.

HLD vs LLD — What’s the Difference?

This confuses a lot of people, so let’s clear it up.

HLD (High-Level Design) answers: “What components do we need and how do they talk to each other?” Think databases, services, load balancers, message queues.

LLD (Low-Level Design) answers: “How do we structure the code inside one of those components?” Think classes, interfaces, design patterns, method signatures.

HLD vs LLD Comparison
HLD (System Design)
- Architecture diagrams
- Database choices
- API contracts
- Service communication
- Scaling strategies
- "Which services do we need?"
LLD (Code Design)
- Class diagrams
- Design patterns
- Interfaces & abstractions
- Object relationships
- SOLID principles
- "How do we structure the code?"

What Do Interviewers Look For?

LLD interviews aren’t about writing perfect production code. Here’s what actually matters:

  1. Requirement gathering — Can we ask the right questions to scope the problem?
  2. Class identification — Can we figure out the right classes and their responsibilities?
  3. Relationships — Do we understand when to use inheritance vs composition?
  4. Design patterns — Can we apply the right pattern for the right situation?
  5. Extensibility — Is our design easy to extend without modifying existing code?
  6. Clean code — Are our naming conventions, method sizes, and class structures sensible?

The biggest red flag? A class that does everything. If we have a ParkingLotManager that handles payments, assigns spots, manages vehicles, AND sends notifications — that’s a problem.

The Typical LLD Interview Format

Most LLD rounds follow this pattern in a 45-60 minute window:

First 5-10 minutes: Requirements The interviewer gives us a vague problem like “Design a parking lot system.” We ask clarifying questions. How many floors? What vehicle types? Do we need payment? Real-time availability?

Next 10-15 minutes: Class Design We identify the main classes, their attributes, and methods. We might sketch a UML diagram or just list them out. This is where we show we understand OOP and SOLID principles.

Remaining 20-30 minutes: Code We write the core classes. Not the entire system — just enough to show the design works. The interviewer might ask us to extend it (e.g., “now add motorcycle support”) to test how flexible our design is.

Key Skills We Need

Before diving into design patterns and interview questions, we need solid foundations:

  • OOP fundamentals — the four pillars (encapsulation, abstraction, inheritance, polymorphism)
  • SOLID principles — the five rules that keep our code flexible
  • Composition vs Inheritance — knowing when to use “has-a” vs “is-a”
  • Design patterns — common solutions to common problems (Strategy, Observer, Factory, etc.)
  • UML basics — enough to sketch class diagrams quickly

That’s exactly what we’ll cover in this collection. We start with OOP foundations, move through SOLID, explore major design patterns, and then tackle real LLD interview questions like Parking Lot, Elevator System, and more.

The goal isn’t to memorize designs — it’s to build the intuition to decompose any problem into clean, extensible classes.


2

OOP: The Four Pillars

beginner 0-2 YOE lld oop encapsulation abstraction inheritance polymorphism

Every LLD interview assumes we know OOP cold. These four pillars are the building blocks of everything we’ll design. Let’s break each one down with real-world analogies and code.

1. Encapsulation

Encapsulation means bundling data and the methods that operate on that data into a single unit (a class), and hiding the internal state from the outside world.

Think of it like a TV remote. We press “volume up” and the volume goes up. We don’t need to know about the circuits inside. The remote encapsulates its internal workings and gives us a clean interface (buttons).

class BankAccount:
    def __init__(self, owner, balance):
        self._owner = owner       # "protected" by convention
        self.__balance = balance   # "private" -- name-mangled

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    def get_balance(self):
        return self.__balance

account = BankAccount("Alice", 1000)
account.deposit(500)
print(account.get_balance())  # 1500
# account.__balance  # AttributeError -- can't touch it directly
class BankAccount {
  #balance; // private field (ES2022)

  constructor(owner, balance) {
    this.owner = owner;
    this.#balance = balance;
  }

  deposit(amount) {
    if (amount > 0) this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// account.#balance  // SyntaxError -- can't access private field
public class BankAccount {
    private String owner;
    private double balance;

    public BankAccount(String owner, double balance) {
        this.owner = owner;
        this.balance = balance;
    }

    public void deposit(double amount) {
        if (amount > 0) this.balance += amount;
    }

    public double getBalance() {
        return this.balance;
    }
}
// BankAccount account = new BankAccount("Alice", 1000);
// account.balance  // Compile error -- private

The key idea: nobody outside the class can mess with balance directly. They must go through deposit(). This way we control the rules (no negative deposits, logging, etc.).

2. Abstraction

Abstraction means hiding complex implementation details and exposing only what’s necessary.

In simple language, when we drive a car, we use the steering wheel and pedals. We don’t need to understand fuel injection or the transmission system. The car abstracts all that complexity away.

The only difference between encapsulation and abstraction: encapsulation is about hiding data, abstraction is about hiding complexity.

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def pay(self, amount):
        pass  # subclasses MUST implement this

class CreditCardPayment(PaymentProcessor):
    def pay(self, amount):
        # complex credit card logic hidden inside
        print(f"Charged ${amount} to credit card")

class UPIPayment(PaymentProcessor):
    def pay(self, amount):
        # complex UPI logic hidden inside
        print(f"Paid ${amount} via UPI")

# We just call .pay() -- don't care about the internals
payment = UPIPayment()
payment.pay(500)
// JS doesn't have formal abstract classes, but we can simulate
class PaymentProcessor {
  pay(amount) {
    throw new Error("Subclass must implement pay()");
  }
}

class CreditCardPayment extends PaymentProcessor {
  pay(amount) {
    console.log(`Charged $${amount} to credit card`);
  }
}

class UPIPayment extends PaymentProcessor {
  pay(amount) {
    console.log(`Paid $${amount} via UPI`);
  }
}

const payment = new UPIPayment();
payment.pay(500);
abstract class PaymentProcessor {
    abstract void pay(double amount);
}

class CreditCardPayment extends PaymentProcessor {
    void pay(double amount) {
        System.out.println("Charged $" + amount + " to credit card");
    }
}

class UPIPayment extends PaymentProcessor {
    void pay(double amount) {
        System.out.println("Paid $" + amount + " via UPI");
    }
}
// PaymentProcessor p = new UPIPayment();
// p.pay(500);  // We don't care HOW it pays

3. Inheritance

Inheritance lets a class reuse code from a parent class. It models an “is-a” relationship.

Think of it like this: a Dog is an Animal. So Dog inherits common stuff from Animal (like eat(), sleep()) and adds its own behavior (like bark()).

class Animal:
    def __init__(self, name):
        self.name = name

    def eat(self):
        print(f"{self.name} is eating")

class Dog(Animal):
    def bark(self):
        print(f"{self.name} says Woof!")

dog = Dog("Buddy")
dog.eat()   # inherited from Animal
dog.bark()  # Dog's own method
class Animal {
  constructor(name) {
    this.name = name;
  }

  eat() {
    console.log(`${this.name} is eating`);
  }
}

class Dog extends Animal {
  bark() {
    console.log(`${this.name} says Woof!`);
  }
}

const dog = new Dog("Buddy");
dog.eat();  // inherited from Animal
dog.bark(); // Dog's own method
class Animal {
    String name;

    Animal(String name) { this.name = name; }

    void eat() {
        System.out.println(name + " is eating");
    }
}

class Dog extends Animal {
    Dog(String name) { super(name); }

    void bark() {
        System.out.println(name + " says Woof!");
    }
}
// Dog dog = new Dog("Buddy");
// dog.eat();  // inherited
// dog.bark(); // Dog's own

4. Polymorphism

Polymorphism means same method name, different behavior depending on the object. The word literally means “many forms.”

Think of it like the word “open.” We can open a door, open a file, open a conversation — same word, totally different actions depending on context.

class Shape:
    def area(self):
        raise NotImplementedError

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

# Same method call, different behavior
shapes = [Circle(5), Rectangle(4, 6)]
for shape in shapes:
    print(shape.area())  # 78.5, then 24
class Shape {
  area() {
    throw new Error("Must implement area()");
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  area() {
    return 3.14 * this.radius ** 2;
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

const shapes = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach((s) => console.log(s.area())); // 78.5, then 24
abstract class Shape {
    abstract double area();
}

class Circle extends Shape {
    double radius;
    Circle(double radius) { this.radius = radius; }
    double area() { return 3.14 * radius * radius; }
}

class Rectangle extends Shape {
    double width, height;
    Rectangle(double w, double h) { width = w; height = h; }
    double area() { return width * height; }
}

// Shape[] shapes = {new Circle(5), new Rectangle(4, 6)};
// for (Shape s : shapes) System.out.println(s.area());

The magic here: we loop through a list of Shape objects. We don’t know (or care) if it’s a Circle or Rectangle. We just call .area() and the right implementation runs. This is runtime polymorphism (method overriding).

Quick Recap

PillarWhat It DoesReal-World Analogy
EncapsulationHides internal state, exposes methodsTV remote (buttons hide circuitry)
AbstractionHides complexity behind a simple interfaceCar steering wheel (hides engine)
InheritanceReuses code through parent-child classesDog is an Animal
PolymorphismSame method, different behavior”Open” a door vs “open” a file

These four show up in every LLD interview. Not as direct questions, but through how we design our classes. If our Parking Lot design has good encapsulation, clean abstractions, proper inheritance, and polymorphism where needed — the interviewer knows we get OOP.


3

Composition vs Inheritance

beginner 0-2 YOE lld oop composition inheritance design

“Favor composition over inheritance” is one of the most quoted principles in software design. But what does it actually mean, and when should we pick one over the other?

Inheritance: The “Is-A” Relationship

Inheritance says: “A Dog is an Animal.” The child class gets everything from the parent.

Animal
  └── Dog
  └── Cat

This works great for simple hierarchies. But problems creep in when things get deeper or wider.

Composition: The “Has-A” Relationship

Composition says: “A Car has an Engine.” Instead of inheriting behavior, we plug in objects that provide that behavior.

Car
  ├── has Engine
  ├── has Transmission
  └── has GPS

In simple language, inheritance is about what something IS. Composition is about what something HAS or what it can DO.

The Problem with Deep Inheritance

Let’s say we’re building a game with characters. We start with inheritance:

Character
  └── Warrior (can fight)
       └── FlyingWarrior (can fight + fly)
            └── MagicFlyingWarrior (can fight + fly + cast spells)

Now the designer says: “We need a Mage that can cast spells but can’t fly.” Uh oh. The spell-casting logic is buried inside MagicFlyingWarrior. We’d have to duplicate code or create a weird parallel hierarchy. This is called the diamond problem or fragile base class problem.

Composition to the Rescue

With composition, we break abilities into separate components and mix-and-match:

class FightAbility:
    def fight(self):
        print("Swinging sword!")

class FlyAbility:
    def fly(self):
        print("Soaring through the sky!")

class MagicAbility:
    def cast_spell(self):
        print("Casting fireball!")

class Warrior:
    def __init__(self):
        self.fighting = FightAbility()

class FlyingWarrior:
    def __init__(self):
        self.fighting = FightAbility()
        self.flying = FlyAbility()

class Mage:
    def __init__(self):
        self.magic = MagicAbility()

# Mix and match -- no hierarchy headaches
mage = Mage()
mage.magic.cast_spell()

warrior = FlyingWarrior()
warrior.fighting.fight()
warrior.flying.fly()
class FightAbility {
  fight() {
    console.log("Swinging sword!");
  }
}

class FlyAbility {
  fly() {
    console.log("Soaring through the sky!");
  }
}

class MagicAbility {
  castSpell() {
    console.log("Casting fireball!");
  }
}

class Warrior {
  constructor() {
    this.fighting = new FightAbility();
  }
}

class FlyingWarrior {
  constructor() {
    this.fighting = new FightAbility();
    this.flying = new FlyAbility();
  }
}

class Mage {
  constructor() {
    this.magic = new MagicAbility();
  }
}

const mage = new Mage();
mage.magic.castSpell();
class FightAbility {
    void fight() { System.out.println("Swinging sword!"); }
}

class FlyAbility {
    void fly() { System.out.println("Soaring through the sky!"); }
}

class MagicAbility {
    void castSpell() { System.out.println("Casting fireball!"); }
}

class Warrior {
    FightAbility fighting = new FightAbility();
}

class FlyingWarrior {
    FightAbility fighting = new FightAbility();
    FlyAbility flying = new FlyAbility();
}

class Mage {
    MagicAbility magic = new MagicAbility();
}

// Mage mage = new Mage();
// mage.magic.castSpell();

Now if the designer wants a MagicWarrior, we just create a class with both FightAbility and MagicAbility. No inheritance chain to untangle.

Same Problem, Two Approaches

Let’s see a more realistic example — a notification system:

With inheritance (fragile):

class Notifier:
    def send(self, message):
        print(f"Email: {message}")

class SMSNotifier(Notifier):
    def send(self, message):
        print(f"SMS: {message}")

# What if we want BOTH email AND SMS? Uh oh...
# Multiple inheritance? That gets messy fast.
class Notifier {
  send(message) {
    console.log(`Email: ${message}`);
  }
}

class SMSNotifier extends Notifier {
  send(message) {
    console.log(`SMS: ${message}`);
  }
}

// Want both email AND SMS? Can't extend two classes...
class Notifier {
    void send(String message) {
        System.out.println("Email: " + message);
    }
}

class SMSNotifier extends Notifier {
    void send(String message) {
        System.out.println("SMS: " + message);
    }
}

// Java doesn't allow multiple class inheritance.
// Stuck if we want both Email + SMS.

With composition (flexible):

class EmailSender:
    def send(self, message):
        print(f"Email: {message}")

class SMSSender:
    def send(self, message):
        print(f"SMS: {message}")

class NotificationService:
    def __init__(self, senders):
        self.senders = senders  # list of sender objects

    def notify(self, message):
        for sender in self.senders:
            sender.send(message)

# Easy -- just plug in what we need
service = NotificationService([EmailSender(), SMSSender()])
service.notify("Your order shipped!")
# Output: Email: Your order shipped!
#         SMS: Your order shipped!
class EmailSender {
  send(message) {
    console.log(`Email: ${message}`);
  }
}

class SMSSender {
  send(message) {
    console.log(`SMS: ${message}`);
  }
}

class NotificationService {
  constructor(senders) {
    this.senders = senders;
  }

  notify(message) {
    this.senders.forEach((s) => s.send(message));
  }
}

const service = new NotificationService([
  new EmailSender(),
  new SMSSender(),
]);
service.notify("Your order shipped!");
interface Sender {
    void send(String message);
}

class EmailSender implements Sender {
    public void send(String message) {
        System.out.println("Email: " + message);
    }
}

class SMSSender implements Sender {
    public void send(String message) {
        System.out.println("SMS: " + message);
    }
}

class NotificationService {
    List<Sender> senders;

    NotificationService(List<Sender> senders) {
        this.senders = senders;
    }

    void notify(String message) {
        for (Sender s : senders) s.send(message);
    }
}

Want to add Slack notifications later? Just create a SlackSender class and plug it in. No existing code changes.

When to Use Which

Use Inheritance When…Use Composition When…
There’s a clear “is-a” relationshipObjects need behaviors from multiple sources
The hierarchy is shallow (1-2 levels)We want to swap behaviors at runtime
Subclasses truly are specialized versionsThe relationship is “has-a” or “can-do”
We need to override specific behaviorWe want loose coupling

A good rule of thumb: start with composition. Only reach for inheritance when the “is-a” relationship is obvious and the hierarchy won’t grow beyond 2-3 levels.

In LLD interviews, using composition shows the interviewer we think about flexibility. And that’s exactly what they’re looking for.


4

Abstract Classes vs Interfaces

beginner 0-2 YOE lld oop abstract-class interface design

In LLD interviews, we often need to decide: should this be an abstract class or an interface? They look similar but serve different purposes. Let’s break it down.

What is an Abstract Class?

An abstract class is a class that can’t be instantiated directly. It provides a partial implementation — some methods are fully defined, others are left for subclasses to fill in.

Think of it like a recipe template. It tells us: “Here’s how to preheat the oven (done for you), but YOU decide what filling to use.”

from abc import ABC, abstractmethod

class Vehicle(ABC):
    def __init__(self, brand):
        self.brand = brand  # abstract classes CAN have state

    def start_engine(self):
        print("Turning key... engine on!")  # shared implementation

    @abstractmethod
    def fuel_type(self):
        pass  # subclasses MUST implement this

class Car(Vehicle):
    def fuel_type(self):
        return "Petrol"

class ElectricCar(Vehicle):
    def fuel_type(self):
        return "Electric"

# vehicle = Vehicle("Generic")  # TypeError -- can't instantiate
car = Car("Toyota")
car.start_engine()       # inherited: "Turning key... engine on!"
print(car.fuel_type())   # Car's own: "Petrol"
class Vehicle {
  constructor(brand) {
    if (new.target === Vehicle) {
      throw new Error("Can't instantiate Vehicle directly");
    }
    this.brand = brand;
  }

  startEngine() {
    console.log("Turning key... engine on!");
  }

  fuelType() {
    throw new Error("Subclass must implement fuelType()");
  }
}

class Car extends Vehicle {
  fuelType() {
    return "Petrol";
  }
}

class ElectricCar extends Vehicle {
  fuelType() {
    return "Electric";
  }
}

const car = new Car("Toyota");
car.startEngine();          // "Turning key... engine on!"
console.log(car.fuelType()); // "Petrol"
abstract class Vehicle {
    String brand;

    Vehicle(String brand) {
        this.brand = brand; // abstract classes CAN have state
    }

    void startEngine() {
        System.out.println("Turning key... engine on!"); // shared
    }

    abstract String fuelType(); // subclasses MUST implement
}

class Car extends Vehicle {
    Car(String brand) { super(brand); }
    String fuelType() { return "Petrol"; }
}

class ElectricCar extends Vehicle {
    ElectricCar(String brand) { super(brand); }
    String fuelType() { return "Electric"; }
}

What is an Interface?

An interface is a pure contract. It says “any class that implements me MUST have these methods” but provides no implementation and no state.

Think of it like a job description. It lists what skills are required, but doesn’t teach how to do the work.

from abc import ABC, abstractmethod

# Python uses ABCs as interfaces (no built-in interface keyword)
class Printable(ABC):
    @abstractmethod
    def print_details(self):
        pass

class Exportable(ABC):
    @abstractmethod
    def export_to_pdf(self):
        pass

# A class can implement MULTIPLE interfaces
class Invoice(Printable, Exportable):
    def print_details(self):
        print("Invoice #123 - $500")

    def export_to_pdf(self):
        print("Exporting invoice to PDF...")

invoice = Invoice()
invoice.print_details()
invoice.export_to_pdf()
// JS doesn't have interfaces natively
// We rely on duck typing or documentation

// "Interface" as a convention:
// Printable: must have printDetails()
// Exportable: must have exportToPdf()

class Invoice {
  printDetails() {
    console.log("Invoice #123 - $500");
  }

  exportToPdf() {
    console.log("Exporting invoice to PDF...");
  }
}

// In TypeScript, we'd use actual `interface` keyword:
// interface Printable { printDetails(): void; }
// interface Exportable { exportToPdf(): void; }
// class Invoice implements Printable, Exportable { ... }
interface Printable {
    void printDetails(); // no implementation, just the contract
}

interface Exportable {
    void exportToPdf();
}

// A class can implement MULTIPLE interfaces
class Invoice implements Printable, Exportable {
    public void printDetails() {
        System.out.println("Invoice #123 - $500");
    }

    public void exportToPdf() {
        System.out.println("Exporting invoice to PDF...");
    }
}

Key Differences

Abstract Class vs Interface
Feature Abstract Class Interface
State (fields) Can have instance variables No state (only constants)
Implementation Can have concrete methods Only method signatures*
Inheritance Single (one parent only) Multiple (many interfaces)
Constructor Can have constructors No constructors
Relationship "Is-a" (shared identity) "Can-do" (shared capability)
*Java 8+ allows default methods in interfaces, but the core idea still holds.

When to Use Which

Use an abstract class when:

  • Subclasses share common state (fields) or behavior (methods)
  • We want to provide a base implementation that subclasses extend
  • The relationship is “is-a” — Car is a Vehicle

Use an interface when:

  • We just need to define a contract (what methods must exist)
  • A class needs to fulfill multiple roles (Printable AND Exportable)
  • Unrelated classes need the same capability — a Document and an Image can both be Exportable

The Rule of Thumb

Here’s a simple mental model:

  • Abstract class = “Here’s a base thing. You’re a specialized version of it.”
  • Interface = “Here’s a capability. You can do this, regardless of what you are.”

A Dog extends Animal (abstract class — it IS an animal). A Dog implements Trainable (interface — it CAN be trained).

In LLD interviews, we’ll use interfaces heavily for defining contracts between components, and abstract classes when we have shared logic in a hierarchy. Most design patterns (Strategy, Observer, Factory) lean on interfaces because they care about what an object can do, not what it is.


SOLID Principles

5

Single Responsibility Principle (SRP)

intermediate 2-4 YOE lld solid srp clean-code

The Single Responsibility Principle says: a class should have only one reason to change. That’s it. One job. One focus. One responsibility.

In simple language, think of a chef who also does the restaurant’s accounting, manages the waitstaff, AND fixes the plumbing. Sure, one person could do all that, but if the tax laws change, should we be modifying the chef? Nope. Each role should be a separate person (class).

The Problem: A God Class

Here’s a class that does way too much:

# BAD -- this class has THREE reasons to change
class UserManager:
    def __init__(self, db):
        self.db = db

    def create_user(self, name, email):
        # user creation logic
        user = {"name": name, "email": email}
        self.db.save(user)

        # email sending logic (reason to change #2)
        self._send_welcome_email(email)

        # logging logic (reason to change #3)
        self._log(f"Created user: {name}")

    def _send_welcome_email(self, email):
        print(f"Sending welcome email to {email}")
        # SMTP config, templates, retry logic...

    def _log(self, message):
        print(f"[LOG] {message}")
        # File handling, log rotation, formatting...
// BAD -- this class has THREE reasons to change
class UserManager {
  constructor(db) {
    this.db = db;
  }

  createUser(name, email) {
    // user creation logic
    const user = { name, email };
    this.db.save(user);

    // email sending logic (reason to change #2)
    this.sendWelcomeEmail(email);

    // logging logic (reason to change #3)
    this.log(`Created user: ${name}`);
  }

  sendWelcomeEmail(email) {
    console.log(`Sending welcome email to ${email}`);
    // SMTP config, templates, retry logic...
  }

  log(message) {
    console.log(`[LOG] ${message}`);
    // File handling, log rotation, formatting...
  }
}
// BAD -- this class has THREE reasons to change
class UserManager {
    private Database db;

    UserManager(Database db) { this.db = db; }

    void createUser(String name, String email) {
        // user creation logic
        User user = new User(name, email);
        db.save(user);

        // email sending logic (reason to change #2)
        sendWelcomeEmail(email);

        // logging logic (reason to change #3)
        log("Created user: " + name);
    }

    private void sendWelcomeEmail(String email) {
        System.out.println("Sending welcome email to " + email);
    }

    private void log(String message) {
        System.out.println("[LOG] " + message);
    }
}

Why is this bad? Because UserManager will change if:

  1. The user creation logic changes (new fields, validation rules)
  2. The email service changes (switch from SMTP to SendGrid)
  3. The logging format changes (switch to JSON logs)

Three reasons to change = three responsibilities = SRP violation.

The Fix: One Class, One Job

# GOOD -- each class has ONE responsibility

class EmailService:
    def send_welcome_email(self, email):
        print(f"Sending welcome email to {email}")

class Logger:
    def log(self, message):
        print(f"[LOG] {message}")

class UserService:
    def __init__(self, db, email_service, logger):
        self.db = db
        self.email_service = email_service
        self.logger = logger

    def create_user(self, name, email):
        user = {"name": name, "email": email}
        self.db.save(user)
        self.email_service.send_welcome_email(email)
        self.logger.log(f"Created user: {name}")
// GOOD -- each class has ONE responsibility

class EmailService {
  sendWelcomeEmail(email) {
    console.log(`Sending welcome email to ${email}`);
  }
}

class Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

class UserService {
  constructor(db, emailService, logger) {
    this.db = db;
    this.emailService = emailService;
    this.logger = logger;
  }

  createUser(name, email) {
    const user = { name, email };
    this.db.save(user);
    this.emailService.sendWelcomeEmail(email);
    this.logger.log(`Created user: ${name}`);
  }
}
// GOOD -- each class has ONE responsibility

class EmailService {
    void sendWelcomeEmail(String email) {
        System.out.println("Sending welcome email to " + email);
    }
}

class Logger {
    void log(String message) {
        System.out.println("[LOG] " + message);
    }
}

class UserService {
    private Database db;
    private EmailService emailService;
    private Logger logger;

    UserService(Database db, EmailService emailService, Logger logger) {
        this.db = db;
        this.emailService = emailService;
        this.logger = logger;
    }

    void createUser(String name, String email) {
        User user = new User(name, email);
        db.save(user);
        emailService.sendWelcomeEmail(email);
        logger.log("Created user: " + name);
    }
}

Now each class has exactly one reason to change:

  • UserService — only changes if user creation logic changes
  • EmailService — only changes if email sending logic changes
  • Logger — only changes if logging logic changes

How to Spot SRP Violations

Here are some red flags:

  1. The class name has “And” or “Manager”UserAndEmailManager is doing too much
  2. The class has methods that don’t use each other — if sendEmail() and generateReport() share nothing, they probably belong in different classes
  3. We change the class for unrelated reasons — fixing a logging bug shouldn’t touch user creation code
  4. The class is growing and growing — if a file is 500+ lines, it’s probably doing too much

A Common Mistake

SRP doesn’t mean “a class should have only one method.” That’s too extreme. A UserService can have createUser(), updateUser(), deleteUser(), and findUser() — those are all part of the same responsibility: managing user data.

The key question is always: “How many reasons would this class change?” If the answer is more than one, split it up.

Why It Matters in Interviews

When we’re designing a Parking Lot system and we put payment logic, spot assignment, and vehicle tracking all in one ParkingLot class — that’s a red flag. The interviewer wants to see us naturally separate concerns into PaymentService, SpotManager, and VehicleTracker.

SRP is the foundation. Get this right, and the other SOLID principles become much easier.


6

Open/Closed Principle (OCP)

intermediate 2-4 YOE lld solid ocp clean-code

The Open/Closed Principle says: software entities (classes, modules, functions) should be open for extension but closed for modification.

In simple language, we should be able to add new behavior without changing existing code. Sounds impossible? It’s not — we use polymorphism.

The Problem: The If/Else Chain

Let’s say we’re building a payment system. Here’s the naive approach:

# BAD -- adding a new payment type means modifying this class
class PaymentProcessor:
    def process(self, payment_type, amount):
        if payment_type == "credit_card":
            print(f"Processing ${amount} via Credit Card")
            # credit card logic...
        elif payment_type == "upi":
            print(f"Processing ${amount} via UPI")
            # UPI logic...
        elif payment_type == "paypal":
            print(f"Processing ${amount} via PayPal")
            # PayPal logic...
        # Every new payment type = modify this method
// BAD -- adding a new payment type means modifying this class
class PaymentProcessor {
  process(paymentType, amount) {
    if (paymentType === "credit_card") {
      console.log(`Processing $${amount} via Credit Card`);
    } else if (paymentType === "upi") {
      console.log(`Processing $${amount} via UPI`);
    } else if (paymentType === "paypal") {
      console.log(`Processing $${amount} via PayPal`);
    }
    // Every new payment type = modify this method
  }
}
// BAD -- adding a new payment type means modifying this class
class PaymentProcessor {
    void process(String paymentType, double amount) {
        if (paymentType.equals("credit_card")) {
            System.out.println("Processing $" + amount + " via Credit Card");
        } else if (paymentType.equals("upi")) {
            System.out.println("Processing $" + amount + " via UPI");
        } else if (paymentType.equals("paypal")) {
            System.out.println("Processing $" + amount + " via PayPal");
        }
        // Every new payment type = modify this method
    }
}

Want to add crypto payments? We have to open up PaymentProcessor and add another elif. That’s modification of existing, working code. Every time we touch it, we risk breaking what already works.

The Fix: Use Polymorphism

Instead of a big if/else, we define a common interface and let each payment type implement it:

from abc import ABC, abstractmethod

# The abstraction -- closed for modification
class PaymentMethod(ABC):
    @abstractmethod
    def process(self, amount):
        pass

# Open for extension -- just add new classes
class CreditCardPayment(PaymentMethod):
    def process(self, amount):
        print(f"Charging ${amount} to credit card")

class UPIPayment(PaymentMethod):
    def process(self, amount):
        print(f"Paying ${amount} via UPI")

class CryptoPayment(PaymentMethod):  # NEW -- no existing code changed!
    def process(self, amount):
        print(f"Sending ${amount} in crypto")

class PaymentProcessor:
    def process(self, method: PaymentMethod, amount: float):
        method.process(amount)

# Usage
processor = PaymentProcessor()
processor.process(CryptoPayment(), 100)  # works without touching processor
// The abstraction
class PaymentMethod {
  process(amount) {
    throw new Error("Must implement process()");
  }
}

// Open for extension -- just add new classes
class CreditCardPayment extends PaymentMethod {
  process(amount) {
    console.log(`Charging $${amount} to credit card`);
  }
}

class UPIPayment extends PaymentMethod {
  process(amount) {
    console.log(`Paying $${amount} via UPI`);
  }
}

class CryptoPayment extends PaymentMethod {
  // NEW -- no existing code changed!
  process(amount) {
    console.log(`Sending $${amount} in crypto`);
  }
}

class PaymentProcessor {
  process(method, amount) {
    method.process(amount);
  }
}

const processor = new PaymentProcessor();
processor.process(new CryptoPayment(), 100);
// The abstraction -- closed for modification
interface PaymentMethod {
    void process(double amount);
}

// Open for extension -- just add new classes
class CreditCardPayment implements PaymentMethod {
    public void process(double amount) {
        System.out.println("Charging $" + amount + " to credit card");
    }
}

class UPIPayment implements PaymentMethod {
    public void process(double amount) {
        System.out.println("Paying $" + amount + " via UPI");
    }
}

class CryptoPayment implements PaymentMethod { // NEW!
    public void process(double amount) {
        System.out.println("Sending $" + amount + " in crypto");
    }
}

class PaymentProcessor {
    void process(PaymentMethod method, double amount) {
        method.process(amount);
    }
}

Now adding a new payment type means creating a new class. We never touch PaymentProcessor or any existing payment class. Open for extension, closed for modification.

Another Example: Discount Calculator

from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    @abstractmethod
    def calculate(self, price):
        pass

class NoDiscount(DiscountStrategy):
    def calculate(self, price):
        return price

class PercentageDiscount(DiscountStrategy):
    def __init__(self, percent):
        self.percent = percent

    def calculate(self, price):
        return price * (1 - self.percent / 100)

class FlatDiscount(DiscountStrategy):
    def __init__(self, flat_amount):
        self.flat_amount = flat_amount

    def calculate(self, price):
        return max(0, price - self.flat_amount)

# Adding "BuyOneGetOneFree" = just a new class. Zero modifications.
def checkout(price, discount: DiscountStrategy):
    final_price = discount.calculate(price)
    print(f"Original: ${price}, Final: ${final_price}")

checkout(100, PercentageDiscount(20))  # Original: $100, Final: $80.0
checkout(100, FlatDiscount(30))        # Original: $100, Final: $70
class NoDiscount {
  calculate(price) {
    return price;
  }
}

class PercentageDiscount {
  constructor(percent) {
    this.percent = percent;
  }

  calculate(price) {
    return price * (1 - this.percent / 100);
  }
}

class FlatDiscount {
  constructor(flatAmount) {
    this.flatAmount = flatAmount;
  }

  calculate(price) {
    return Math.max(0, price - this.flatAmount);
  }
}

function checkout(price, discount) {
  const finalPrice = discount.calculate(price);
  console.log(`Original: $${price}, Final: $${finalPrice}`);
}

checkout(100, new PercentageDiscount(20)); // Final: $80
checkout(100, new FlatDiscount(30));       // Final: $70
interface DiscountStrategy {
    double calculate(double price);
}

class NoDiscount implements DiscountStrategy {
    public double calculate(double price) { return price; }
}

class PercentageDiscount implements DiscountStrategy {
    double percent;
    PercentageDiscount(double percent) { this.percent = percent; }
    public double calculate(double price) {
        return price * (1 - percent / 100);
    }
}

class FlatDiscount implements DiscountStrategy {
    double flatAmount;
    FlatDiscount(double flatAmount) { this.flatAmount = flatAmount; }
    public double calculate(double price) {
        return Math.max(0, price - flatAmount);
    }
}

The Pattern

OCP almost always follows the same recipe:

  1. Define an abstraction (interface or abstract class)
  2. Write concrete implementations for each variant
  3. Depend on the abstraction in consuming code

This is also the core of the Strategy Pattern — one of the most useful design patterns in LLD interviews.

When We Can’t Avoid Modification

Let’s be real — sometimes we HAVE to modify existing code. OCP is a guideline, not a religion. If a change is small and localized, it’s fine. OCP shines when we have a growing family of types (payment methods, discount rules, notification channels) where new additions are frequent.

The goal: design our code so that the most common changes require the least modification of existing code.


7

Liskov Substitution Principle (LSP)

intermediate 2-4 YOE lld solid lsp clean-code

The Liskov Substitution Principle says: if class B is a subclass of class A, we should be able to use B wherever we use A without anything breaking.

In simple language, if our code works with an Animal object, it should also work perfectly with a Dog object (since Dog extends Animal). If swapping in the subclass causes surprises — we’ve violated LSP.

Or as the joke goes: “If it looks like a duck, quacks like a duck, but needs batteries — we probably have the wrong abstraction.”

The Classic Violation: Rectangle and Square

This is the most famous LSP example. Mathematically, a square IS a rectangle. So it makes sense to write:

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    def set_width(self, width):
        self._width = width

    def set_height(self, height):
        self._height = height

    def area(self):
        return self._width * self._height

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    def set_width(self, width):
        # A square must keep sides equal -- SURPRISE!
        self._width = width
        self._height = width  # also changes height

    def set_height(self, height):
        self._width = height  # also changes width
        self._height = height

# This function expects Rectangle behavior
def test_rectangle(rect):
    rect.set_width(5)
    rect.set_height(10)
    assert rect.area() == 50, f"Expected 50, got {rect.area()}"

test_rectangle(Rectangle(2, 3))  # PASSES -- area is 50
test_rectangle(Square(2))        # FAILS -- area is 100!
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(w) {
    this.width = w;
  }

  setHeight(h) {
    this.height = h;
  }

  area() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side) {
    super(side, side);
  }

  setWidth(w) {
    this.width = w;
    this.height = w; // SURPRISE -- changes height too
  }

  setHeight(h) {
    this.width = h; // SURPRISE -- changes width too
    this.height = h;
  }
}

function testRectangle(rect) {
  rect.setWidth(5);
  rect.setHeight(10);
  console.assert(rect.area() === 50, `Expected 50, got ${rect.area()}`);
}

testRectangle(new Rectangle(2, 3)); // PASSES
testRectangle(new Square(2));       // FAILS -- area is 100
class Rectangle {
    protected int width, height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    void setWidth(int w) { this.width = w; }
    void setHeight(int h) { this.height = h; }
    int area() { return width * height; }
}

class Square extends Rectangle {
    Square(int side) { super(side, side); }

    void setWidth(int w) {
        this.width = w;
        this.height = w; // SURPRISE
    }

    void setHeight(int h) {
        this.width = h; // SURPRISE
        this.height = h;
    }
}

// testRectangle(new Square(2)) would fail -- area is 100, not 50

The problem: Square overrides setWidth() and setHeight() in a way that breaks the expected behavior of Rectangle. We can’t substitute a Square where a Rectangle is expected. LSP violated.

The Fix

Don’t force Square to extend Rectangle. Instead, use a common interface:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side

# Now they're siblings, not parent-child. No LSP issues.
shapes = [Rectangle(5, 10), Square(7)]
for s in shapes:
    print(s.area())  # 50, 49 -- both work as expected
class Shape {
  area() {
    throw new Error("Must implement area()");
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }

  area() {
    return this.side * this.side;
  }
}

// Siblings, not parent-child. Both are Shapes.
[new Rectangle(5, 10), new Square(7)].forEach((s) =>
  console.log(s.area())
); // 50, 49
interface Shape {
    int area();
}

class Rectangle implements Shape {
    int width, height;
    Rectangle(int w, int h) { width = w; height = h; }
    public int area() { return width * height; }
}

class Square implements Shape {
    int side;
    Square(int s) { side = s; }
    public int area() { return side * side; }
}

// Both implement Shape. No parent-child weirdness.

Another Example: Birds That Can’t Fly

# BAD -- Penguin violates LSP
class Bird:
    def fly(self):
        print("Flying high!")

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins can't fly!")  # SURPRISE!

# GOOD -- separate the ability to fly
class Bird:
    def eat(self):
        print("Eating...")

class FlyingBird(Bird):
    def fly(self):
        print("Flying high!")

class Penguin(Bird):
    def swim(self):
        print("Swimming!")

# Now nobody expects Penguin to fly
// BAD -- Penguin violates LSP
class Bird {
  fly() {
    console.log("Flying high!");
  }
}

class Penguin extends Bird {
  fly() {
    throw new Error("Penguins can't fly!"); // SURPRISE!
  }
}

// GOOD -- separate the ability to fly
class Bird {
  eat() {
    console.log("Eating...");
  }
}

class FlyingBird extends Bird {
  fly() {
    console.log("Flying high!");
  }
}

class Penguin extends Bird {
  swim() {
    console.log("Swimming!");
  }
}
// BAD -- Penguin violates LSP
class Bird {
    void fly() { System.out.println("Flying high!"); }
}
class Penguin extends Bird {
    void fly() { throw new RuntimeException("Can't fly!"); }
}

// GOOD -- separate the concerns
class Bird {
    void eat() { System.out.println("Eating..."); }
}
class FlyingBird extends Bird {
    void fly() { System.out.println("Flying high!"); }
}
class Penguin extends Bird {
    void swim() { System.out.println("Swimming!"); }
}

How to Detect LSP Violations

Watch out for these code smells:

  1. Subclass throws exceptions the parent doesn’tfly() throws “can’t fly”
  2. Subclass does nothing in an inherited method — empty override of save()
  3. Subclass changes the expected behavior — setting width also sets height
  4. We check the type before calling a methodif isinstance(bird, Penguin): skip fly()

If we find ourselves writing instanceof or typeof checks to handle subclass differences, that’s a strong hint our hierarchy is wrong.

The Simple Rule

Every subclass should strengthen the parent’s promises, never weaken them. If the parent says “I can fly,” every child better be able to fly. If not, we need a different abstraction.


8

Interface Segregation Principle (ISP)

intermediate 2-4 YOE lld solid isp clean-code

The Interface Segregation Principle says: no class should be forced to implement methods it doesn’t use.

In simple language, don’t create one giant interface that tries to cover everything. Break it into smaller, focused interfaces so each class only implements what it actually needs.

Think of it like a restaurant menu. A vegetarian shouldn’t have to flip through 10 pages of meat dishes to find their options. Give them a separate vegetarian menu.

The Problem: Fat Interfaces

Let’s say we’re designing a system with different types of workers:

from abc import ABC, abstractmethod

# BAD -- one fat interface for everything
class Worker(ABC):
    @abstractmethod
    def work(self):
        pass

    @abstractmethod
    def eat(self):
        pass

    @abstractmethod
    def sleep(self):
        pass

class HumanWorker(Worker):
    def work(self):
        print("Writing code...")

    def eat(self):
        print("Eating lunch...")

    def sleep(self):
        print("Sleeping...")

class RobotWorker(Worker):
    def work(self):
        print("Assembling parts...")

    def eat(self):
        pass  # Robots don't eat! But forced to implement this.

    def sleep(self):
        pass  # Robots don't sleep! But forced to implement this.
// BAD -- one fat interface (enforced by convention in JS)
class Worker {
  work() {
    throw new Error("Must implement");
  }
  eat() {
    throw new Error("Must implement");
  }
  sleep() {
    throw new Error("Must implement");
  }
}

class HumanWorker extends Worker {
  work() {
    console.log("Writing code...");
  }
  eat() {
    console.log("Eating lunch...");
  }
  sleep() {
    console.log("Sleeping...");
  }
}

class RobotWorker extends Worker {
  work() {
    console.log("Assembling parts...");
  }
  eat() {
    // Robots don't eat... but we're forced to have this
  }
  sleep() {
    // Robots don't sleep... but we're forced to have this
  }
}
// BAD -- one fat interface
interface Worker {
    void work();
    void eat();
    void sleep();
}

class HumanWorker implements Worker {
    public void work() { System.out.println("Writing code..."); }
    public void eat() { System.out.println("Eating lunch..."); }
    public void sleep() { System.out.println("Sleeping..."); }
}

class RobotWorker implements Worker {
    public void work() { System.out.println("Assembling parts..."); }
    public void eat() { /* do nothing -- robots don't eat */ }
    public void sleep() { /* do nothing -- robots don't sleep */ }
}

RobotWorker is forced to implement eat() and sleep() even though those make zero sense for a robot. Those empty methods are a code smell. They confuse anyone reading the code: “Wait, why is this empty? Is it a bug?”

The Fix: Segregated Interfaces

Split the fat interface into focused ones:

from abc import ABC, abstractmethod

# GOOD -- small, focused interfaces
class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

class Eatable(ABC):
    @abstractmethod
    def eat(self):
        pass

class Sleepable(ABC):
    @abstractmethod
    def sleep(self):
        pass

class HumanWorker(Workable, Eatable, Sleepable):
    def work(self):
        print("Writing code...")

    def eat(self):
        print("Eating lunch...")

    def sleep(self):
        print("Sleeping...")

class RobotWorker(Workable):  # only implements what it needs!
    def work(self):
        print("Assembling parts...")

# Clean. No empty methods. No confusion.
// GOOD -- in JS, we just don't force unnecessary methods
// With TypeScript interfaces, this would be:

// interface Workable { work(): void; }
// interface Eatable { eat(): void; }
// interface Sleepable { sleep(): void; }

class HumanWorker {
  work() {
    console.log("Writing code...");
  }
  eat() {
    console.log("Eating lunch...");
  }
  sleep() {
    console.log("Sleeping...");
  }
}

class RobotWorker {
  // only has what it needs -- work()
  work() {
    console.log("Assembling parts...");
  }
}
// GOOD -- small, focused interfaces
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class HumanWorker implements Workable, Eatable, Sleepable {
    public void work() { System.out.println("Writing code..."); }
    public void eat() { System.out.println("Eating lunch..."); }
    public void sleep() { System.out.println("Sleeping..."); }
}

class RobotWorker implements Workable { // only what it needs!
    public void work() { System.out.println("Assembling parts..."); }
}

Now RobotWorker only implements Workable. No empty methods. No confusion. If we later add a ChargingRobot, it implements Workable and maybe a new Chargeable interface. Everything stays clean.

Real-World Example: Printer Interface

from abc import ABC, abstractmethod

# BAD -- fat interface
class Machine(ABC):
    @abstractmethod
    def print_doc(self):
        pass

    @abstractmethod
    def scan(self):
        pass

    @abstractmethod
    def fax(self):
        pass

# A simple printer is forced to implement scan() and fax()
# Even if it can't do those things!

# GOOD -- segregated
class Printer(ABC):
    @abstractmethod
    def print_doc(self):
        pass

class Scanner(ABC):
    @abstractmethod
    def scan(self):
        pass

class Faxer(ABC):
    @abstractmethod
    def fax(self):
        pass

class SimplePrinter(Printer):
    def print_doc(self):
        print("Printing...")

class AllInOnePrinter(Printer, Scanner, Faxer):
    def print_doc(self):
        print("Printing...")

    def scan(self):
        print("Scanning...")

    def fax(self):
        print("Faxing...")
// GOOD -- each capability is its own thing
class SimplePrinter {
  printDoc() {
    console.log("Printing...");
  }
}

class AllInOnePrinter {
  printDoc() {
    console.log("Printing...");
  }
  scan() {
    console.log("Scanning...");
  }
  fax() {
    console.log("Faxing...");
  }
}

// SimplePrinter doesn't pretend it can scan or fax
interface Printer { void printDoc(); }
interface Scanner { void scan(); }
interface Faxer { void fax(); }

class SimplePrinter implements Printer {
    public void printDoc() { System.out.println("Printing..."); }
}

class AllInOnePrinter implements Printer, Scanner, Faxer {
    public void printDoc() { System.out.println("Printing..."); }
    public void scan() { System.out.println("Scanning..."); }
    public void fax() { System.out.println("Faxing..."); }
}

How to Spot ISP Violations

  1. Empty method implementations — a class implements a method but the body is empty or throws “not supported”
  2. “I only use 2 out of 10 methods” — if a class implementing an interface only needs a few methods, the interface is too fat
  3. Changes to one method affect unrelated classes — adding a new method to the interface forces ALL implementers to change

The Simple Rule

Keep interfaces small and cohesive. If an interface has methods that don’t all make sense together, split it up. It’s better to have 5 small interfaces than 1 giant one.

In LLD interviews, this shows up when we design things like vehicles (not every vehicle can fly), employees (not every employee gets a parking spot), or payment methods (not every method supports refunds). Segregated interfaces keep our design honest.


9

Dependency Inversion Principle (DIP)

intermediate 2-4 YOE lld solid dip dependency-injection clean-code

The Dependency Inversion Principle says:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

In simple language, our business logic shouldn’t care whether we’re sending emails via SendGrid or SMTP or carrier pigeon. It should depend on an abstraction like MessageSender, and the specific implementation is plugged in from outside.

The Problem: Tight Coupling

Here’s what tight coupling looks like:

# BAD -- NotificationService directly creates its dependency
class EmailSender:
    def send(self, to, message):
        print(f"Email to {to}: {message}")

class NotificationService:
    def __init__(self):
        self.sender = EmailSender()  # hardcoded dependency!

    def notify(self, user, message):
        self.sender.send(user.email, message)
// BAD -- NotificationService directly creates its dependency
class EmailSender {
  send(to, message) {
    console.log(`Email to ${to}: ${message}`);
  }
}

class NotificationService {
  constructor() {
    this.sender = new EmailSender(); // hardcoded dependency!
  }

  notify(user, message) {
    this.sender.send(user.email, message);
  }
}
// BAD -- NotificationService directly creates its dependency
class EmailSender {
    void send(String to, String message) {
        System.out.println("Email to " + to + ": " + message);
    }
}

class NotificationService {
    private EmailSender sender = new EmailSender(); // hardcoded!

    void notify(User user, String message) {
        sender.send(user.getEmail(), message);
    }
}

What’s wrong here? NotificationService (high-level) directly depends on EmailSender (low-level). Want to switch to SMS? We have to modify NotificationService. Want to test without actually sending emails? Can’t — it’s hardcoded.

The Fix: Depend on Abstractions

We introduce an interface and inject the dependency from outside:

from abc import ABC, abstractmethod

# The abstraction
class MessageSender(ABC):
    @abstractmethod
    def send(self, to, message):
        pass

# Concrete implementations
class EmailSender(MessageSender):
    def send(self, to, message):
        print(f"Email to {to}: {message}")

class SMSSender(MessageSender):
    def send(self, to, message):
        print(f"SMS to {to}: {message}")

class SlackSender(MessageSender):
    def send(self, to, message):
        print(f"Slack to {to}: {message}")

# High-level module depends on abstraction, not details
class NotificationService:
    def __init__(self, sender: MessageSender):  # injected!
        self.sender = sender

    def notify(self, to, message):
        self.sender.send(to, message)

# We control what gets plugged in
service = NotificationService(SMSSender())
service.notify("Alice", "Your order shipped!")

# For testing -- just pass a mock
class MockSender(MessageSender):
    def __init__(self):
        self.sent = []

    def send(self, to, message):
        self.sent.append((to, message))

test_service = NotificationService(MockSender())
// The abstraction (interface by convention in JS)
// Any object with a send(to, message) method works

class EmailSender {
  send(to, message) {
    console.log(`Email to ${to}: ${message}`);
  }
}

class SMSSender {
  send(to, message) {
    console.log(`SMS to ${to}: ${message}`);
  }
}

// High-level module depends on abstraction
class NotificationService {
  constructor(sender) {
    // injected from outside!
    this.sender = sender;
  }

  notify(to, message) {
    this.sender.send(to, message);
  }
}

// Plug in whatever we want
const service = new NotificationService(new SMSSender());
service.notify("Alice", "Your order shipped!");

// For testing -- just pass a mock
const mockSender = { send: (to, msg) => {} };
const testService = new NotificationService(mockSender);
// The abstraction
interface MessageSender {
    void send(String to, String message);
}

class EmailSender implements MessageSender {
    public void send(String to, String message) {
        System.out.println("Email to " + to + ": " + message);
    }
}

class SMSSender implements MessageSender {
    public void send(String to, String message) {
        System.out.println("SMS to " + to + ": " + message);
    }
}

// High-level module depends on abstraction
class NotificationService {
    private MessageSender sender; // interface, not concrete class

    NotificationService(MessageSender sender) { // injected!
        this.sender = sender;
    }

    void notify(String to, String message) {
        sender.send(to, message);
    }
}

// NotificationService service = new NotificationService(new SMSSender());
// service.notify("Alice", "Your order shipped!");

Now NotificationService doesn’t know or care whether it’s sending emails, SMS, or Slack messages. We pass in whatever MessageSender implementation we want. This is called Dependency Injection — the most common way to apply DIP.

Dependency Injection Explained Simply

Dependency Injection (DI) just means: instead of a class creating its own dependencies, we give them to it from outside.

There are three ways to inject:

1. Constructor Injection (most common, what we showed above)

class Service:
    def __init__(self, repo):  # dependency passed in constructor
        self.repo = repo

2. Setter Injection

class Service:
    def set_repo(self, repo):  # dependency set after construction
        self.repo = repo

3. Method Injection

class Service:
    def do_work(self, repo):  # dependency passed per method call
        repo.save(data)

Constructor injection is preferred because the object is fully ready to use the moment it’s created. No risk of forgetting to set a dependency.

Before and After

Without DIPWith DIP
Classes create their own dependenciesDependencies are injected from outside
Changing a dependency = modifying the classChanging a dependency = passing a different object
Hard to test (real emails get sent)Easy to test (pass a mock)
Tight couplingLoose coupling

Why It Matters

DIP is the principle that makes everything else work together. When we follow DIP:

  • OCP becomes natural — we extend by adding new implementations, not modifying existing code
  • Testing becomes easy — we inject mocks instead of real services
  • Swapping implementations is painless — switch from MySQL to PostgreSQL by injecting a different repository

In LLD interviews, whenever we have a service that talks to an external system (database, API, notification service), we should define an interface for it and inject the implementation. That’s the interviewer’s “this person knows what they’re doing” signal.


Design Principles

10

DRY, KISS, and YAGNI

beginner 0-2 YOE lld clean-code dry kiss yagni

These three principles are so simple they fit on a sticky note, but they’ll save us from writing mountains of unnecessary code. Let’s break each one down.

DRY — Don’t Repeat Yourself

If we find ourselves writing the same logic in two places, that’s a sign to extract it into a shared function or class.

Why? Because when the logic changes (and it will), we only want to change it in ONE place. Copy-pasted code means copy-pasted bugs.

# BAD -- same validation logic in two places
def create_user(email):
    if "@" not in email or "." not in email:
        raise ValueError("Invalid email")
    # create user...

def update_user(email):
    if "@" not in email or "." not in email:
        raise ValueError("Invalid email")
    # update user...

# GOOD -- extract the repeated logic
def validate_email(email):
    if "@" not in email or "." not in email:
        raise ValueError("Invalid email")

def create_user(email):
    validate_email(email)
    # create user...

def update_user(email):
    validate_email(email)
    # update user...
// BAD -- same validation in two places
function createUser(email) {
  if (!email.includes("@") || !email.includes(".")) {
    throw new Error("Invalid email");
  }
  // create user...
}

function updateUser(email) {
  if (!email.includes("@") || !email.includes(".")) {
    throw new Error("Invalid email");
  }
  // update user...
}

// GOOD -- extract the repeated logic
function validateEmail(email) {
  if (!email.includes("@") || !email.includes(".")) {
    throw new Error("Invalid email");
  }
}

function createUser(email) {
  validateEmail(email);
  // create user...
}

function updateUser(email) {
  validateEmail(email);
  // update user...
}
// BAD -- repeated validation
void createUser(String email) {
    if (!email.contains("@") || !email.contains("."))
        throw new IllegalArgumentException("Invalid email");
    // create user...
}

void updateUser(String email) {
    if (!email.contains("@") || !email.contains("."))
        throw new IllegalArgumentException("Invalid email");
    // update user...
}

// GOOD -- extracted
void validateEmail(String email) {
    if (!email.contains("@") || !email.contains("."))
        throw new IllegalArgumentException("Invalid email");
}

void createUser(String email) {
    validateEmail(email);
    // create user...
}

When DRY Goes Too Far

Here’s the catch — sometimes two pieces of code LOOK the same but serve different purposes. If we force them together, a change in one breaks the other. This is called premature abstraction.

If two functions happen to have similar code but exist for different reasons, it’s okay to let them be. Wait until the duplication proves to be a real problem before abstracting.

KISS — Keep It Simple, Stupid

The simplest solution that works is usually the best. Don’t write clever code when straightforward code will do.

# BAD -- clever but hard to read
def is_even(n):
    return not n & 1

# GOOD -- anyone can understand this
def is_even(n):
    return n % 2 == 0

# BAD -- over-engineered for a simple task
class StringReverserFactory:
    def create_reverser(self):
        return StringReverser()

class StringReverser:
    def reverse(self, s):
        return s[::-1]

# GOOD -- just a function
def reverse_string(s):
    return s[::-1]
// BAD -- clever one-liner nobody wants to debug
const flattenDeep = (a) =>
  a.reduce((acc, v) => acc.concat(Array.isArray(v) ? flattenDeep(v) : v), []);

// GOOD -- readable and clear
function flattenDeep(arr) {
  return arr.flat(Infinity);
}

// BAD -- over-engineered
class ConfigManager {
  #instance;
  static getInstance() {
    /* ... singleton pattern for a simple config ... */
  }
}

// GOOD -- for simple cases, just use an object
const config = { port: 3000, host: "localhost" };
// BAD -- over-engineered for simple string joining
class StringJoinerBuilder {
    private StringBuilder sb = new StringBuilder();
    StringJoinerBuilder add(String s) { sb.append(s); return this; }
    String build() { return sb.toString(); }
}

// GOOD -- use what the language gives us
String result = String.join(", ", "a", "b", "c");

KISS doesn’t mean “write the fewest characters.” It means “write code that’s easy to read, understand, and modify.” Future us (and our teammates) will thank us.

YAGNI — You Ain’t Gonna Need It

Don’t build features we think we MIGHT need later. Build what we need NOW.

This one is hard because as developers, we love to plan ahead. “What if we need to support multiple currencies?” “What if we need to handle 10 million users?” But until those requirements actually exist, we’re just adding complexity for nothing.

// BAD -- building for imaginary requirements
"Let's add a plugin system for our TODO app"
"Let's make this support 15 databases just in case"
"Let's add multi-language support for our internal tool"

// GOOD -- build what's needed
"We need to add and complete TODOs. Let's do that."
"We use PostgreSQL. Let's support PostgreSQL."
"Our team speaks English. Let's write it in English."

The cost of building something we don’t need:

  • Time wasted writing and testing unused code
  • Complexity added that makes real features harder to build
  • Bugs introduced in code that nobody asked for
  • Maintenance burden for features nobody uses

How They Work Together

PrincipleMantraAnti-Pattern
DRY”One source of truth”Copy-pasting the same logic
KISS”Simple beats clever”Over-engineering a simple problem
YAGNI”Build it when we need it”Building features speculatively

In LLD interviews, these principles guide our design instincts:

  • DRY: Extract shared logic into base classes or utility methods
  • KISS: Don’t apply every design pattern we know — use the simplest solution
  • YAGNI: Don’t add interfaces and abstractions until there’s a real reason for them

A Parking Lot system doesn’t need a PluginManager. It needs to park cars. Start simple, extend when needed.


11

Coupling and Cohesion

intermediate 2-4 YOE lld coupling cohesion clean-code design

If SOLID principles are the rules, coupling and cohesion are the metrics that tell us if we’re doing it right. Every good design aims for the same thing: low coupling, high cohesion.

What is Coupling?

Coupling is how much one class depends on another class. The more a class knows about another class’s internals, the tighter the coupling.

Think of it like people in an office. If Alice can’t do her job without knowing exactly how Bob organizes his desk, that’s tight coupling. If Alice just emails Bob a request and gets a response back — that’s loose coupling.

Tight Coupling (Bad)

# BAD -- OrderService knows the INTERNAL structure of Database
class MySQLDatabase:
    def __init__(self):
        self.connection = "mysql://localhost:3306"

    def execute_query(self, sql):
        print(f"Running SQL: {sql}")

class OrderService:
    def __init__(self):
        self.db = MySQLDatabase()  # directly creates the dependency

    def create_order(self, item):
        # knows it's MySQL, knows the SQL syntax, knows the table name
        self.db.execute_query(f"INSERT INTO orders VALUES ('{item}')")
// BAD -- OrderService is glued to MySQLDatabase
class MySQLDatabase {
  constructor() {
    this.connection = "mysql://localhost:3306";
  }

  executeQuery(sql) {
    console.log(`Running SQL: ${sql}`);
  }
}

class OrderService {
  constructor() {
    this.db = new MySQLDatabase(); // hardcoded
  }

  createOrder(item) {
    this.db.executeQuery(`INSERT INTO orders VALUES ('${item}')`);
  }
}
// BAD -- tightly coupled to MySQL
class MySQLDatabase {
    void executeQuery(String sql) {
        System.out.println("Running SQL: " + sql);
    }
}

class OrderService {
    private MySQLDatabase db = new MySQLDatabase(); // hardcoded

    void createOrder(String item) {
        db.executeQuery("INSERT INTO orders VALUES ('" + item + "')");
    }
}

Want to switch to PostgreSQL? We have to rewrite OrderService. Want to test without a real database? Can’t.

Loose Coupling (Good)

from abc import ABC, abstractmethod

# GOOD -- OrderService depends on an abstraction
class Database(ABC):
    @abstractmethod
    def save(self, table, data):
        pass

class MySQLDatabase(Database):
    def save(self, table, data):
        print(f"MySQL: saving to {table}")

class PostgresDatabase(Database):
    def save(self, table, data):
        print(f"Postgres: saving to {table}")

class OrderService:
    def __init__(self, db: Database):  # accepts ANY Database
        self.db = db

    def create_order(self, item):
        self.db.save("orders", {"item": item})

# Easy to swap
service = OrderService(PostgresDatabase())
service.create_order("Laptop")
// GOOD -- OrderService takes any object with a save() method
class MySQLDatabase {
  save(table, data) {
    console.log(`MySQL: saving to ${table}`);
  }
}

class PostgresDatabase {
  save(table, data) {
    console.log(`Postgres: saving to ${table}`);
  }
}

class OrderService {
  constructor(db) {
    this.db = db; // injected -- could be anything
  }

  createOrder(item) {
    this.db.save("orders", { item });
  }
}

const service = new OrderService(new PostgresDatabase());
service.createOrder("Laptop");
interface Database {
    void save(String table, Object data);
}

class MySQLDatabase implements Database {
    public void save(String table, Object data) {
        System.out.println("MySQL: saving to " + table);
    }
}

class PostgresDatabase implements Database {
    public void save(String table, Object data) {
        System.out.println("Postgres: saving to " + table);
    }
}

class OrderService {
    private Database db; // depends on interface

    OrderService(Database db) { this.db = db; }

    void createOrder(String item) {
        db.save("orders", item);
    }
}

Now OrderService has no idea if it’s talking to MySQL, Postgres, or a mock. That’s loose coupling.

What is Cohesion?

Cohesion is how related the responsibilities within a single class are. A class with high cohesion does one thing well. A class with low cohesion is a grab bag of unrelated stuff.

Think of it like a toolbox. A well-organized toolbox (high cohesion) has screwdrivers in one section, wrenches in another. A junk drawer (low cohesion) has batteries, tape, a fork, and a birthday candle.

Low Cohesion (Bad)

# BAD -- this class does too many unrelated things
class Utils:
    def send_email(self, to, message):
        print(f"Email to {to}")

    def calculate_tax(self, amount):
        return amount * 0.18

    def resize_image(self, image, width):
        print(f"Resizing to {width}px")

    def generate_report(self, data):
        print("Generating report...")

Email, tax, images, and reports have nothing in common. This is the classic “Utils” or “Helper” class anti-pattern.

High Cohesion (Good)

# GOOD -- each class has a focused, related set of methods
class EmailService:
    def send(self, to, message):
        print(f"Email to {to}")

    def send_bulk(self, recipients, message):
        for r in recipients:
            self.send(r, message)

class TaxCalculator:
    def calculate(self, amount):
        return amount * 0.18

    def calculate_with_cess(self, amount):
        return amount * 0.18 + amount * 0.01

Every method in EmailService is about emails. Every method in TaxCalculator is about taxes. High cohesion.

The Sweet Spot

Coupling vs Cohesion
Bad Design
High Coupling
Classes tightly depend on each other
+
Low Cohesion
Classes do many unrelated things
Good Design
Low Coupling
Classes interact through interfaces
+
High Cohesion
Each class has one focused purpose

How to Measure (Quick Gut Check)

Coupling — ask: “If I change class A, how many other classes break?”

  • If the answer is “many” — coupling is too high
  • If the answer is “none or few” — we’re in good shape

Cohesion — ask: “Can I describe what this class does in ONE sentence without using the word ‘and’?”

  • “This class manages user authentication” — high cohesion
  • “This class sends emails AND calculates taxes AND resizes images” — low cohesion

Connection to SOLID

Low coupling and high cohesion aren’t separate ideas from SOLID — they’re the RESULT of following SOLID:

  • SRP directly increases cohesion (one responsibility per class)
  • DIP directly reduces coupling (depend on abstractions)
  • ISP increases cohesion of interfaces (small, focused contracts)
  • OCP reduces coupling to specific implementations (extend, don’t modify)

In LLD interviews, every time we split a god class into focused services or introduce an interface between two classes, we’re improving coupling and cohesion. The interviewer might not name these terms, but they’ll notice the quality of our design.


12

UML Class Diagrams

intermediate 2-4 YOE lld uml class-diagram design

UML class diagrams are the universal sketch language for LLD interviews. When an interviewer says “walk me through your design,” they expect a quick diagram showing classes and how they relate. We don’t need to be UML experts — just enough to communicate clearly.

The Class Box

Every class is drawn as a box with three sections:

Class Box Structure
ClassName
- privateField: Type
# protectedField: Type
+ publicField: Type
+ publicMethod(): ReturnType
- privateMethod(): void
# protectedMethod(): void
+ public   - private   # protected

In interviews, we don’t always fill in every detail. Sometimes just the class name and key methods are enough. Speed matters more than perfection.

Relationships

This is where UML gets really useful. There are six relationships we should know:

1. Association (uses)

A basic “knows about” relationship. One class uses another.

Student ──────── Course
  "A student is enrolled in a course"

An Order has a reference to a Customer. They know about each other but can exist independently.

2. Aggregation (has, loosely)

A “whole-part” relationship where the part can exist without the whole. Drawn with an empty diamond on the “whole” side.

Department ◇──────── Employee
  "Department has employees, but employees exist without the department"

If we delete the Department, the Employee objects still exist. They can work somewhere else.

3. Composition (has, tightly)

A stronger “whole-part” relationship where the part CANNOT exist without the whole. Drawn with a filled diamond.

House ◆──────── Room
  "House has rooms. Destroy the house, rooms are gone too"

A Room makes no sense without its House. If the house is demolished, the rooms go with it.

4. Inheritance (is-a)

A child class extends a parent class. Drawn with a hollow triangle arrow pointing to the parent.

     Animal

      / \
   Dog   Cat
  "Dog is an Animal"

5. Implementation (implements)

A class implements an interface. Drawn with a dashed line and hollow triangle.

  <<interface>>
    Flyable

      ┆ (dashed)

    Bird
  "Bird implements Flyable"

6. Dependency (uses temporarily)

A class uses another class temporarily (e.g., as a method parameter). Drawn with a dashed arrow.

OrderService - - - -> EmailService
  "OrderService uses EmailService to send confirmation"

The only difference from association: dependency is temporary (method parameter), association is stored (field).

Arrow Reference

UML Relationship Cheat Sheet
Relationship Arrow Meaning
Association A ────> B A knows about B
Aggregation A ◇───> B A has B (B can exist alone)
Composition A ◆───> B A owns B (B dies with A)
Inheritance B ───▷ A B is a type of A
Implementation B --▷ A B implements interface A
Dependency A ---> B A temporarily uses B

Example: Library System

Let’s sketch a simple library system to put it all together:

Library System -- Class Diagram
Library
- name: String
- books: List<Book>
+ addBook(Book)
+ searchByTitle(String)
Book
- title: String
- isbn: String
- author: Author
+ getDetails(): String
+ isAvailable(): bool
Member
- name: String
- memberId: String
+ borrowBook(Book)
+ returnBook(Book)
Library ◆── Book (composition)   |   Member ──> Book (association)   |   Book ──> Author (aggregation)

The relationships:

  • Library ◆── Book: Composition. Books belong to the library. Delete the library, the books catalog is gone.
  • Member ──> Book: Association. A member borrows books but they’re independent entities.
  • Book ──> Author: Aggregation. A book has an author, but the author exists independently.

Tips for Interviews

  1. Start with nouns — the nouns in the problem statement often become classes (ParkingLot, Vehicle, Spot, Ticket)
  2. Draw boxes first, arrows later — get the classes right before worrying about relationships
  3. Keep it simple — don’t draw every getter/setter. Focus on key attributes and methods
  4. Use the whiteboard wisely — leave space between classes so we can add arrows without it becoming a mess
  5. Talk while drawing — explain our thinking. “I’m making this a composition because a Room can’t exist without a Hotel”

We don’t need to memorize UML syntax perfectly. The interviewer cares that we can clearly communicate our design. A clean sketch with clear relationships beats a perfect UML diagram that takes 20 minutes to draw.


Creational Design Patterns

13

Singleton Pattern

intermediate 2-4 YOE lld design-pattern creational

The Singleton pattern guarantees that a class has only one instance and provides a global access point to it. That’s it. One object, shared everywhere.

Think of it like the president of a country. There’s only one at a time. Everyone refers to the same person. We don’t create a new president every time someone needs to talk to one.

The Problem It Solves

Some things should only exist once in our application:

  • Database connection pool — we don’t want 50 separate pools fighting for connections
  • Logger — all parts of the app should log to the same place
  • Config manager — one source of truth for settings

Without Singleton, any part of the code could do new DatabasePool() and accidentally create duplicates. Wasteful and buggy.

How It Works

Singleton Structure
Singleton
- static instance: Singleton
- private constructor()
+ static getInstance(): Singleton
Constructor is private → no one can call new Singleton()
getInstance() creates the instance on first call, returns it on every subsequent call

The trick: make the constructor private so nobody can call new. Then expose a static method that creates the instance only once and returns it every time.

Basic Implementation

class DatabaseConnection:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.connection = "Connected to DB"
            print("Creating new DB connection...")
        return cls._instance

# Both variables point to the SAME object
db1 = DatabaseConnection()
db2 = DatabaseConnection()
print(db1 is db2)  # True
class DatabaseConnection {
  static #instance = null;

  constructor() {
    if (DatabaseConnection.#instance) {
      return DatabaseConnection.#instance;
    }
    this.connection = "Connected to DB";
    console.log("Creating new DB connection...");
    DatabaseConnection.#instance = this;
  }

  static getInstance() {
    if (!DatabaseConnection.#instance) {
      DatabaseConnection.#instance = new DatabaseConnection();
    }
    return DatabaseConnection.#instance;
  }
}

const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true
public class DatabaseConnection {
    private static DatabaseConnection instance;
    private String connection;

    // Private constructor -- no one can call new
    private DatabaseConnection() {
        this.connection = "Connected to DB";
        System.out.println("Creating new DB connection...");
    }

    public static DatabaseConnection getInstance() {
        if (instance == null) {
            instance = new DatabaseConnection();
        }
        return instance;
    }
}

// Usage
// DatabaseConnection db1 = DatabaseConnection.getInstance();
// DatabaseConnection db2 = DatabaseConnection.getInstance();
// db1 == db2 → true

Thread-Safe Singleton (Interview Favorite!)

The basic version has a problem. If two threads call getInstance() at the exact same time, both might see instance == null and create two objects. Not good.

import threading

class ThreadSafeSingleton:
    _instance = None
    _lock = threading.Lock()

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                # Double-check after acquiring the lock
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
        return cls._instance
// In JS, the event loop is single-threaded
// so we don't truly need thread safety.
// But in Node.js worker threads or for good practice:
class ThreadSafeSingleton {
  static #instance = null;

  static getInstance() {
    // JS is single-threaded, so this is already safe
    // But the pattern is still good to know for interviews
    if (!ThreadSafeSingleton.#instance) {
      ThreadSafeSingleton.#instance = new ThreadSafeSingleton();
    }
    return ThreadSafeSingleton.#instance;
  }
}
public class ThreadSafeSingleton {
    // volatile ensures visibility across threads
    private static volatile ThreadSafeSingleton instance;

    private ThreadSafeSingleton() {}

    public static ThreadSafeSingleton getInstance() {
        if (instance == null) {                    // First check (no lock)
            synchronized (ThreadSafeSingleton.class) {
                if (instance == null) {            // Second check (with lock)
                    instance = new ThreadSafeSingleton();
                }
            }
        }
        return instance;
    }
}

This is called double-checked locking. We check twice — once without the lock (fast path) and once with the lock (safe path). Interviewers love asking about this.

When to Use

  • Database connection pools — one pool shared across the app
  • Loggers — centralized logging
  • Config/settings — single source of truth
  • Cache managers — one shared cache
  • Thread pools — manage threads from one place

When NOT to Use

  • When it makes testing hard — singletons are essentially global state, and global state makes unit tests flaky
  • When we need multiple instances later — refactoring away from singleton is painful
  • When it hides dependencies — if every class secretly depends on a singleton, the code becomes hard to understand

In simple language, Singleton is a box that only ever has one item in it. Everyone in the app reaches into the same box. Super useful for shared resources, but don’t overuse it — global state can bite us during testing.


14

Factory Pattern

intermediate 2-4 YOE lld design-pattern creational

The Factory pattern is about letting someone else create objects for us. Instead of calling new ConcreteClass() directly, we ask a factory to give us the right object.

Think of it like a pizza store. We walk in and say “I want a veggie pizza.” We don’t go into the kitchen and make it ourselves. The store (factory) knows exactly which class to instantiate, what ingredients to use, and how to assemble it.

The Problem It Solves

Without a factory, our code looks like this:

if (type === "email") notification = new EmailNotification();
else if (type === "sms") notification = new SMSNotification();
else if (type === "push") notification = new PushNotification();

This if-else mess gets scattered everywhere. Every time we add a new type, we hunt down every place that creates notifications. Painful.

A factory centralizes this creation logic in one place.

Factory Method vs Abstract Factory

Two Flavors of Factory
Factory Method
One method creates one type of object.

Subclasses decide which class to instantiate.

Example: createNotification() returns EmailNotification or SMSNotification
Abstract Factory
Creates families of related objects.

One factory produces a whole set of things that belong together.

Example: UIFactory creates Button + Checkbox + Input that all match (Material or iOS style)

The only difference is scope. Factory Method creates one product. Abstract Factory creates a family of products.

Factory Method — Notification Example

from abc import ABC, abstractmethod

# Product interface
class Notification(ABC):
    @abstractmethod
    def send(self, message: str) -> None:
        pass

# Concrete products
class EmailNotification(Notification):
    def send(self, message: str):
        print(f"Email: {message}")

class SMSNotification(Notification):
    def send(self, message: str):
        print(f"SMS: {message}")

class PushNotification(Notification):
    def send(self, message: str):
        print(f"Push: {message}")

# Factory
class NotificationFactory:
    @staticmethod
    def create(ntype: str) -> Notification:
        factories = {
            "email": EmailNotification,
            "sms": SMSNotification,
            "push": PushNotification,
        }
        if ntype not in factories:
            raise ValueError(f"Unknown type: {ntype}")
        return factories[ntype]()

# Usage -- we never call new EmailNotification() directly
notif = NotificationFactory.create("email")
notif.send("Hello!")  # Email: Hello!
// Product interface (via duck typing in JS)
class EmailNotification {
  send(message) { console.log(`Email: ${message}`); }
}

class SMSNotification {
  send(message) { console.log(`SMS: ${message}`); }
}

class PushNotification {
  send(message) { console.log(`Push: ${message}`); }
}

// Factory
class NotificationFactory {
  static create(type) {
    const factories = {
      email: EmailNotification,
      sms: SMSNotification,
      push: PushNotification,
    };
    const NotifClass = factories[type];
    if (!NotifClass) throw new Error(`Unknown type: ${type}`);
    return new NotifClass();
  }
}

// Usage
const notif = NotificationFactory.create("email");
notif.send("Hello!"); // Email: Hello!
// Product interface
interface Notification {
    void send(String message);
}

class EmailNotification implements Notification {
    public void send(String message) { System.out.println("Email: " + message); }
}

class SMSNotification implements Notification {
    public void send(String message) { System.out.println("SMS: " + message); }
}

class PushNotification implements Notification {
    public void send(String message) { System.out.println("Push: " + message); }
}

// Factory
class NotificationFactory {
    public static Notification create(String type) {
        return switch (type) {
            case "email" -> new EmailNotification();
            case "sms"   -> new SMSNotification();
            case "push"  -> new PushNotification();
            default -> throw new IllegalArgumentException("Unknown: " + type);
        };
    }
}

// Usage
// Notification notif = NotificationFactory.create("email");
// notif.send("Hello!");

Now if we add a WhatsAppNotification, we only change one place — the factory. Everything else stays untouched.

Abstract Factory — UI Theme Example

When we need a family of related objects that must be consistent with each other, we use Abstract Factory.

from abc import ABC, abstractmethod

# Abstract products
class Button(ABC):
    @abstractmethod
    def render(self): pass

class Checkbox(ABC):
    @abstractmethod
    def render(self): pass

# Material family
class MaterialButton(Button):
    def render(self): print("Material Button")

class MaterialCheckbox(Checkbox):
    def render(self): print("Material Checkbox")

# iOS family
class IOSButton(Button):
    def render(self): print("iOS Button")

class IOSCheckbox(Checkbox):
    def render(self): print("iOS Checkbox")

# Abstract Factory
class UIFactory(ABC):
    @abstractmethod
    def create_button(self) -> Button: pass
    @abstractmethod
    def create_checkbox(self) -> Checkbox: pass

class MaterialFactory(UIFactory):
    def create_button(self): return MaterialButton()
    def create_checkbox(self): return MaterialCheckbox()

class IOSFactory(UIFactory):
    def create_button(self): return IOSButton()
    def create_checkbox(self): return IOSCheckbox()

# Client code -- doesn't know which family it's using
def build_ui(factory: UIFactory):
    button = factory.create_button()
    checkbox = factory.create_checkbox()
    button.render()
    checkbox.render()

build_ui(MaterialFactory())  # Material Button, Material Checkbox
// Material family
class MaterialButton {
  render() { console.log("Material Button"); }
}
class MaterialCheckbox {
  render() { console.log("Material Checkbox"); }
}

// iOS family
class IOSButton {
  render() { console.log("iOS Button"); }
}
class IOSCheckbox {
  render() { console.log("iOS Checkbox"); }
}

// Abstract Factories
class MaterialFactory {
  createButton() { return new MaterialButton(); }
  createCheckbox() { return new MaterialCheckbox(); }
}

class IOSFactory {
  createButton() { return new IOSButton(); }
  createCheckbox() { return new IOSCheckbox(); }
}

// Client code
function buildUI(factory) {
  const button = factory.createButton();
  const checkbox = factory.createCheckbox();
  button.render();
  checkbox.render();
}

buildUI(new MaterialFactory()); // Material Button, Material Checkbox
// Abstract products
interface Button { void render(); }
interface Checkbox { void render(); }

// Material family
class MaterialButton implements Button {
    public void render() { System.out.println("Material Button"); }
}
class MaterialCheckbox implements Checkbox {
    public void render() { System.out.println("Material Checkbox"); }
}

// iOS family
class IOSButton implements Button {
    public void render() { System.out.println("iOS Button"); }
}
class IOSCheckbox implements Checkbox {
    public void render() { System.out.println("iOS Checkbox"); }
}

// Abstract Factory
interface UIFactory {
    Button createButton();
    Checkbox createCheckbox();
}

class MaterialFactory implements UIFactory {
    public Button createButton() { return new MaterialButton(); }
    public Checkbox createCheckbox() { return new MaterialCheckbox(); }
}

class IOSFactory implements UIFactory {
    public Button createButton() { return new IOSButton(); }
    public Checkbox createCheckbox() { return new IOSCheckbox(); }
}

When to Use

  • Factory Method: when we have one product type with multiple variants (notifications, shapes, parsers)
  • Abstract Factory: when we need families of related objects that must be used together (UI themes, database drivers)
  • When we want to decouple creation from usage
  • When the exact type is decided at runtime

When NOT to Use

  • For simple object creation — if there’s only one concrete class, a factory is overkill
  • When the creation logic never changes — don’t add abstraction for the sake of it

In simple language, a factory is a middleman. We tell it what we want, and it figures out how to make it. Factory Method is a single middleman for one product. Abstract Factory is a whole department that produces a matching set of products.


15

Builder Pattern

intermediate 2-4 YOE lld design-pattern creational

The Builder pattern lets us construct complex objects step by step. Instead of one giant constructor with 10 parameters, we call small, clear methods one at a time.

Think of it like building a custom burger. We start with a bun, add a patty, add cheese, add sauce, add lettuce. Each step is optional. We build exactly what we want, and at the end we get our burger.

The Problem It Solves

Ever seen a constructor like this?

new House(4, 2, true, false, true, "wood", 2, true, false, "red")

What does true mean? What’s 2? Is "red" the roof or the door? This is called the telescoping constructor problem. It’s unreadable and error-prone.

The Builder fixes this:

House.builder().rooms(4).bathrooms(2).hasGarage(true).roofColor("red").build()

Now we can see exactly what each value means.

How It Works

Builder Pattern Flow
Builder
holds partial state
→ .step1() → .step2() → .step3()
.build()
returns final Product
Each step returns this (the builder itself) → enables method chaining

The key ideas:

  1. Each setter method returns this (the builder), so we can chain calls
  2. The build() method at the end assembles and returns the final object
  3. Steps are optional — we only set what we need

Code Implementation

class Pizza:
    def __init__(self):
        self.size = "medium"
        self.cheese = False
        self.pepperoni = False
        self.mushrooms = False
        self.extra_sauce = False

    def __str__(self):
        toppings = []
        if self.cheese: toppings.append("cheese")
        if self.pepperoni: toppings.append("pepperoni")
        if self.mushrooms: toppings.append("mushrooms")
        if self.extra_sauce: toppings.append("extra sauce")
        return f"{self.size} pizza with {', '.join(toppings) or 'nothing'}"

class PizzaBuilder:
    def __init__(self):
        self._pizza = Pizza()

    def size(self, size: str):
        self._pizza.size = size
        return self  # return self for chaining

    def add_cheese(self):
        self._pizza.cheese = True
        return self

    def add_pepperoni(self):
        self._pizza.pepperoni = True
        return self

    def add_mushrooms(self):
        self._pizza.mushrooms = True
        return self

    def add_extra_sauce(self):
        self._pizza.extra_sauce = True
        return self

    def build(self) -> Pizza:
        return self._pizza

# Clean, readable creation
pizza = (PizzaBuilder()
    .size("large")
    .add_cheese()
    .add_pepperoni()
    .add_extra_sauce()
    .build())

print(pizza)  # large pizza with cheese, pepperoni, extra sauce
class Pizza {
  constructor() {
    this.size = "medium";
    this.cheese = false;
    this.pepperoni = false;
    this.mushrooms = false;
    this.extraSauce = false;
  }

  toString() {
    const toppings = [];
    if (this.cheese) toppings.push("cheese");
    if (this.pepperoni) toppings.push("pepperoni");
    if (this.mushrooms) toppings.push("mushrooms");
    if (this.extraSauce) toppings.push("extra sauce");
    return `${this.size} pizza with ${toppings.join(", ") || "nothing"}`;
  }
}

class PizzaBuilder {
  #pizza = new Pizza();

  size(size) {
    this.#pizza.size = size;
    return this; // return this for chaining
  }
  addCheese() { this.#pizza.cheese = true; return this; }
  addPepperoni() { this.#pizza.pepperoni = true; return this; }
  addMushrooms() { this.#pizza.mushrooms = true; return this; }
  addExtraSauce() { this.#pizza.extraSauce = true; return this; }

  build() { return this.#pizza; }
}

// Clean, readable creation
const pizza = new PizzaBuilder()
  .size("large")
  .addCheese()
  .addPepperoni()
  .addExtraSauce()
  .build();

console.log(pizza.toString());
// large pizza with cheese, pepperoni, extra sauce
public class Pizza {
    private String size;
    private boolean cheese;
    private boolean pepperoni;
    private boolean mushrooms;
    private boolean extraSauce;

    // Private constructor -- only the builder can create Pizza
    private Pizza() {}

    @Override
    public String toString() {
        List<String> toppings = new ArrayList<>();
        if (cheese) toppings.add("cheese");
        if (pepperoni) toppings.add("pepperoni");
        if (mushrooms) toppings.add("mushrooms");
        if (extraSauce) toppings.add("extra sauce");
        return size + " pizza with " +
            (toppings.isEmpty() ? "nothing" : String.join(", ", toppings));
    }

    // Static inner Builder class
    public static class Builder {
        private Pizza pizza = new Pizza();

        public Builder size(String size) {
            pizza.size = size;
            return this;
        }
        public Builder addCheese() { pizza.cheese = true; return this; }
        public Builder addPepperoni() { pizza.pepperoni = true; return this; }
        public Builder addMushrooms() { pizza.mushrooms = true; return this; }
        public Builder addExtraSauce() { pizza.extraSauce = true; return this; }

        public Pizza build() { return pizza; }
    }
}

// Usage:
// Pizza pizza = new Pizza.Builder()
//     .size("large")
//     .addCheese()
//     .addPepperoni()
//     .addExtraSauce()
//     .build();

Builder vs Constructor vs Setters

ApproachProsCons
ConstructorSimple, all-at-onceUnreadable with many params
SettersClear namesObject in incomplete state between sets
BuilderReadable, immutable resultMore code to write

The Builder wins when we have 4+ optional parameters. Below that, a constructor is fine.

When to Use

  • Objects with many optional parameters (think HTTP requests, queries, configs)
  • When we want the final object to be immutable (set everything during build, then lock it)
  • When construction has multiple steps that can be combined differently
  • Any time we see a constructor with more than 4-5 parameters

When NOT to Use

  • Simple objects with 2-3 fields — just use a constructor
  • When every field is required — a constructor with named parameters (Python kwargs) works fine

In simple language, Builder is like filling out a form. We fill in what we need, skip what we don’t, and at the end we hit “submit” (build). The result is a clean, fully-constructed object. No guessing what parameter 7 means.


16

Prototype Pattern

advanced 4-7 YOE lld design-pattern creational

The Prototype pattern creates new objects by cloning an existing object instead of constructing one from scratch. We take a fully configured object and make a copy of it.

Think of it like photocopying a document. Instead of typing the whole thing again, we just photocopy it and change what we need. Way faster, way less error-prone.

The Problem It Solves

Sometimes creating an object is expensive. Maybe it requires:

  • A database query to load initial data
  • Complex calculations to set up state
  • Reading from a file or network

If we need many similar objects, building each one from scratch is wasteful. Instead, we create one, clone it, and tweak the differences.

Another scenario: we want a copy of an object but don’t know its concrete class. The object might be behind an interface. We can’t call new WhateverItIs() because we don’t know the type. But we can ask the object to clone itself.

How It Works

Prototype Pattern
Original
name: "Warrior"
hp: 100
weapon: Sword
→ .clone() →
Clone
name: "Warrior 2"
hp: 100
weapon: Sword (copy)
The clone is an independent copy. Changing the clone doesn't affect the original.

Shallow Copy vs Deep Copy

This is the most important thing to understand about Prototype. Interviewers love asking this.

  • Shallow copy: copies the object, but nested objects are still shared references. Changing a nested object in the clone also changes it in the original.
  • Deep copy: copies everything, including nested objects. The clone is 100% independent.
import copy

class Weapon:
    def __init__(self, name: str, damage: int):
        self.name = name
        self.damage = damage

class GameCharacter:
    def __init__(self, name: str, hp: int, weapon: Weapon):
        self.name = name
        self.hp = hp
        self.weapon = weapon

    def clone(self):
        # Deep copy -- weapon object is also duplicated
        return copy.deepcopy(self)

    def __str__(self):
        return f"{self.name} (HP:{self.hp}, Weapon:{self.weapon.name})"

# Create original character
sword = Weapon("Excalibur", 50)
warrior = GameCharacter("Warrior", 100, sword)

# Clone and customize
warrior2 = warrior.clone()
warrior2.name = "Warrior 2"
warrior2.weapon.name = "Dark Sword"  # only changes the clone

print(warrior)   # Warrior (HP:100, Weapon:Excalibur)
print(warrior2)  # Warrior 2 (HP:100, Weapon:Dark Sword)

# Shallow copy danger:
import copy
shallow = copy.copy(warrior)
shallow.weapon.name = "BROKEN"
print(warrior.weapon.name)  # BROKEN! -- shared reference
class Weapon {
  constructor(name, damage) {
    this.name = name;
    this.damage = damage;
  }
}

class GameCharacter {
  constructor(name, hp, weapon) {
    this.name = name;
    this.hp = hp;
    this.weapon = weapon;
  }

  // Deep clone using structuredClone (modern JS)
  clone() {
    return Object.assign(
      Object.create(Object.getPrototypeOf(this)),
      structuredClone(this) // deep copy all properties
    );
  }

  toString() {
    return `${this.name} (HP:${this.hp}, Weapon:${this.weapon.name})`;
  }
}

const sword = new Weapon("Excalibur", 50);
const warrior = new GameCharacter("Warrior", 100, sword);

const warrior2 = warrior.clone();
warrior2.name = "Warrior 2";
warrior2.weapon.name = "Dark Sword"; // only changes the clone

console.log(warrior.toString());  // Warrior (HP:100, Weapon:Excalibur)
console.log(warrior2.toString()); // Warrior 2 (HP:100, Weapon:Dark Sword)

// Shallow copy danger:
// const shallow = { ...warrior };
// shallow.weapon.name = "BROKEN";
// console.log(warrior.weapon.name); // BROKEN! shared reference
public class Weapon implements Cloneable {
    String name;
    int damage;

    Weapon(String name, int damage) {
        this.name = name;
        this.damage = damage;
    }

    @Override
    public Weapon clone() {
        return new Weapon(this.name, this.damage);
    }
}

public class GameCharacter implements Cloneable {
    String name;
    int hp;
    Weapon weapon;

    GameCharacter(String name, int hp, Weapon weapon) {
        this.name = name;
        this.hp = hp;
        this.weapon = weapon;
    }

    // Deep clone -- also clone the weapon
    @Override
    public GameCharacter clone() {
        return new GameCharacter(this.name, this.hp, this.weapon.clone());
    }
}

// Usage:
// Weapon sword = new Weapon("Excalibur", 50);
// GameCharacter warrior = new GameCharacter("Warrior", 100, sword);
// GameCharacter warrior2 = warrior.clone();
// warrior2.name = "Warrior 2";
// warrior2.weapon.name = "Dark Sword"; // only changes the clone

Prototype Registry

Sometimes we keep a registry of pre-configured prototypes. Need a new wizard? Clone the wizard template. Need a tank? Clone the tank template.

class CharacterRegistry:
    def __init__(self):
        self._prototypes = {}

    def register(self, key: str, prototype: GameCharacter):
        self._prototypes[key] = prototype

    def create(self, key: str) -> GameCharacter:
        if key not in self._prototypes:
            raise ValueError(f"Unknown character type: {key}")
        return self._prototypes[key].clone()

# Set up templates once
registry = CharacterRegistry()
registry.register("warrior", GameCharacter("Warrior", 100, Weapon("Sword", 50)))
registry.register("mage", GameCharacter("Mage", 60, Weapon("Staff", 80)))

# Create new characters by cloning templates
player1 = registry.create("warrior")
player1.name = "Arthas"

player2 = registry.create("mage")
player2.name = "Gandalf"
class CharacterRegistry {
  #prototypes = new Map();

  register(key, prototype) {
    this.#prototypes.set(key, prototype);
  }

  create(key) {
    const proto = this.#prototypes.get(key);
    if (!proto) throw new Error(`Unknown type: ${key}`);
    return proto.clone();
  }
}

const registry = new CharacterRegistry();
registry.register("warrior", new GameCharacter("Warrior", 100, new Weapon("Sword", 50)));
registry.register("mage", new GameCharacter("Mage", 60, new Weapon("Staff", 80)));

const player1 = registry.create("warrior");
player1.name = "Arthas";
public class CharacterRegistry {
    private Map<String, GameCharacter> prototypes = new HashMap<>();

    public void register(String key, GameCharacter prototype) {
        prototypes.put(key, prototype);
    }

    public GameCharacter create(String key) {
        GameCharacter proto = prototypes.get(key);
        if (proto == null) throw new IllegalArgumentException("Unknown: " + key);
        return proto.clone();
    }
}

When to Use

  • Creating an object is expensive (DB calls, file reads, complex setup)
  • We need many similar objects with small variations
  • We want to copy an object without depending on its concrete class
  • Game development — spawning enemies, creating item variants

When NOT to Use

  • When objects are simple and cheap to create — cloning adds complexity for no gain
  • When objects have circular references — deep copy can get tricky
  • When the class has very few fields — just use a constructor

In simple language, Prototype says “don’t build from scratch, photocopy and edit.” It’s perfect when construction is expensive or when we need many objects that are mostly the same. Just remember: always deep copy if the object has nested objects, or we’ll end up with shared state bugs that are a nightmare to debug.


Structural Design Patterns

17

Adapter Pattern

intermediate 2-4 YOE lld design-pattern structural

The Adapter pattern lets us make two incompatible interfaces work together. We wrap one class with a new class that translates its interface into something the rest of our code expects.

Think of it like a power adapter. We have a US plug, but we’re in Europe. The plug works fine — we just need an adapter in between to make the shapes match. The adapter doesn’t change the electricity or the device. It just makes them compatible.

The Problem It Solves

We’re building a payment system. Our app expects every payment gateway to have a processPayment(amount) method. Then our company integrates a third-party library whose class has a completely different method: makeTransaction(dollarAmount, currency).

We can’t change the third-party code. We can’t rewrite our whole app. We need an adapter that makes the third-party class look like our expected interface.

How It Works

Adapter Pattern
Client
expects processPayment()
Adapter
processPayment() →
translates to →
makeTransaction()
Legacy Gateway
has makeTransaction()
The client never knows it's talking to a legacy system. The adapter handles the translation.

The adapter:

  1. Implements the target interface (what the client expects)
  2. Holds a reference to the adaptee (the incompatible class)
  3. Translates calls from the target interface to the adaptee’s methods

Code Implementation

from abc import ABC, abstractmethod

# Target interface -- what our app expects
class PaymentGateway(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> str:
        pass

# Our existing gateway -- works perfectly
class StripeGateway(PaymentGateway):
    def process_payment(self, amount: float) -> str:
        return f"Stripe: charged ${amount}"

# Third-party legacy gateway -- DIFFERENT interface
class LegacyPayPal:
    def make_transaction(self, dollar_amount: float, currency: str) -> str:
        return f"PayPal: sent {currency} {dollar_amount}"

# Adapter -- wraps LegacyPayPal to look like PaymentGateway
class PayPalAdapter(PaymentGateway):
    def __init__(self, paypal: LegacyPayPal):
        self._paypal = paypal

    def process_payment(self, amount: float) -> str:
        # Translate our interface to the legacy one
        return self._paypal.make_transaction(amount, "USD")

# Client code -- doesn't care about the underlying implementation
def checkout(gateway: PaymentGateway, amount: float):
    result = gateway.process_payment(amount)
    print(result)

checkout(StripeGateway(), 49.99)               # Stripe: charged $49.99
checkout(PayPalAdapter(LegacyPayPal()), 49.99)  # PayPal: sent USD 49.99
// Target interface (via duck typing in JS)
class StripeGateway {
  processPayment(amount) {
    return `Stripe: charged $${amount}`;
  }
}

// Third-party legacy gateway -- DIFFERENT interface
class LegacyPayPal {
  makeTransaction(dollarAmount, currency) {
    return `PayPal: sent ${currency} ${dollarAmount}`;
  }
}

// Adapter -- wraps LegacyPayPal to match our expected interface
class PayPalAdapter {
  #paypal;

  constructor(paypal) {
    this.#paypal = paypal;
  }

  processPayment(amount) {
    // Translate our interface to the legacy one
    return this.#paypal.makeTransaction(amount, "USD");
  }
}

// Client code
function checkout(gateway, amount) {
  console.log(gateway.processPayment(amount));
}

checkout(new StripeGateway(), 49.99);                   // Stripe: charged $49.99
checkout(new PayPalAdapter(new LegacyPayPal()), 49.99); // PayPal: sent USD 49.99
// Target interface
interface PaymentGateway {
    String processPayment(double amount);
}

class StripeGateway implements PaymentGateway {
    public String processPayment(double amount) {
        return "Stripe: charged $" + amount;
    }
}

// Third-party legacy gateway -- different interface
class LegacyPayPal {
    public String makeTransaction(double dollarAmount, String currency) {
        return "PayPal: sent " + currency + " " + dollarAmount;
    }
}

// Adapter
class PayPalAdapter implements PaymentGateway {
    private final LegacyPayPal paypal;

    PayPalAdapter(LegacyPayPal paypal) {
        this.paypal = paypal;
    }

    public String processPayment(double amount) {
        return paypal.makeTransaction(amount, "USD");
    }
}

// Usage:
// PaymentGateway gw = new PayPalAdapter(new LegacyPayPal());
// System.out.println(gw.processPayment(49.99));

Class Adapter vs Object Adapter

There are two ways to build an adapter:

Object AdapterClass Adapter
HowWraps the adaptee (composition)Extends the adaptee (inheritance)
FlexibilityCan adapt any subclass of adapteeOnly adapts the specific class
RecommendedYes — composition over inheritanceRarely, requires multiple inheritance

We almost always use Object Adapter (composition). It’s more flexible and follows the “favor composition over inheritance” principle.

When to Use

  • Integrating a third-party library with an incompatible interface
  • Working with legacy code that can’t be modified
  • Making unrelated classes work together
  • Wrapping a library so we can swap implementations later

When NOT to Use

  • When we can just modify the source code — don’t add an adapter if we own both sides
  • When the interfaces are too different — if the translation is extremely complex, an adapter might not be the right fit

In simple language, an Adapter is a translator. It sits between two classes that speak different languages and makes them understand each other. We don’t change either class — we just add a middleman that handles the translation.


18

Decorator Pattern

intermediate 2-4 YOE lld design-pattern structural

The Decorator pattern lets us add new behavior to an object without changing its class. We wrap the original object with a new object that adds functionality, like stacking layers.

Think of it like ordering coffee. We start with a base coffee. Then we add milk (wrapper #1). Then sugar (wrapper #2). Then whipped cream (wrapper #3). Each addition wraps the previous one and adds its cost and description. The base coffee never changes.

The Problem It Solves

Let’s say we have a Notification class. Now we want to add logging, encryption, and rate-limiting. Without Decorator, we’d need:

  • LoggedNotification
  • EncryptedNotification
  • RateLimitedNotification
  • LoggedEncryptedNotification
  • LoggedRateLimitedNotification
  • EncryptedRateLimitedNotification
  • LoggedEncryptedRateLimitedNotification

That’s 7 classes for 3 features. Add a 4th feature and it explodes to 15. This is called class explosion and it’s a nightmare.

With Decorator, we just stack what we need: RateLimit(Encrypt(Log(notification))). Each feature is one class. We combine them like LEGO blocks.

How It Works

Decorator Wrapping
WhippedCream ($0.70)
Milk ($0.50)
BaseCoffee ($2.00)
Total: $2.00 + $0.50 + $0.70 = $3.20
Each layer delegates to the inner layer, then adds its own behavior

The key rules:

  1. The decorator implements the same interface as the object it wraps
  2. It holds a reference to the wrapped object
  3. It delegates to the wrapped object, then adds its own behavior

Coffee Shop Example

from abc import ABC, abstractmethod

# Component interface
class Coffee(ABC):
    @abstractmethod
    def cost(self) -> float:
        pass
    @abstractmethod
    def description(self) -> str:
        pass

# Base coffee
class SimpleCoffee(Coffee):
    def cost(self): return 2.00
    def description(self): return "Simple coffee"

# Decorators -- each wraps a Coffee and adds behavior
class MilkDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee  # wrap the inner coffee

    def cost(self): return self._coffee.cost() + 0.50
    def description(self): return self._coffee.description() + ", milk"

class SugarDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee

    def cost(self): return self._coffee.cost() + 0.25
    def description(self): return self._coffee.description() + ", sugar"

class WhippedCreamDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee

    def cost(self): return self._coffee.cost() + 0.70
    def description(self): return self._coffee.description() + ", whipped cream"

# Stack decorators like LEGO blocks
coffee = SimpleCoffee()
coffee = MilkDecorator(coffee)
coffee = SugarDecorator(coffee)
coffee = WhippedCreamDecorator(coffee)

print(coffee.description())  # Simple coffee, milk, sugar, whipped cream
print(f"${coffee.cost()}")   # $3.45
// Base coffee
class SimpleCoffee {
  cost() { return 2.00; }
  description() { return "Simple coffee"; }
}

// Decorators
class MilkDecorator {
  #coffee;
  constructor(coffee) { this.#coffee = coffee; }
  cost() { return this.#coffee.cost() + 0.50; }
  description() { return this.#coffee.description() + ", milk"; }
}

class SugarDecorator {
  #coffee;
  constructor(coffee) { this.#coffee = coffee; }
  cost() { return this.#coffee.cost() + 0.25; }
  description() { return this.#coffee.description() + ", sugar"; }
}

class WhippedCreamDecorator {
  #coffee;
  constructor(coffee) { this.#coffee = coffee; }
  cost() { return this.#coffee.cost() + 0.70; }
  description() { return this.#coffee.description() + ", whipped cream"; }
}

// Stack them up
let coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);
coffee = new WhippedCreamDecorator(coffee);

console.log(coffee.description()); // Simple coffee, milk, sugar, whipped cream
console.log(`$${coffee.cost()}`);  // $3.45
// Component interface
interface Coffee {
    double cost();
    String description();
}

class SimpleCoffee implements Coffee {
    public double cost() { return 2.00; }
    public String description() { return "Simple coffee"; }
}

// Base decorator
abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee;
    CoffeeDecorator(Coffee coffee) { this.coffee = coffee; }
}

class MilkDecorator extends CoffeeDecorator {
    MilkDecorator(Coffee coffee) { super(coffee); }
    public double cost() { return coffee.cost() + 0.50; }
    public String description() { return coffee.description() + ", milk"; }
}

class SugarDecorator extends CoffeeDecorator {
    SugarDecorator(Coffee coffee) { super(coffee); }
    public double cost() { return coffee.cost() + 0.25; }
    public String description() { return coffee.description() + ", sugar"; }
}

class WhippedCreamDecorator extends CoffeeDecorator {
    WhippedCreamDecorator(Coffee coffee) { super(coffee); }
    public double cost() { return coffee.cost() + 0.70; }
    public String description() { return coffee.description() + ", whipped cream"; }
}

// Usage:
// Coffee coffee = new SimpleCoffee();
// coffee = new MilkDecorator(coffee);
// coffee = new SugarDecorator(coffee);
// coffee = new WhippedCreamDecorator(coffee);
// coffee.cost() → 3.45

Decorator vs Inheritance

DecoratorInheritance
WhenRuntimeCompile time
FlexibilityMix and match any combinationFixed class hierarchy
Class countOne class per featureOne class per combination
PrincipleCompositionInheritance

Decorator follows the Open/Closed Principle — we can add new behavior without modifying existing code.

Real-World Examples

  • Java I/O: BufferedReader(InputStreamReader(FileInputStream("file.txt"))) — classic decorator chain
  • Express.js middleware: each middleware wraps the next handler
  • Python decorators: @login_required wraps a function with auth checking (similar concept, different mechanism)

When to Use

  • Adding features to objects at runtime without changing their class
  • When inheritance would cause a class explosion
  • When we want to combine behaviors in any order
  • Logging, caching, encryption, compression wrappers

When NOT to Use

  • When the order of decorators matters a lot and is confusing — too many layers become hard to debug
  • When a simple subclass does the job (only one variation needed)
  • When the component interface is huge — every decorator has to implement every method

In simple language, Decorator is like wrapping a gift. Each layer of wrapping adds something — a bow, a ribbon, a tag. The gift inside never changes. We just keep adding layers on top. And the best part? We can choose any combination of wrapping we want.


19

Facade Pattern

intermediate 2-4 YOE lld design-pattern structural

The Facade pattern provides a simple, unified interface to a complex subsystem. Instead of making clients deal with 10 different classes, we give them one class with easy methods.

Think of it like a waiter at a restaurant. We don’t walk into the kitchen and tell the chef what temperature to cook at, tell the dishwasher which plates to use, and ask the sommelier for wine. We just tell the waiter “I’ll have the steak, medium rare.” The waiter coordinates everything behind the scenes.

The Problem It Solves

Imagine setting up a home theater. Without a facade, we’d have to:

tv.turnOn()
tv.setInput("HDMI1")
speakers.turnOn()
speakers.setVolume(30)
bluray.turnOn()
bluray.play("movie.mkv")
lights.dim(20)

Seven steps just to watch a movie. And we have to remember the exact order. If we do this from multiple places in the app, it gets messy fast.

With a facade: homeTheater.watchMovie("movie.mkv"). Done. One call.

How It Works

Facade Pattern
Client
↓ one simple call
HomeTheaterFacade
watchMovie() / endMovie()
↓ coordinates many calls
TV
Speakers
BluRay
Lights

The facade doesn’t add new functionality. It just simplifies access to existing functionality. Clients can still use the subsystem classes directly if they need fine-grained control.

Code Implementation

# Complex subsystem classes
class TV:
    def turn_on(self): print("TV: on")
    def set_input(self, source): print(f"TV: input set to {source}")
    def turn_off(self): print("TV: off")

class Speakers:
    def turn_on(self): print("Speakers: on")
    def set_volume(self, level): print(f"Speakers: volume {level}")
    def turn_off(self): print("Speakers: off")

class BluRayPlayer:
    def turn_on(self): print("BluRay: on")
    def play(self, movie): print(f"BluRay: playing {movie}")
    def stop(self): print("BluRay: stopped")
    def turn_off(self): print("BluRay: off")

class Lights:
    def dim(self, level): print(f"Lights: dimmed to {level}%")
    def on(self): print("Lights: on full")

# Facade -- one simple interface
class HomeTheaterFacade:
    def __init__(self):
        self.tv = TV()
        self.speakers = Speakers()
        self.bluray = BluRayPlayer()
        self.lights = Lights()

    def watch_movie(self, movie: str):
        print("--- Starting movie night ---")
        self.lights.dim(20)
        self.tv.turn_on()
        self.tv.set_input("HDMI1")
        self.speakers.turn_on()
        self.speakers.set_volume(30)
        self.bluray.turn_on()
        self.bluray.play(movie)

    def end_movie(self):
        print("--- Shutting down ---")
        self.bluray.stop()
        self.bluray.turn_off()
        self.speakers.turn_off()
        self.tv.turn_off()
        self.lights.on()

# One line does everything
theater = HomeTheaterFacade()
theater.watch_movie("Inception")
theater.end_movie()
// Complex subsystem classes
class TV {
  turnOn() { console.log("TV: on"); }
  setInput(source) { console.log(`TV: input set to ${source}`); }
  turnOff() { console.log("TV: off"); }
}

class Speakers {
  turnOn() { console.log("Speakers: on"); }
  setVolume(level) { console.log(`Speakers: volume ${level}`); }
  turnOff() { console.log("Speakers: off"); }
}

class BluRayPlayer {
  turnOn() { console.log("BluRay: on"); }
  play(movie) { console.log(`BluRay: playing ${movie}`); }
  stop() { console.log("BluRay: stopped"); }
  turnOff() { console.log("BluRay: off"); }
}

class Lights {
  dim(level) { console.log(`Lights: dimmed to ${level}%`); }
  on() { console.log("Lights: on full"); }
}

// Facade
class HomeTheaterFacade {
  #tv = new TV();
  #speakers = new Speakers();
  #bluray = new BluRayPlayer();
  #lights = new Lights();

  watchMovie(movie) {
    console.log("--- Starting movie night ---");
    this.#lights.dim(20);
    this.#tv.turnOn();
    this.#tv.setInput("HDMI1");
    this.#speakers.turnOn();
    this.#speakers.setVolume(30);
    this.#bluray.turnOn();
    this.#bluray.play(movie);
  }

  endMovie() {
    console.log("--- Shutting down ---");
    this.#bluray.stop();
    this.#bluray.turnOff();
    this.#speakers.turnOff();
    this.#tv.turnOff();
    this.#lights.on();
  }
}

const theater = new HomeTheaterFacade();
theater.watchMovie("Inception");
theater.endMovie();
// Complex subsystem classes
class TV {
    void turnOn() { System.out.println("TV: on"); }
    void setInput(String source) { System.out.println("TV: input set to " + source); }
    void turnOff() { System.out.println("TV: off"); }
}

class Speakers {
    void turnOn() { System.out.println("Speakers: on"); }
    void setVolume(int level) { System.out.println("Speakers: volume " + level); }
    void turnOff() { System.out.println("Speakers: off"); }
}

class BluRayPlayer {
    void turnOn() { System.out.println("BluRay: on"); }
    void play(String movie) { System.out.println("BluRay: playing " + movie); }
    void stop() { System.out.println("BluRay: stopped"); }
    void turnOff() { System.out.println("BluRay: off"); }
}

class Lights {
    void dim(int level) { System.out.println("Lights: dimmed to " + level + "%"); }
    void on() { System.out.println("Lights: on full"); }
}

// Facade
class HomeTheaterFacade {
    private TV tv = new TV();
    private Speakers speakers = new Speakers();
    private BluRayPlayer bluray = new BluRayPlayer();
    private Lights lights = new Lights();

    void watchMovie(String movie) {
        System.out.println("--- Starting movie night ---");
        lights.dim(20);
        tv.turnOn();
        tv.setInput("HDMI1");
        speakers.turnOn();
        speakers.setVolume(30);
        bluray.turnOn();
        bluray.play(movie);
    }

    void endMovie() {
        System.out.println("--- Shutting down ---");
        bluray.stop();
        bluray.turnOff();
        speakers.turnOff();
        tv.turnOff();
        lights.on();
    }
}

Real-World Examples

Facades are everywhere:

  • jQuery: $("div").hide() is a facade over complex DOM manipulation
  • ORM libraries: User.findById(1) is a facade over SQL queries, connection pooling, and result mapping
  • AWS SDK: s3.upload(file) is a facade over HTTP requests, multipart uploads, retries, and auth
  • Express.js: res.json(data) is a facade over serialization, headers, and stream writing

Facade vs Adapter

People mix these up. Here’s the difference:

FacadeAdapter
PurposeSimplify a complex interfaceMake incompatible interfaces compatible
What changesThe complexity levelThe interface shape
Number of classes wrappedUsually manyUsually one

When to Use

  • When a subsystem has too many classes and clients need a simpler entry point
  • When we want to layer our system — facade acts as a clean boundary between layers
  • When we want to reduce dependencies between clients and complex subsystems

When NOT to Use

  • When the subsystem is already simple — don’t add a facade over one class
  • When clients genuinely need fine-grained control over every subsystem class
  • When the facade becomes a God object that does everything — that’s a red flag

In simple language, a Facade is a reception desk. Behind that desk, there’s a complex office with dozens of people doing different jobs. But we don’t need to know about any of that. We just talk to the receptionist, and they handle the rest.


20

Proxy Pattern

advanced 4-7 YOE lld design-pattern structural

The Proxy pattern puts a surrogate object in front of the real object to control access to it. The proxy has the same interface as the real object, so clients don’t even know they’re talking to a proxy.

Think of it like a security guard at a building entrance. We don’t interact with the building directly. The guard checks our ID, decides if we can enter, maybe logs our visit — all before letting us through to the actual building.

The Problem It Solves

Sometimes we can’t or don’t want to give clients direct access to an object:

  • The object is expensive to create and we want to delay it until it’s actually needed (lazy loading)
  • We need to check permissions before allowing access
  • We want to cache results to avoid repeated expensive operations
  • We need to log every access to the object

A proxy handles all of this without the client knowing anything changed.

Types of Proxy

Three Common Proxy Types
Virtual Proxy
Delays creating the real object until we actually use it. Great for expensive resources like large images or DB connections.
Protection Proxy
Checks if the client has permission before forwarding the request. Like an access control layer.
Caching Proxy
Stores results of expensive operations and returns the cached version if the same request comes again.

Virtual Proxy — Lazy Image Loading

The image isn’t loaded until we actually call display(). This saves memory and startup time.

from abc import ABC, abstractmethod

# Common interface
class Image(ABC):
    @abstractmethod
    def display(self) -> None:
        pass

# Real image -- expensive to create (loads from disk)
class RealImage(Image):
    def __init__(self, filename: str):
        self.filename = filename
        self._load_from_disk()  # Slow! Happens at creation time

    def _load_from_disk(self):
        print(f"Loading {self.filename} from disk... (slow)")

    def display(self):
        print(f"Displaying {self.filename}")

# Proxy -- delays loading until display() is called
class ImageProxy(Image):
    def __init__(self, filename: str):
        self.filename = filename
        self._real_image = None  # not loaded yet!

    def display(self):
        if self._real_image is None:
            self._real_image = RealImage(self.filename)  # load on first use
        self._real_image.display()

# Without proxy: image loads immediately (even if never displayed)
# With proxy: image loads ONLY when display() is called
gallery = [ImageProxy(f"photo_{i}.jpg") for i in range(100)]
# Nothing loaded yet! Memory is clean.

gallery[0].display()  # NOW photo_0.jpg loads
gallery[0].display()  # Already loaded, just displays
// Real image -- expensive to create
class RealImage {
  constructor(filename) {
    this.filename = filename;
    this.#loadFromDisk();
  }

  #loadFromDisk() {
    console.log(`Loading ${this.filename} from disk... (slow)`);
  }

  display() {
    console.log(`Displaying ${this.filename}`);
  }
}

// Proxy -- delays loading
class ImageProxy {
  #filename;
  #realImage = null;

  constructor(filename) {
    this.#filename = filename;
  }

  display() {
    if (!this.#realImage) {
      this.#realImage = new RealImage(this.#filename);
    }
    this.#realImage.display();
  }
}

// Create 100 proxies -- nothing loads
const gallery = Array.from({ length: 100 },
  (_, i) => new ImageProxy(`photo_${i}.jpg`)
);

gallery[0].display(); // NOW it loads
gallery[0].display(); // Already loaded
interface Image {
    void display();
}

class RealImage implements Image {
    private String filename;

    RealImage(String filename) {
        this.filename = filename;
        loadFromDisk();
    }

    private void loadFromDisk() {
        System.out.println("Loading " + filename + " from disk... (slow)");
    }

    public void display() {
        System.out.println("Displaying " + filename);
    }
}

class ImageProxy implements Image {
    private String filename;
    private RealImage realImage;

    ImageProxy(String filename) {
        this.filename = filename;
        // realImage is null -- not loaded yet
    }

    public void display() {
        if (realImage == null) {
            realImage = new RealImage(filename); // load on first use
        }
        realImage.display();
    }
}

Protection Proxy — Access Control

Only users with the right role can perform certain actions.

class Document:
    def __init__(self, content: str):
        self.content = content

    def read(self) -> str:
        return self.content

    def write(self, content: str):
        self.content = content

class DocumentProxy:
    def __init__(self, document: Document, user_role: str):
        self._document = document
        self._user_role = user_role

    def read(self) -> str:
        # Everyone can read
        return self._document.read()

    def write(self, content: str):
        # Only admins can write
        if self._user_role != "admin":
            raise PermissionError("Only admins can edit this document")
        self._document.write(content)

doc = Document("Secret stuff")
viewer = DocumentProxy(doc, "viewer")
admin = DocumentProxy(doc, "admin")

print(viewer.read())       # "Secret stuff" -- works
admin.write("Updated")     # works
# viewer.write("Hacked!")  # raises PermissionError
class Document {
  constructor(content) { this.content = content; }
  read() { return this.content; }
  write(content) { this.content = content; }
}

class DocumentProxy {
  #document;
  #userRole;

  constructor(document, userRole) {
    this.#document = document;
    this.#userRole = userRole;
  }

  read() {
    return this.#document.read();
  }

  write(content) {
    if (this.#userRole !== "admin") {
      throw new Error("Only admins can edit this document");
    }
    this.#document.write(content);
  }
}

const doc = new Document("Secret stuff");
const viewer = new DocumentProxy(doc, "viewer");
const admin = new DocumentProxy(doc, "admin");

console.log(viewer.read());     // "Secret stuff"
admin.write("Updated");         // works
// viewer.write("Hacked!");     // throws Error
class Document {
    private String content;
    Document(String content) { this.content = content; }
    String read() { return content; }
    void write(String content) { this.content = content; }
}

class DocumentProxy {
    private Document document;
    private String userRole;

    DocumentProxy(Document document, String userRole) {
        this.document = document;
        this.userRole = userRole;
    }

    String read() {
        return document.read();
    }

    void write(String content) {
        if (!"admin".equals(userRole)) {
            throw new SecurityException("Only admins can edit");
        }
        document.write(content);
    }
}

Proxy vs Decorator vs Adapter

These three look similar because they all wrap objects. Here’s how to tell them apart:

PatternPurposeKey Difference
ProxyControl access to an objectSame interface, manages lifecycle/access
DecoratorAdd new behavior to an objectSame interface, adds functionality
AdapterMake incompatible interfaces workDifferent interface, translates

The easiest way to remember: Proxy controls, Decorator enhances, Adapter translates.

When to Use

  • Lazy initialization — delay expensive object creation until needed
  • Access control — check permissions before forwarding requests
  • Caching — store expensive results and reuse them
  • Logging/monitoring — track every access to an object
  • Remote proxy — represent an object that lives on a different server (like RPC stubs)

When NOT to Use

  • When there’s no reason to control access — adding a proxy “just in case” is overengineering
  • When the overhead of an extra layer hurts performance more than it helps
  • When the real object is cheap to create and has no access restrictions

In simple language, a Proxy is a bodyguard standing in front of the real object. It looks just like the real thing from the outside (same interface), but it decides when, how, and if we get access. Lazy loading, permission checks, caching — the proxy handles all of it, and the client never knows it’s not talking to the real thing.


Behavioral Design Patterns

21

Observer Pattern

intermediate 2-4 YOE lld design-pattern behavioral

The Observer pattern sets up a one-to-many relationship between objects. When one object (the subject) changes its state, all its dependents (observers) get notified and updated automatically.

Think of it like YouTube subscriptions. We subscribe to a channel. When the channel uploads a new video, every subscriber gets a notification. The channel doesn’t need to know who we are or what we do with the notification — it just broadcasts.

The Problem

Let’s say we have a stock price system. Multiple displays need to show the latest price — a dashboard, a mobile app, an alert system. Without Observer, we’d have to:

  • Make the stock object directly call each display (tight coupling)
  • Have each display constantly poll the stock for changes (wasteful)
  • Add new if-else branches every time we add a new display (violates Open/Closed)

The Observer pattern decouples all of this. The stock just says “hey, I changed” and everyone listening gets the update.

How It Works

Observer Pattern Structure
Subject (Publisher)
- observers: List<Observer>
- subscribe(observer)
- unsubscribe(observer)
- notify()
──notifies──▶
Observer (Subscriber)
- update(data)

Implementations:
- EmailAlert
- Dashboard
- MobileApp

The Subject keeps a list of observers. When something changes, it loops through the list and calls update() on each one. Observers can subscribe or unsubscribe at any time.

Push vs Pull Model

There are two ways the subject can share data with observers:

  • Push model — The subject sends the data directly in the update() call. Observers get everything whether they need it or not.
  • Pull model — The subject just says “I changed.” Observers then call back to the subject to get only the data they care about.

Push is simpler. Pull is more flexible when different observers need different pieces of data.

Implementation

from abc import ABC, abstractmethod

class Observer(ABC):
    @abstractmethod
    def update(self, news: str) -> None:
        pass

class NewsPublisher:
    def __init__(self):
        self._subscribers: list[Observer] = []
        self._latest_news = ""

    def subscribe(self, observer: Observer):
        self._subscribers.append(observer)

    def unsubscribe(self, observer: Observer):
        self._subscribers.remove(observer)

    def publish(self, news: str):
        self._latest_news = news
        self._notify()

    def _notify(self):
        for subscriber in self._subscribers:
            subscriber.update(self._latest_news)

class EmailSubscriber(Observer):
    def __init__(self, email: str):
        self.email = email

    def update(self, news: str):
        print(f"Email to {self.email}: {news}")

class AppSubscriber(Observer):
    def __init__(self, user_id: str):
        self.user_id = user_id

    def update(self, news: str):
        print(f"Push notification to {self.user_id}: {news}")

# Usage
publisher = NewsPublisher()
email_sub = EmailSubscriber("dev@example.com")
app_sub = AppSubscriber("user_42")

publisher.subscribe(email_sub)
publisher.subscribe(app_sub)
publisher.publish("Breaking: Observer pattern is awesome!")
# Email to dev@example.com: Breaking: Observer pattern is awesome!
# Push notification to user_42: Breaking: Observer pattern is awesome!
class NewsPublisher {
  constructor() {
    this.subscribers = [];
    this.latestNews = "";
  }

  subscribe(observer) {
    this.subscribers.push(observer);
  }

  unsubscribe(observer) {
    this.subscribers = this.subscribers.filter((s) => s !== observer);
  }

  publish(news) {
    this.latestNews = news;
    this.subscribers.forEach((sub) => sub.update(news));
  }
}

class EmailSubscriber {
  constructor(email) {
    this.email = email;
  }
  update(news) {
    console.log(`Email to ${this.email}: ${news}`);
  }
}

class AppSubscriber {
  constructor(userId) {
    this.userId = userId;
  }
  update(news) {
    console.log(`Push notification to ${this.userId}: ${news}`);
  }
}

// Usage
const publisher = new NewsPublisher();
const emailSub = new EmailSubscriber("dev@example.com");
const appSub = new AppSubscriber("user_42");

publisher.subscribe(emailSub);
publisher.subscribe(appSub);
publisher.publish("Breaking: Observer pattern is awesome!");
import java.util.ArrayList;
import java.util.List;

interface Observer {
    void update(String news);
}

class NewsPublisher {
    private List<Observer> subscribers = new ArrayList<>();
    private String latestNews;

    public void subscribe(Observer observer) {
        subscribers.add(observer);
    }

    public void unsubscribe(Observer observer) {
        subscribers.remove(observer);
    }

    public void publish(String news) {
        this.latestNews = news;
        for (Observer sub : subscribers) {
            sub.update(news);
        }
    }
}

class EmailSubscriber implements Observer {
    private String email;

    public EmailSubscriber(String email) { this.email = email; }

    public void update(String news) {
        System.out.println("Email to " + email + ": " + news);
    }
}

class AppSubscriber implements Observer {
    private String userId;

    public AppSubscriber(String userId) { this.userId = userId; }

    public void update(String news) {
        System.out.println("Push notification to " + userId + ": " + news);
    }
}

When to Use

  • Event systems — UI frameworks use this heavily (click handlers, state changes)
  • Pub/Sub messaging — message brokers like Kafka, RabbitMQ are Observer on steroids
  • Notification systems — email, SMS, push notifications when something happens
  • Data binding — React’s state updates, Angular’s change detection, MobX

When NOT to Use

  • When we only have one observer — overkill, just call it directly
  • When notification order matters a lot — Observer doesn’t guarantee order
  • When observers need to respond synchronously in sequence — can get messy
  • When the subject changes very frequently — notification storms can kill performance

Common Interview Questions

Q: How is Observer different from Pub/Sub? Observer is direct — the subject knows about observers and calls them. Pub/Sub has a middleman (message broker) so publishers and subscribers don’t know about each other at all.

Q: How do we avoid memory leaks? Always unsubscribe when an observer is done. In languages without garbage collection, dangling observer references are a classic memory leak.

In simple language, the Observer pattern is like a newsletter. We sign up, we get updates. We unsubscribe, we stop getting them. The publisher doesn’t care who’s reading — it just publishes. Clean, decoupled, and extensible.


22

Strategy Pattern

intermediate 2-4 YOE lld design-pattern behavioral

The Strategy pattern lets us define a family of algorithms, put each one in its own class, and swap them at runtime. The client code doesn’t change at all — it just picks a different strategy.

Think of it like a navigation app. We want to go from A to B. The app gives us options: fastest route, shortest route, avoid tolls, walking only. Same destination, same app — but completely different algorithms calculating the path. That’s Strategy.

The Problem

Imagine we’re building a payment system. Without Strategy, our code looks like this:

if paymentType == "credit_card":
    # 30 lines of credit card logic
elif paymentType == "paypal":
    # 25 lines of PayPal logic
elif paymentType == "crypto":
    # 35 lines of crypto logic
elif paymentType == "bank_transfer":
    # 20 lines of bank transfer logic

Every new payment method means another elif block. The class gets bigger and bigger. Testing is painful. Adding UPI means touching this giant file. This violates both Single Responsibility and Open/Closed principles.

How Strategy Fixes This

Strategy Pattern Structure
Context (PaymentProcessor)
- strategy: PaymentStrategy
- pay(amount)
│ delegates to
PaymentStrategy (Interface)
+ execute(amount)
│ implemented by
CreditCardStrategy
PayPalStrategy
CryptoStrategy

Each algorithm gets its own class. The context (PaymentProcessor) holds a reference to a strategy and delegates to it. We can swap strategies at runtime without touching any existing code.

Implementation

from abc import ABC, abstractmethod

class PaymentStrategy(ABC):
    @abstractmethod
    def execute(self, amount: float) -> None:
        pass

class CreditCardPayment(PaymentStrategy):
    def __init__(self, card_number: str):
        self.card_number = card_number

    def execute(self, amount: float):
        print(f"Charged ${amount:.2f} to card ending {self.card_number[-4:]}")

class PayPalPayment(PaymentStrategy):
    def __init__(self, email: str):
        self.email = email

    def execute(self, amount: float):
        print(f"Sent ${amount:.2f} via PayPal to {self.email}")

class CryptoPayment(PaymentStrategy):
    def __init__(self, wallet_address: str):
        self.wallet = wallet_address

    def execute(self, amount: float):
        print(f"Transferred ${amount:.2f} in crypto to {self.wallet[:8]}...")

class PaymentProcessor:
    def __init__(self, strategy: PaymentStrategy):
        self._strategy = strategy

    def set_strategy(self, strategy: PaymentStrategy):
        self._strategy = strategy

    def pay(self, amount: float):
        self._strategy.execute(amount)

# Usage
processor = PaymentProcessor(CreditCardPayment("4111111111111234"))
processor.pay(99.99)  # Charged $99.99 to card ending 1234

processor.set_strategy(PayPalPayment("dev@example.com"))
processor.pay(49.99)  # Sent $49.99 via PayPal to dev@example.com

processor.set_strategy(CryptoPayment("0xABCDEF1234567890"))
processor.pay(199.99)  # Transferred $199.99 in crypto to 0xABCDEF...
class CreditCardPayment {
  constructor(cardNumber) {
    this.cardNumber = cardNumber;
  }
  execute(amount) {
    console.log(`Charged $${amount.toFixed(2)} to card ending ${this.cardNumber.slice(-4)}`);
  }
}

class PayPalPayment {
  constructor(email) {
    this.email = email;
  }
  execute(amount) {
    console.log(`Sent $${amount.toFixed(2)} via PayPal to ${this.email}`);
  }
}

class CryptoPayment {
  constructor(walletAddress) {
    this.wallet = walletAddress;
  }
  execute(amount) {
    console.log(`Transferred $${amount.toFixed(2)} in crypto to ${this.wallet.slice(0, 8)}...`);
  }
}

class PaymentProcessor {
  constructor(strategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy) {
    this.strategy = strategy;
  }

  pay(amount) {
    this.strategy.execute(amount);
  }
}

// Usage
const processor = new PaymentProcessor(new CreditCardPayment("4111111111111234"));
processor.pay(99.99);

processor.setStrategy(new PayPalPayment("dev@example.com"));
processor.pay(49.99);
interface PaymentStrategy {
    void execute(double amount);
}

class CreditCardPayment implements PaymentStrategy {
    private String cardNumber;

    public CreditCardPayment(String cardNumber) {
        this.cardNumber = cardNumber;
    }

    public void execute(double amount) {
        System.out.printf("Charged $%.2f to card ending %s%n",
            amount, cardNumber.substring(cardNumber.length() - 4));
    }
}

class PayPalPayment implements PaymentStrategy {
    private String email;

    public PayPalPayment(String email) { this.email = email; }

    public void execute(double amount) {
        System.out.printf("Sent $%.2f via PayPal to %s%n", amount, email);
    }
}

class CryptoPayment implements PaymentStrategy {
    private String wallet;

    public CryptoPayment(String wallet) { this.wallet = wallet; }

    public void execute(double amount) {
        System.out.printf("Transferred $%.2f in crypto to %s...%n",
            amount, wallet.substring(0, 8));
    }
}

class PaymentProcessor {
    private PaymentStrategy strategy;

    public PaymentProcessor(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

    public void setStrategy(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

    public void pay(double amount) {
        strategy.execute(amount);
    }
}

When to Use

  • Multiple algorithms for the same task — sorting, compression, validation, pricing
  • Eliminating conditional logic — replacing if-else chains with polymorphism
  • Runtime behavior switching — user picks a shipping method, discount type, etc.
  • Testing — we can inject mock strategies easily

When NOT to Use

  • When we only have 2 algorithms that will never change — a simple if-else is fine
  • When the algorithms share a lot of state with the context — the separation becomes awkward
  • When clients don’t need to know about different strategies — added complexity for no gain

Strategy vs State Pattern

People mix these up constantly in interviews. The key difference:

  • Strategy — the client picks which algorithm to use. Strategies don’t know about each other.
  • State — the object transitions between states on its own. States know which state comes next.

The only difference is who controls the switching. In Strategy, it’s the outside code. In State, it’s the states themselves.

In simple language, Strategy is like having a toolbox. We pick the right tool for the job and snap it in. Need a different tool? Swap it out. The workbench (context) doesn’t care which tool we’re using — it just says “do the thing” and the tool handles the rest.


23

Command Pattern

advanced 4-7 YOE lld design-pattern behavioral

The Command pattern turns a request into a standalone object. This object contains everything about the request — what to do, who does it, and any parameters needed. Once it’s an object, we can pass it around, queue it, log it, and even undo it.

Think of it like a restaurant order slip. We tell the waiter “I want a burger.” The waiter writes it on a slip (the command object) and hands it to the kitchen. The kitchen executes it later. The waiter doesn’t cook. The kitchen doesn’t take orders. And if we change our mind, we can cancel the slip.

The Problem

Imagine we’re building a text editor. We need undo/redo, keyboard shortcuts, menu actions, and toolbar buttons — all triggering the same operations. Without Command:

  • The UI directly calls business logic (tight coupling)
  • Undo/redo is nearly impossible without tracking every change
  • We can’t queue operations or replay them
  • Adding a new action means changing multiple places

Key Components

Command Pattern Structure
Invoker
Triggers the command.
Button, menu item,
keyboard shortcut.
─▶
Command
execute()
undo()
Wraps the request
as an object.
─▶
Receiver
Does the actual work.
TextDocument,
Light, Thermostat.
Invoker doesn't know what the command does. Command doesn't know who triggered it.
  • Command — interface with execute() and optionally undo()
  • Concrete Command — implements the command, holds a reference to the receiver
  • Invoker — triggers the command (doesn’t know what it does)
  • Receiver — the object that actually performs the work

Implementation — Text Editor with Undo/Redo

from abc import ABC, abstractmethod

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

    @abstractmethod
    def undo(self) -> None:
        pass

class TextDocument:
    """The Receiver -- does the actual work."""
    def __init__(self):
        self.content = ""

    def insert(self, text: str, position: int):
        self.content = self.content[:position] + text + self.content[position:]

    def delete(self, position: int, length: int) -> str:
        deleted = self.content[position:position + length]
        self.content = self.content[:position] + self.content[position + length:]
        return deleted

class InsertCommand(Command):
    def __init__(self, document: TextDocument, text: str, position: int):
        self.document = document
        self.text = text
        self.position = position

    def execute(self):
        self.document.insert(self.text, self.position)

    def undo(self):
        self.document.delete(self.position, len(self.text))

class DeleteCommand(Command):
    def __init__(self, document: TextDocument, position: int, length: int):
        self.document = document
        self.position = position
        self.length = length
        self.deleted_text = ""

    def execute(self):
        self.deleted_text = self.document.delete(self.position, self.length)

    def undo(self):
        self.document.insert(self.deleted_text, self.position)

class Editor:
    """The Invoker -- triggers commands and manages history."""
    def __init__(self, document: TextDocument):
        self.document = document
        self.history: list[Command] = []
        self.redo_stack: list[Command] = []

    def execute(self, command: Command):
        command.execute()
        self.history.append(command)
        self.redo_stack.clear()

    def undo(self):
        if not self.history:
            return
        cmd = self.history.pop()
        cmd.undo()
        self.redo_stack.append(cmd)

    def redo(self):
        if not self.redo_stack:
            return
        cmd = self.redo_stack.pop()
        cmd.execute()
        self.history.append(cmd)

# Usage
doc = TextDocument()
editor = Editor(doc)

editor.execute(InsertCommand(doc, "Hello", 0))
print(doc.content)  # "Hello"

editor.execute(InsertCommand(doc, " World", 5))
print(doc.content)  # "Hello World"

editor.undo()
print(doc.content)  # "Hello"

editor.redo()
print(doc.content)  # "Hello World"
class TextDocument {
  constructor() {
    this.content = "";
  }

  insert(text, position) {
    this.content = this.content.slice(0, position) + text + this.content.slice(position);
  }

  delete(position, length) {
    const deleted = this.content.slice(position, position + length);
    this.content = this.content.slice(0, position) + this.content.slice(position + length);
    return deleted;
  }
}

class InsertCommand {
  constructor(document, text, position) {
    this.document = document;
    this.text = text;
    this.position = position;
  }

  execute() {
    this.document.insert(this.text, this.position);
  }

  undo() {
    this.document.delete(this.position, this.text.length);
  }
}

class DeleteCommand {
  constructor(document, position, length) {
    this.document = document;
    this.position = position;
    this.length = length;
    this.deletedText = "";
  }

  execute() {
    this.deletedText = this.document.delete(this.position, this.length);
  }

  undo() {
    this.document.insert(this.deletedText, this.position);
  }
}

class Editor {
  constructor(document) {
    this.document = document;
    this.history = [];
    this.redoStack = [];
  }

  execute(command) {
    command.execute();
    this.history.push(command);
    this.redoStack = [];
  }

  undo() {
    if (this.history.length === 0) return;
    const cmd = this.history.pop();
    cmd.undo();
    this.redoStack.push(cmd);
  }

  redo() {
    if (this.redoStack.length === 0) return;
    const cmd = this.redoStack.pop();
    cmd.execute();
    this.history.push(cmd);
  }
}

// Usage
const doc = new TextDocument();
const editor = new Editor(doc);

editor.execute(new InsertCommand(doc, "Hello", 0));
console.log(doc.content); // "Hello"

editor.execute(new InsertCommand(doc, " World", 5));
console.log(doc.content); // "Hello World"

editor.undo();
console.log(doc.content); // "Hello"
import java.util.Stack;

interface Command {
    void execute();
    void undo();
}

class TextDocument {
    StringBuilder content = new StringBuilder();

    void insert(String text, int position) {
        content.insert(position, text);
    }

    String delete(int position, int length) {
        String deleted = content.substring(position, position + length);
        content.delete(position, position + length);
        return deleted;
    }

    String getContent() { return content.toString(); }
}

class InsertCommand implements Command {
    private TextDocument document;
    private String text;
    private int position;

    InsertCommand(TextDocument doc, String text, int position) {
        this.document = doc;
        this.text = text;
        this.position = position;
    }

    public void execute() { document.insert(text, position); }
    public void undo() { document.delete(position, text.length()); }
}

class DeleteCommand implements Command {
    private TextDocument document;
    private int position, length;
    private String deletedText = "";

    DeleteCommand(TextDocument doc, int position, int length) {
        this.document = doc;
        this.position = position;
        this.length = length;
    }

    public void execute() { deletedText = document.delete(position, length); }
    public void undo() { document.insert(deletedText, position); }
}

class Editor {
    private TextDocument document;
    private Stack<Command> history = new Stack<>();
    private Stack<Command> redoStack = new Stack<>();

    Editor(TextDocument document) { this.document = document; }

    void execute(Command cmd) {
        cmd.execute();
        history.push(cmd);
        redoStack.clear();
    }

    void undo() {
        if (history.isEmpty()) return;
        Command cmd = history.pop();
        cmd.undo();
        redoStack.push(cmd);
    }

    void redo() {
        if (redoStack.isEmpty()) return;
        Command cmd = redoStack.pop();
        cmd.execute();
        history.push(cmd);
    }
}

When to Use

  • Undo/Redo — text editors, drawing apps, any operation we need to reverse
  • Task queues — schedule commands to run later, retry on failure
  • Macro recording — record a sequence of commands and replay them
  • Transaction management — execute a batch, roll back everything if one fails
  • Remote control / smart home — “turn on lights” is a command object

When NOT to Use

  • Simple direct calls that will never need undo, queuing, or logging
  • When the command just wraps a single method call with no extra behavior — that’s pointless indirection
  • When we don’t need any of the benefits (undo, queue, log) — it’s just extra classes

Common Interview Questions

Q: How does Command enable macro recording? Store a list of executed commands. To replay the macro, just loop through the list and call execute() on each one. Simple as that.

Q: How do we implement transactions with Command? Execute commands in sequence. If any fails, call undo() on all previously executed commands in reverse order. Same idea as database rollback.

In simple language, Command is like writing down a task on a sticky note instead of doing it immediately. Once it’s written down, we can stick it on a board (queue), throw it away (cancel), or unstick it and reverse what it did (undo). The sticky note IS the command.


24

State Pattern

advanced 4-7 YOE lld design-pattern behavioral

The State pattern lets an object change its behavior when its internal state changes. From the outside, it looks like the object changed its class entirely.

Think of it like a vending machine. When it’s idle, pressing buttons does nothing useful. Insert money, and now the buttons actually work. Select an item, and it dispenses. Try to select again without money? Nope, back to idle. Same machine, completely different behavior depending on its state.

The Problem

Without the State pattern, we end up with code like this:

def press_button(self):
    if self.state == "idle":
        print("Insert money first")
    elif self.state == "has_money":
        self.dispense()
        self.state = "dispensing"
    elif self.state == "dispensing":
        print("Already dispensing, please wait")
    elif self.state == "out_of_stock":
        print("Sorry, out of stock")

Every method has the same if-else chain. Add a new state? We have to update every single method. Forget one? Bug. This gets out of hand fast.

How State Fixes This

State Pattern Structure
Context (VendingMachine)
- state: State
- set_state(state)
- insert_money() / select_item()
│ delegates to current state
State (Interface)
+ insert_money(context)
+ select_item(context)
+ dispense(context)
│ implemented by
IdleState
HasMoneyState
DispensingState
OutOfStockState

Each state is its own class. The context delegates all behavior to the current state object. When a transition happens, the state object swaps itself out for the next one. No if-else chains anywhere.

Implementation — Vending Machine

from abc import ABC, abstractmethod

class State(ABC):
    @abstractmethod
    def insert_money(self, machine) -> None:
        pass

    @abstractmethod
    def select_item(self, machine) -> None:
        pass

    @abstractmethod
    def dispense(self, machine) -> None:
        pass

class IdleState(State):
    def insert_money(self, machine):
        print("Money accepted!")
        machine.set_state(HasMoneyState())

    def select_item(self, machine):
        print("Insert money first.")

    def dispense(self, machine):
        print("Insert money and select an item first.")

class HasMoneyState(State):
    def insert_money(self, machine):
        print("Money already inserted.")

    def select_item(self, machine):
        if machine.stock > 0:
            print("Item selected. Dispensing...")
            machine.set_state(DispensingState())
        else:
            print("Out of stock! Refunding money.")
            machine.set_state(OutOfStockState())

    def dispense(self, machine):
        print("Select an item first.")

class DispensingState(State):
    def insert_money(self, machine):
        print("Please wait, dispensing in progress.")

    def select_item(self, machine):
        print("Already dispensing, please wait.")

    def dispense(self, machine):
        machine.stock -= 1
        print(f"Item dispensed! ({machine.stock} left)")
        if machine.stock > 0:
            machine.set_state(IdleState())
        else:
            machine.set_state(OutOfStockState())

class OutOfStockState(State):
    def insert_money(self, machine):
        print("Sorry, machine is out of stock.")

    def select_item(self, machine):
        print("Machine is out of stock.")

    def dispense(self, machine):
        print("Nothing to dispense.")

class VendingMachine:
    def __init__(self, stock: int):
        self.stock = stock
        self._state: State = IdleState() if stock > 0 else OutOfStockState()

    def set_state(self, state: State):
        self._state = state

    def insert_money(self):
        self._state.insert_money(self)

    def select_item(self):
        self._state.select_item(self)

    def dispense(self):
        self._state.dispense(self)

# Usage
machine = VendingMachine(2)

machine.select_item()      # Insert money first.
machine.insert_money()     # Money accepted!
machine.select_item()      # Item selected. Dispensing...
machine.dispense()         # Item dispensed! (1 left)

machine.insert_money()     # Money accepted!
machine.select_item()      # Item selected. Dispensing...
machine.dispense()         # Item dispensed! (0 left)

machine.insert_money()     # Sorry, machine is out of stock.
class IdleState {
  insertMoney(machine) {
    console.log("Money accepted!");
    machine.setState(new HasMoneyState());
  }
  selectItem(machine) {
    console.log("Insert money first.");
  }
  dispense(machine) {
    console.log("Insert money and select an item first.");
  }
}

class HasMoneyState {
  insertMoney(machine) {
    console.log("Money already inserted.");
  }
  selectItem(machine) {
    if (machine.stock > 0) {
      console.log("Item selected. Dispensing...");
      machine.setState(new DispensingState());
    } else {
      console.log("Out of stock! Refunding money.");
      machine.setState(new OutOfStockState());
    }
  }
  dispense(machine) {
    console.log("Select an item first.");
  }
}

class DispensingState {
  insertMoney(machine) {
    console.log("Please wait, dispensing in progress.");
  }
  selectItem(machine) {
    console.log("Already dispensing, please wait.");
  }
  dispense(machine) {
    machine.stock--;
    console.log(`Item dispensed! (${machine.stock} left)`);
    machine.setState(machine.stock > 0 ? new IdleState() : new OutOfStockState());
  }
}

class OutOfStockState {
  insertMoney(machine) { console.log("Sorry, machine is out of stock."); }
  selectItem(machine) { console.log("Machine is out of stock."); }
  dispense(machine) { console.log("Nothing to dispense."); }
}

class VendingMachine {
  constructor(stock) {
    this.stock = stock;
    this.state = stock > 0 ? new IdleState() : new OutOfStockState();
  }

  setState(state) { this.state = state; }
  insertMoney() { this.state.insertMoney(this); }
  selectItem() { this.state.selectItem(this); }
  dispense() { this.state.dispense(this); }
}

// Usage
const machine = new VendingMachine(2);
machine.selectItem();   // Insert money first.
machine.insertMoney();  // Money accepted!
machine.selectItem();   // Item selected. Dispensing...
machine.dispense();     // Item dispensed! (1 left)
interface State {
    void insertMoney(VendingMachine machine);
    void selectItem(VendingMachine machine);
    void dispense(VendingMachine machine);
}

class IdleState implements State {
    public void insertMoney(VendingMachine m) {
        System.out.println("Money accepted!");
        m.setState(new HasMoneyState());
    }
    public void selectItem(VendingMachine m) {
        System.out.println("Insert money first.");
    }
    public void dispense(VendingMachine m) {
        System.out.println("Insert money and select an item first.");
    }
}

class HasMoneyState implements State {
    public void insertMoney(VendingMachine m) {
        System.out.println("Money already inserted.");
    }
    public void selectItem(VendingMachine m) {
        if (m.getStock() > 0) {
            System.out.println("Item selected. Dispensing...");
            m.setState(new DispensingState());
        } else {
            System.out.println("Out of stock! Refunding.");
            m.setState(new OutOfStockState());
        }
    }
    public void dispense(VendingMachine m) {
        System.out.println("Select an item first.");
    }
}

class DispensingState implements State {
    public void insertMoney(VendingMachine m) {
        System.out.println("Please wait, dispensing.");
    }
    public void selectItem(VendingMachine m) {
        System.out.println("Already dispensing, please wait.");
    }
    public void dispense(VendingMachine m) {
        m.decrementStock();
        System.out.println("Item dispensed! (" + m.getStock() + " left)");
        m.setState(m.getStock() > 0 ? new IdleState() : new OutOfStockState());
    }
}

class OutOfStockState implements State {
    public void insertMoney(VendingMachine m) { System.out.println("Out of stock."); }
    public void selectItem(VendingMachine m) { System.out.println("Out of stock."); }
    public void dispense(VendingMachine m) { System.out.println("Nothing to dispense."); }
}

class VendingMachine {
    private State state;
    private int stock;

    public VendingMachine(int stock) {
        this.stock = stock;
        this.state = stock > 0 ? new IdleState() : new OutOfStockState();
    }

    public void setState(State s) { this.state = s; }
    public int getStock() { return stock; }
    public void decrementStock() { stock--; }

    public void insertMoney() { state.insertMoney(this); }
    public void selectItem() { state.selectItem(this); }
    public void dispense() { state.dispense(this); }
}

State vs Strategy — The Interview Favorite

This is one of the most commonly asked comparisons in LLD interviews.

StateStrategy
Who controls switching?States transition themselvesClient picks the strategy
States aware of each other?Yes, each state knows the nextNo, strategies are independent
PurposeModel object lifecycleSwap algorithms
ExampleOrder: Placed → Shipped → DeliveredPayment: Card, PayPal, Crypto

The only difference is intent. State models transitions in an object’s lifecycle. Strategy lets us swap interchangeable algorithms. Structurally, they look almost identical.

When to Use

  • Object lifecycle — order states, document workflow, game character modes
  • Finite state machines — traffic lights, network protocols, vending machines
  • Complex conditional behavior — when methods have large state-dependent if-else blocks
  • UI component states — loading, error, success, empty

When NOT to Use

  • When we only have 2-3 states with simple logic — a simple enum and switch is fine
  • When states don’t have meaningfully different behavior — just use a status field
  • When transitions are rare and simple — the pattern adds overhead for little benefit

In simple language, the State pattern says “don’t ask what state we’re in — just let each state handle things its own way.” Instead of checking if state == X everywhere, we let the state object do the talking. New state? New class. No existing code touched. Clean.


25

Template Method Pattern

advanced 4-7 YOE lld design-pattern behavioral

The Template Method pattern defines the skeleton of an algorithm in a base class. The overall structure stays the same, but subclasses can override specific steps without changing the algorithm’s flow.

Think of it like a recipe template. Every cake follows the same steps: prepare batter → bake → decorate. But a chocolate cake uses cocoa in the batter, a vanilla cake uses vanilla extract, and a red velvet cake adds food coloring. The template (steps and order) stays the same. The specifics change.

The Problem

Let’s say we’re building data parsers for different file formats — CSV, JSON, and XML. Each parser does the same high-level thing:

  1. Open and read the file
  2. Parse the raw data into records
  3. Validate the records
  4. Process/transform the data
  5. Generate output

Without Template Method, we’d duplicate steps 1, 3, 4, and 5 across all three parsers. Only step 2 (parsing) actually differs. That’s a lot of repeated code. And if we need to change the validation logic, we’d have to update it in three places.

How It Works

Template Method Structure
AbstractClass (DataParser)
+ process()  ← template method (final)
    1. readFile()  ← concrete step
    2. parseData()  ← abstract step
    3. validate()  ← concrete step
    4. beforeOutput()  ← hook (optional)
    5. output()  ← concrete step
│ subclasses override abstract & hook methods
CSVParser
overrides parseData()
JSONParser
overrides parseData()
XMLParser
overrides parseData()
+ beforeOutput()

There are two types of steps subclasses can customize:

  • Abstract methods (mandatory) — subclasses MUST override these. The base class has no default implementation.
  • Hook methods (optional) — subclasses CAN override these. The base class provides a default (usually empty) implementation.

The template method itself should not be overridden. In Java we’d make it final. In Python we rely on convention.

Implementation — Data Parser

from abc import ABC, abstractmethod

class DataParser(ABC):
    def process(self, filepath: str):
        """Template method -- defines the algorithm skeleton."""
        raw_data = self.read_file(filepath)
        records = self.parse_data(raw_data)
        valid_records = self.validate(records)
        self.before_output(valid_records)  # hook
        self.output(valid_records)

    def read_file(self, filepath: str) -> str:
        print(f"Reading file: {filepath}")
        # In real code, we'd actually read the file
        return f"raw content of {filepath}"

    @abstractmethod
    def parse_data(self, raw_data: str) -> list[dict]:
        """Subclasses MUST implement this."""
        pass

    def validate(self, records: list[dict]) -> list[dict]:
        print(f"Validating {len(records)} records...")
        return [r for r in records if r]  # filter out empty records

    def before_output(self, records: list[dict]):
        """Hook -- subclasses CAN override this. Does nothing by default."""
        pass

    def output(self, records: list[dict]):
        print(f"Output: {len(records)} records processed.")

class CSVParser(DataParser):
    def parse_data(self, raw_data: str) -> list[dict]:
        print("Parsing CSV: splitting by commas and newlines...")
        return [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]

class JSONParser(DataParser):
    def parse_data(self, raw_data: str) -> list[dict]:
        print("Parsing JSON: using json.loads()...")
        return [{"name": "Charlie", "role": "dev"}]

class XMLParser(DataParser):
    def parse_data(self, raw_data: str) -> list[dict]:
        print("Parsing XML: walking DOM tree...")
        return [{"node": "item", "value": "data"}]

    def before_output(self, records: list[dict]):
        print("XML-specific: converting attributes to flat keys...")

# Usage
csv_parser = CSVParser()
csv_parser.process("data.csv")
# Reading file: data.csv
# Parsing CSV: splitting by commas and newlines...
# Validating 2 records...
# Output: 2 records processed.

print("---")
xml_parser = XMLParser()
xml_parser.process("data.xml")
# Reading file: data.xml
# Parsing XML: walking DOM tree...
# Validating 1 records...
# XML-specific: converting attributes to flat keys...
# Output: 1 records processed.
class DataParser {
  process(filepath) {
    const rawData = this.readFile(filepath);
    const records = this.parseData(rawData);
    const valid = this.validate(records);
    this.beforeOutput(valid); // hook
    this.output(valid);
  }

  readFile(filepath) {
    console.log(`Reading file: ${filepath}`);
    return `raw content of ${filepath}`;
  }

  parseData(rawData) {
    throw new Error("Subclass must implement parseData()");
  }

  validate(records) {
    console.log(`Validating ${records.length} records...`);
    return records.filter((r) => r !== null);
  }

  beforeOutput(records) {
    // Hook -- does nothing by default. Override if needed.
  }

  output(records) {
    console.log(`Output: ${records.length} records processed.`);
  }
}

class CSVParser extends DataParser {
  parseData(rawData) {
    console.log("Parsing CSV: splitting by commas and newlines...");
    return [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }];
  }
}

class JSONParser extends DataParser {
  parseData(rawData) {
    console.log("Parsing JSON: using JSON.parse()...");
    return [{ name: "Charlie", role: "dev" }];
  }
}

class XMLParser extends DataParser {
  parseData(rawData) {
    console.log("Parsing XML: walking DOM tree...");
    return [{ node: "item", value: "data" }];
  }

  beforeOutput(records) {
    console.log("XML-specific: converting attributes to flat keys...");
  }
}

// Usage
const csvParser = new CSVParser();
csvParser.process("data.csv");

console.log("---");
const xmlParser = new XMLParser();
xmlParser.process("data.xml");
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

abstract class DataParser {
    // Template method -- final so subclasses can't change the flow
    public final void process(String filepath) {
        String rawData = readFile(filepath);
        List<String> records = parseData(rawData);
        List<String> valid = validate(records);
        beforeOutput(valid);  // hook
        output(valid);
    }

    private String readFile(String filepath) {
        System.out.println("Reading file: " + filepath);
        return "raw content of " + filepath;
    }

    // Abstract -- subclasses MUST implement
    protected abstract List<String> parseData(String rawData);

    private List<String> validate(List<String> records) {
        System.out.println("Validating " + records.size() + " records...");
        return records.stream()
            .filter(r -> r != null && !r.isEmpty())
            .collect(Collectors.toList());
    }

    // Hook -- subclasses CAN override
    protected void beforeOutput(List<String> records) { }

    private void output(List<String> records) {
        System.out.println("Output: " + records.size() + " records processed.");
    }
}

class CSVParser extends DataParser {
    protected List<String> parseData(String rawData) {
        System.out.println("Parsing CSV: splitting by commas...");
        return List.of("Alice,30", "Bob,25");
    }
}

class XMLParser extends DataParser {
    protected List<String> parseData(String rawData) {
        System.out.println("Parsing XML: walking DOM tree...");
        return List.of("<item>data</item>");
    }

    @Override
    protected void beforeOutput(List<String> records) {
        System.out.println("XML-specific: converting attributes...");
    }
}

Hooks vs Abstract Methods

This distinction comes up a lot in interviews:

Abstract MethodHook Method
Must override?YesNo
Has default?NoYes (usually empty)
PurposeStep that MUST be customizedOptional customization point
ExampleparseData()beforeOutput()

Hooks give subclasses a chance to react at certain points in the algorithm without forcing them to. It’s a nice touch that makes the pattern more flexible.

When to Use

  • Data processing pipelines — ETL jobs, file converters, report generators
  • Game loops — initialize → update → render, but each game customizes the steps
  • Test frameworks — setUp → runTest → tearDown (JUnit, pytest fixtures)
  • Web frameworks — request lifecycle hooks (before/after middleware)

When NOT to Use

  • When the algorithm has too many steps that ALL need overriding — at that point, we’re not reusing anything
  • When subclasses need to change the order of steps — Template Method locks the order
  • When composition would work better — sometimes Strategy is a cleaner fit because it doesn’t require inheritance

Template Method vs Strategy

Both let us vary parts of an algorithm. The difference is HOW:

  • Template Method uses inheritance. The base class defines the flow, subclasses fill in the blanks.
  • Strategy uses composition. The algorithm is a separate object we plug in.

Template Method is great when the overall flow is fixed and only a few steps vary. Strategy is better when we need to swap the entire algorithm at runtime.

In simple language, Template Method is like a fill-in-the-blank form. The structure is already printed. We just fill in our specific answers. The form decides the order of questions. We decide the answers. No one can rearrange the questions — and that’s the whole point.


Real LLD Questions

26

Design a Parking Lot

intermediate 2-4 YOE lld parking-lot design-question

This is THE most asked LLD interview question. If we’re preparing for only one design question, this should be it. We’re designing a multi-floor parking lot that handles different vehicle types, assigns spots, issues tickets, and calculates fees.

The beauty of this question is that it touches almost every OOP concept — inheritance, enums, composition, and multiple design patterns. Let’s build it piece by piece.

Requirements

Functional:

  • Multiple floors, each with multiple parking spots
  • Three vehicle types: Car, Bike, Truck
  • Three spot sizes: Small (bikes), Medium (cars), Large (trucks)
  • Entry generates a ticket with timestamp
  • Exit calculates fee based on duration
  • Track available spots per floor and per type

Assumptions:

  • One vehicle per spot (trucks don’t take 2 spots — keeps it simple)
  • Hourly pricing, different rates per vehicle type
  • Single parking lot instance (Singleton)

Key Classes & Relationships

Parking Lot Class Diagram
ParkingLot (Singleton)
- floors: List<Floor>
+ park(vehicle): Ticket
+ unpark(ticket): Payment
│ has many
Floor
- floor_number: int
- spots: List<ParkingSpot>
+ find_available_spot(vehicle_type): ParkingSpot
│ has many
ParkingSpot
- spot_type: SpotType
- vehicle: Vehicle | None
Ticket
- vehicle, spot, entry_time
- status: TicketStatus
Vehicle
- license, type: VehicleType
Car / Bike / Truck

Core Implementation

from abc import ABC, abstractmethod
from enum import Enum
from datetime import datetime
import uuid

# ---- Enums ----
class VehicleType(Enum):
    BIKE = "BIKE"
    CAR = "CAR"
    TRUCK = "TRUCK"

class SpotType(Enum):
    SMALL = "SMALL"
    MEDIUM = "MEDIUM"
    LARGE = "LARGE"

class TicketStatus(Enum):
    ACTIVE = "ACTIVE"
    PAID = "PAID"

# ---- Vehicles ----
class Vehicle:
    def __init__(self, license_plate: str, vehicle_type: VehicleType):
        self.license_plate = license_plate
        self.vehicle_type = vehicle_type

class Car(Vehicle):
    def __init__(self, license_plate: str):
        super().__init__(license_plate, VehicleType.CAR)

class Bike(Vehicle):
    def __init__(self, license_plate: str):
        super().__init__(license_plate, VehicleType.BIKE)

class Truck(Vehicle):
    def __init__(self, license_plate: str):
        super().__init__(license_plate, VehicleType.TRUCK)

# ---- Mapping: which vehicle goes in which spot ----
VEHICLE_TO_SPOT = {
    VehicleType.BIKE: SpotType.SMALL,
    VehicleType.CAR: SpotType.MEDIUM,
    VehicleType.TRUCK: SpotType.LARGE,
}

# ---- Parking Spot ----
class ParkingSpot:
    def __init__(self, spot_id: str, spot_type: SpotType):
        self.spot_id = spot_id
        self.spot_type = spot_type
        self.vehicle = None

    def is_available(self) -> bool:
        return self.vehicle is None

    def park(self, vehicle: Vehicle):
        self.vehicle = vehicle

    def unpark(self):
        self.vehicle = None

# ---- Floor ----
class Floor:
    def __init__(self, floor_number: int, spots: list):
        self.floor_number = floor_number
        self.spots = spots  # list of ParkingSpot

    def find_available_spot(self, vehicle_type: VehicleType):
        needed = VEHICLE_TO_SPOT[vehicle_type]
        for spot in self.spots:
            if spot.spot_type == needed and spot.is_available():
                return spot
        return None

# ---- Ticket ----
class Ticket:
    def __init__(self, vehicle: Vehicle, spot: ParkingSpot):
        self.ticket_id = str(uuid.uuid4())[:8]
        self.vehicle = vehicle
        self.spot = spot
        self.entry_time = datetime.now()
        self.status = TicketStatus.ACTIVE

# ---- Pricing Strategy ----
class PricingStrategy(ABC):
    @abstractmethod
    def calculate(self, hours: float, vehicle_type: VehicleType) -> float:
        pass

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

    def calculate(self, hours: float, vehicle_type: VehicleType) -> float:
        rate = self.RATES[vehicle_type]
        return max(1, int(hours + 0.99)) * rate  # round up to next hour

# ---- Payment ----
class Payment:
    def __init__(self, ticket: Ticket, amount: float):
        self.ticket = ticket
        self.amount = amount
        self.paid_at = datetime.now()

# ---- Parking Lot (Singleton) ----
class ParkingLot:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, floors: list = None, pricing: PricingStrategy = None):
        if not hasattr(self, '_initialized'):
            self.floors = floors or []
            self.pricing = pricing or HourlyPricing()
            self._tickets = {}  # ticket_id -> Ticket
            self._initialized = True

    def park(self, vehicle: Vehicle) -> Ticket:
        for floor in self.floors:
            spot = floor.find_available_spot(vehicle.vehicle_type)
            if spot:
                spot.park(vehicle)
                ticket = Ticket(vehicle, spot)
                self._tickets[ticket.ticket_id] = ticket
                print(f"Parked {vehicle.license_plate} at spot {spot.spot_id}")
                return ticket
        print("No available spot!")
        return None

    def unpark(self, ticket_id: str) -> Payment:
        ticket = self._tickets.get(ticket_id)
        if not ticket or ticket.status != TicketStatus.ACTIVE:
            print("Invalid ticket!")
            return None

        hours = (datetime.now() - ticket.entry_time).total_seconds() / 3600
        amount = self.pricing.calculate(hours, ticket.vehicle.vehicle_type)

        ticket.spot.unpark()
        ticket.status = TicketStatus.PAID
        payment = Payment(ticket, amount)
        print(f"Vehicle {ticket.vehicle.license_plate} -- Fee: ${amount}")
        return payment

# ---- Usage ----
floors = [
    Floor(1, [ParkingSpot(f"1-S{i}", SpotType.SMALL) for i in range(5)]
            + [ParkingSpot(f"1-M{i}", SpotType.MEDIUM) for i in range(10)]
            + [ParkingSpot(f"1-L{i}", SpotType.LARGE) for i in range(3)]),
    Floor(2, [ParkingSpot(f"2-M{i}", SpotType.MEDIUM) for i in range(10)]),
]

lot = ParkingLot(floors)
ticket = lot.park(Car("KA-01-1234"))   # Parked KA-01-1234 at spot 1-M0
lot.unpark(ticket.ticket_id)            # Vehicle KA-01-1234 -- Fee: $20
// ---- Enums ----
const VehicleType = Object.freeze({ BIKE: "BIKE", CAR: "CAR", TRUCK: "TRUCK" });
const SpotType = Object.freeze({ SMALL: "SMALL", MEDIUM: "MEDIUM", LARGE: "LARGE" });
const TicketStatus = Object.freeze({ ACTIVE: "ACTIVE", PAID: "PAID" });

const VEHICLE_TO_SPOT = {
  [VehicleType.BIKE]: SpotType.SMALL,
  [VehicleType.CAR]: SpotType.MEDIUM,
  [VehicleType.TRUCK]: SpotType.LARGE,
};

// ---- Vehicles ----
class Vehicle {
  constructor(licensePlate, vehicleType) {
    this.licensePlate = licensePlate;
    this.vehicleType = vehicleType;
  }
}
class Car extends Vehicle { constructor(lp) { super(lp, VehicleType.CAR); } }
class Bike extends Vehicle { constructor(lp) { super(lp, VehicleType.BIKE); } }
class Truck extends Vehicle { constructor(lp) { super(lp, VehicleType.TRUCK); } }

// ---- Parking Spot ----
class ParkingSpot {
  constructor(spotId, spotType) {
    this.spotId = spotId;
    this.spotType = spotType;
    this.vehicle = null;
  }
  isAvailable() { return this.vehicle === null; }
  park(vehicle) { this.vehicle = vehicle; }
  unpark() { this.vehicle = null; }
}

// ---- Floor ----
class Floor {
  constructor(floorNumber, spots) {
    this.floorNumber = floorNumber;
    this.spots = spots;
  }
  findAvailableSpot(vehicleType) {
    const needed = VEHICLE_TO_SPOT[vehicleType];
    return this.spots.find(s => s.spotType === needed && s.isAvailable()) || null;
  }
}

// ---- Ticket ----
class Ticket {
  constructor(vehicle, spot) {
    this.ticketId = Math.random().toString(36).slice(2, 10);
    this.vehicle = vehicle;
    this.spot = spot;
    this.entryTime = new Date();
    this.status = TicketStatus.ACTIVE;
  }
}

// ---- Pricing Strategy ----
class HourlyPricing {
  static RATES = { [VehicleType.BIKE]: 10, [VehicleType.CAR]: 20, [VehicleType.TRUCK]: 40 };
  calculate(hours, vehicleType) {
    return Math.max(1, Math.ceil(hours)) * HourlyPricing.RATES[vehicleType];
  }
}

// ---- Payment ----
class Payment {
  constructor(ticket, amount) {
    this.ticket = ticket;
    this.amount = amount;
    this.paidAt = new Date();
  }
}

// ---- Parking Lot (Singleton) ----
class ParkingLot {
  static #instance = null;
  static getInstance(floors, pricing) {
    if (!ParkingLot.#instance) {
      ParkingLot.#instance = new ParkingLot(floors, pricing);
    }
    return ParkingLot.#instance;
  }

  constructor(floors = [], pricing = new HourlyPricing()) {
    this.floors = floors;
    this.pricing = pricing;
    this.tickets = new Map();
  }

  park(vehicle) {
    for (const floor of this.floors) {
      const spot = floor.findAvailableSpot(vehicle.vehicleType);
      if (spot) {
        spot.park(vehicle);
        const ticket = new Ticket(vehicle, spot);
        this.tickets.set(ticket.ticketId, ticket);
        console.log(`Parked ${vehicle.licensePlate} at ${spot.spotId}`);
        return ticket;
      }
    }
    console.log("No available spot!");
    return null;
  }

  unpark(ticketId) {
    const ticket = this.tickets.get(ticketId);
    if (!ticket || ticket.status !== TicketStatus.ACTIVE) return null;

    const hours = (Date.now() - ticket.entryTime.getTime()) / 3600000;
    const amount = this.pricing.calculate(hours, ticket.vehicle.vehicleType);

    ticket.spot.unpark();
    ticket.status = TicketStatus.PAID;
    console.log(`Vehicle ${ticket.vehicle.licensePlate} -- Fee: $${amount}`);
    return new Payment(ticket, amount);
  }
}

// Usage
const floors = [
  new Floor(1, [
    ...Array.from({ length: 5 }, (_, i) => new ParkingSpot(`1-S${i}`, SpotType.SMALL)),
    ...Array.from({ length: 10 }, (_, i) => new ParkingSpot(`1-M${i}`, SpotType.MEDIUM)),
    ...Array.from({ length: 3 }, (_, i) => new ParkingSpot(`1-L${i}`, SpotType.LARGE)),
  ]),
];
const lot = ParkingLot.getInstance(floors);
const ticket = lot.park(new Car("KA-01-1234"));
lot.unpark(ticket.ticketId);
import java.util.*;
import java.time.*;

enum VehicleType { BIKE, CAR, TRUCK }
enum SpotType { SMALL, MEDIUM, LARGE }
enum TicketStatus { ACTIVE, PAID }

// ---- Vehicles ----
abstract class Vehicle {
    String licensePlate;
    VehicleType type;
    Vehicle(String lp, VehicleType t) { this.licensePlate = lp; this.type = t; }
}
class Car extends Vehicle { Car(String lp) { super(lp, VehicleType.CAR); } }
class Bike extends Vehicle { Bike(String lp) { super(lp, VehicleType.BIKE); } }
class Truck extends Vehicle { Truck(String lp) { super(lp, VehicleType.TRUCK); } }

// ---- Parking Spot ----
class ParkingSpot {
    String spotId;
    SpotType spotType;
    Vehicle vehicle;

    ParkingSpot(String id, SpotType type) { this.spotId = id; this.spotType = type; }
    boolean isAvailable() { return vehicle == null; }
    void park(Vehicle v) { this.vehicle = v; }
    void unpark() { this.vehicle = null; }
}

// ---- Floor ----
class Floor {
    int floorNumber;
    List<ParkingSpot> spots;

    Floor(int num, List<ParkingSpot> spots) { this.floorNumber = num; this.spots = spots; }

    ParkingSpot findAvailableSpot(VehicleType vType) {
        SpotType needed = switch (vType) {
            case BIKE -> SpotType.SMALL;
            case CAR -> SpotType.MEDIUM;
            case TRUCK -> SpotType.LARGE;
        };
        return spots.stream()
            .filter(s -> s.spotType == needed && s.isAvailable())
            .findFirst().orElse(null);
    }
}

// ---- Ticket ----
class Ticket {
    String ticketId = UUID.randomUUID().toString().substring(0, 8);
    Vehicle vehicle;
    ParkingSpot spot;
    LocalDateTime entryTime = LocalDateTime.now();
    TicketStatus status = TicketStatus.ACTIVE;

    Ticket(Vehicle v, ParkingSpot s) { this.vehicle = v; this.spot = s; }
}

// ---- Pricing Strategy ----
interface PricingStrategy {
    double calculate(double hours, VehicleType type);
}

class HourlyPricing implements PricingStrategy {
    private static final Map<VehicleType, Integer> RATES = Map.of(
        VehicleType.BIKE, 10, VehicleType.CAR, 20, VehicleType.TRUCK, 40
    );

    public double calculate(double hours, VehicleType type) {
        return Math.max(1, (int) Math.ceil(hours)) * RATES.get(type);
    }
}

// ---- Parking Lot (Singleton) ----
class ParkingLot {
    private static ParkingLot instance;
    private List<Floor> floors;
    private PricingStrategy pricing;
    private Map<String, Ticket> tickets = new HashMap<>();

    private ParkingLot(List<Floor> floors, PricingStrategy pricing) {
        this.floors = floors;
        this.pricing = pricing;
    }

    public static ParkingLot getInstance(List<Floor> floors, PricingStrategy pricing) {
        if (instance == null) instance = new ParkingLot(floors, pricing);
        return instance;
    }

    public Ticket park(Vehicle vehicle) {
        for (Floor floor : floors) {
            ParkingSpot spot = floor.findAvailableSpot(vehicle.type);
            if (spot != null) {
                spot.park(vehicle);
                Ticket ticket = new Ticket(vehicle, spot);
                tickets.put(ticket.ticketId, ticket);
                System.out.println("Parked " + vehicle.licensePlate + " at " + spot.spotId);
                return ticket;
            }
        }
        System.out.println("No available spot!");
        return null;
    }

    public double unpark(String ticketId) {
        Ticket ticket = tickets.get(ticketId);
        if (ticket == null || ticket.status != TicketStatus.ACTIVE) return -1;

        double hours = Duration.between(ticket.entryTime, LocalDateTime.now()).toMinutes() / 60.0;
        double amount = pricing.calculate(hours, ticket.vehicle.type);

        ticket.spot.unpark();
        ticket.status = TicketStatus.PAID;
        System.out.printf("Vehicle %s -- Fee: $%.0f%n", ticket.vehicle.licensePlate, amount);
        return amount;
    }
}

Design Patterns Used

Singleton — ParkingLot is a single instance. There’s only one parking lot, so we don’t want multiple objects floating around with conflicting state.

Strategy — PricingStrategy lets us swap pricing logic. Maybe weekends have surge pricing, or we offer flat-rate night parking. New pricing? Just add a new strategy class. No changes to ParkingLot.

Composition — ParkingLot has Floors, Floors have ParkingSpots. Clean hierarchy. No inheritance abuse.

Extensions

These are common follow-up questions interviewers love to throw:

  • Valet parking — Add a VehicleType-aware assignment where attendants get a queue of vehicles to park. We can add a ValetService class that wraps ParkingLot.park().
  • EV charging spots — Extend SpotType with EV_CHARGING. Add a ChargingSpot subclass of ParkingSpot with charge rate and status.
  • Reserved spots — Add a reserved_for field on ParkingSpot. Skip reserved spots in find_available_spot() unless the vehicle matches.
  • Multiple entry/exit gates — Add EntryGate and ExitGate classes. Each gate calls ParkingLot.park() or unpark(). Use thread-safe methods if concurrent access is needed.
  • Dynamic pricing — Time-of-day pricing, weekend surge, loyalty discounts. All handled by adding new PricingStrategy implementations.

In simple language, this design breaks the parking lot into small, focused classes. Each class does one thing. Spots know if they’re free. Floors know how to find spots. The lot ties it all together. Pricing is pluggable. That’s exactly what interviewers want to see.


27

Design an Elevator System

advanced 4-7 YOE lld elevator design-question

The elevator system is a favorite for senior-level LLD interviews. It’s a real-world system that naturally involves the State pattern, Strategy pattern, and concurrency thinking. We’re designing a building with multiple elevators that handle floor requests efficiently.

What makes this question tricky is the scheduling — when someone presses “up” on floor 5, which elevator should respond? That decision-making logic is where the real design lives.

Requirements

Functional:

  • Building has N floors and M elevators
  • External requests: person on a floor presses UP or DOWN button
  • Internal requests: person inside elevator presses a floor button
  • Elevator moves up/down, opens/closes doors, picks up passengers
  • Select the optimal elevator for each external request

Assumptions:

  • Elevators use the LOOK algorithm (serve requests in current direction, then reverse)
  • No weight limit for now (common extension)
  • Doors open automatically when elevator reaches a requested floor

Key Classes & Relationships

Elevator System Architecture
ElevatorSystem
- elevators: List<Elevator>
- scheduler: SchedulingStrategy
+ handle_request(floor, direction)
│ picks best elevator via
SchedulingStrategy
NearestElevator / LOOKScheduler
Elevator
- currentFloor, direction
- state: ElevatorState
- requests: SortedSet
│ elevator has
Door (OPEN / CLOSED)
Display (floor, direction)
Direction (UP / DOWN / IDLE)

Core Implementation

from abc import ABC, abstractmethod
from enum import Enum

# ---- Enums ----
class Direction(Enum):
    UP = "UP"
    DOWN = "DOWN"
    IDLE = "IDLE"

class DoorState(Enum):
    OPEN = "OPEN"
    CLOSED = "CLOSED"

# ---- Door & Display ----
class Door:
    def __init__(self):
        self.state = DoorState.CLOSED

    def open(self):
        self.state = DoorState.OPEN
        print("  Door opened")

    def close(self):
        self.state = DoorState.CLOSED
        print("  Door closed")

class Display:
    def __init__(self):
        self.floor = 0
        self.direction = Direction.IDLE

    def update(self, floor: int, direction: Direction):
        self.floor = floor
        self.direction = direction

# ---- Elevator ----
class Elevator:
    def __init__(self, elevator_id: int):
        self.id = elevator_id
        self.current_floor = 0
        self.direction = Direction.IDLE
        self.door = Door()
        self.display = Display()
        self.up_requests = set()    # floors to visit going up
        self.down_requests = set()  # floors to visit going down

    def add_request(self, floor: int, direction: Direction = None):
        """Add a floor request. Direction helps decide which set to add to."""
        if floor > self.current_floor:
            self.up_requests.add(floor)
        elif floor < self.current_floor:
            self.down_requests.add(floor)
        else:
            # Already on this floor -- just open doors
            self._stop_at_floor()

    def _stop_at_floor(self):
        self.door.open()
        self.display.update(self.current_floor, self.direction)
        print(f"  Elevator {self.id} stopped at floor {self.current_floor}")
        self.door.close()

    def move(self):
        """Process one step of movement using LOOK algorithm."""
        if not self.up_requests and not self.down_requests:
            self.direction = Direction.IDLE
            return

        if self.direction == Direction.IDLE:
            # Pick whichever direction has requests
            self.direction = Direction.UP if self.up_requests else Direction.DOWN

        if self.direction == Direction.UP:
            if self.up_requests:
                next_floor = min(self.up_requests)
                self._move_to(next_floor)
                self.up_requests.discard(next_floor)
            else:
                # No more up requests, reverse
                self.direction = Direction.DOWN
                self.move()

        elif self.direction == Direction.DOWN:
            if self.down_requests:
                next_floor = max(self.down_requests)
                self._move_to(next_floor)
                self.down_requests.discard(next_floor)
            else:
                self.direction = Direction.UP
                self.move()

    def _move_to(self, floor: int):
        print(f"  Elevator {self.id}: floor {self.current_floor} -> {floor}")
        self.current_floor = floor
        self._stop_at_floor()

    def pending_count(self) -> int:
        return len(self.up_requests) + len(self.down_requests)

# ---- Scheduling Strategy ----
class SchedulingStrategy(ABC):
    @abstractmethod
    def select_elevator(self, elevators: list, floor: int, direction: Direction) -> "Elevator":
        pass

class NearestElevatorStrategy(SchedulingStrategy):
    """Pick the closest idle or same-direction elevator."""
    def select_elevator(self, elevators, floor, direction):
        best = None
        best_score = float('inf')

        for elevator in elevators:
            # Prefer idle elevators or ones going the same direction
            if elevator.direction == Direction.IDLE:
                score = abs(elevator.current_floor - floor)
            elif elevator.direction == direction:
                diff = floor - elevator.current_floor
                # Only good if the elevator hasn't passed us yet
                if direction == Direction.UP and diff >= 0:
                    score = diff
                elif direction == Direction.DOWN and diff <= 0:
                    score = abs(diff)
                else:
                    score = float('inf')  # It'll have to come back
            else:
                score = float('inf')  # Going the wrong way

            if score < best_score:
                best_score = score
                best = elevator

        # If no good option, pick least busy
        if best_score == float('inf'):
            best = min(elevators, key=lambda e: e.pending_count())

        return best

# ---- Elevator System ----
class ElevatorSystem:
    def __init__(self, num_elevators: int, num_floors: int,
                 strategy: SchedulingStrategy = None):
        self.elevators = [Elevator(i) for i in range(num_elevators)]
        self.num_floors = num_floors
        self.strategy = strategy or NearestElevatorStrategy()

    def handle_external_request(self, floor: int, direction: Direction):
        """Someone pressed UP/DOWN on a floor."""
        elevator = self.strategy.select_elevator(self.elevators, floor, direction)
        print(f"Request: floor {floor} {direction.value} -> assigned to Elevator {elevator.id}")
        elevator.add_request(floor, direction)

    def handle_internal_request(self, elevator_id: int, floor: int):
        """Someone inside an elevator pressed a floor button."""
        self.elevators[elevator_id].add_request(floor)

    def step(self):
        """Move all elevators one step. Call this in a loop or on a timer."""
        for elevator in self.elevators:
            elevator.move()

# ---- Usage ----
system = ElevatorSystem(num_elevators=3, num_floors=10)

system.handle_external_request(5, Direction.UP)   # Someone on floor 5 going up
system.handle_internal_request(0, 8)               # Inside elevator 0, press 8
system.handle_external_request(2, Direction.DOWN)  # Someone on floor 2 going down

system.step()  # All elevators process one movement
system.step()  # Continue processing
// ---- Enums ----
const Direction = Object.freeze({ UP: "UP", DOWN: "DOWN", IDLE: "IDLE" });
const DoorState = Object.freeze({ OPEN: "OPEN", CLOSED: "CLOSED" });

// ---- Door & Display ----
class Door {
  constructor() { this.state = DoorState.CLOSED; }
  open() { this.state = DoorState.OPEN; console.log("  Door opened"); }
  close() { this.state = DoorState.CLOSED; console.log("  Door closed"); }
}

class Display {
  constructor() { this.floor = 0; this.direction = Direction.IDLE; }
  update(floor, direction) { this.floor = floor; this.direction = direction; }
}

// ---- Elevator ----
class Elevator {
  constructor(id) {
    this.id = id;
    this.currentFloor = 0;
    this.direction = Direction.IDLE;
    this.door = new Door();
    this.display = new Display();
    this.upRequests = new Set();
    this.downRequests = new Set();
  }

  addRequest(floor) {
    if (floor > this.currentFloor) this.upRequests.add(floor);
    else if (floor < this.currentFloor) this.downRequests.add(floor);
    else this.#stopAtFloor();
  }

  #stopAtFloor() {
    this.door.open();
    this.display.update(this.currentFloor, this.direction);
    console.log(`  Elevator ${this.id} stopped at floor ${this.currentFloor}`);
    this.door.close();
  }

  move() {
    if (!this.upRequests.size && !this.downRequests.size) {
      this.direction = Direction.IDLE;
      return;
    }

    if (this.direction === Direction.IDLE) {
      this.direction = this.upRequests.size ? Direction.UP : Direction.DOWN;
    }

    if (this.direction === Direction.UP) {
      if (this.upRequests.size) {
        const next = Math.min(...this.upRequests);
        this.#moveTo(next);
        this.upRequests.delete(next);
      } else {
        this.direction = Direction.DOWN;
        this.move();
      }
    } else if (this.direction === Direction.DOWN) {
      if (this.downRequests.size) {
        const next = Math.max(...this.downRequests);
        this.#moveTo(next);
        this.downRequests.delete(next);
      } else {
        this.direction = Direction.UP;
        this.move();
      }
    }
  }

  #moveTo(floor) {
    console.log(`  Elevator ${this.id}: floor ${this.currentFloor} -> ${floor}`);
    this.currentFloor = floor;
    this.#stopAtFloor();
  }

  get pendingCount() { return this.upRequests.size + this.downRequests.size; }
}

// ---- Scheduling Strategy ----
class NearestElevatorStrategy {
  selectElevator(elevators, floor, direction) {
    let best = null, bestScore = Infinity;

    for (const elev of elevators) {
      let score;
      if (elev.direction === Direction.IDLE) {
        score = Math.abs(elev.currentFloor - floor);
      } else if (elev.direction === direction) {
        const diff = floor - elev.currentFloor;
        if (direction === Direction.UP && diff >= 0) score = diff;
        else if (direction === Direction.DOWN && diff <= 0) score = Math.abs(diff);
        else score = Infinity;
      } else {
        score = Infinity;
      }
      if (score < bestScore) { bestScore = score; best = elev; }
    }

    if (bestScore === Infinity) {
      best = elevators.reduce((a, b) => a.pendingCount <= b.pendingCount ? a : b);
    }
    return best;
  }
}

// ---- Elevator System ----
class ElevatorSystem {
  constructor(numElevators, numFloors, strategy = new NearestElevatorStrategy()) {
    this.elevators = Array.from({ length: numElevators }, (_, i) => new Elevator(i));
    this.numFloors = numFloors;
    this.strategy = strategy;
  }

  handleExternalRequest(floor, direction) {
    const elev = this.strategy.selectElevator(this.elevators, floor, direction);
    console.log(`Request: floor ${floor} ${direction} -> Elevator ${elev.id}`);
    elev.addRequest(floor);
  }

  handleInternalRequest(elevatorId, floor) {
    this.elevators[elevatorId].addRequest(floor);
  }

  step() { this.elevators.forEach(e => e.move()); }
}

// Usage
const system = new ElevatorSystem(3, 10);
system.handleExternalRequest(5, Direction.UP);
system.handleInternalRequest(0, 8);
system.step();
import java.util.*;

enum Direction { UP, DOWN, IDLE }
enum DoorState { OPEN, CLOSED }

class Door {
    DoorState state = DoorState.CLOSED;
    void open() { state = DoorState.OPEN; System.out.println("  Door opened"); }
    void close() { state = DoorState.CLOSED; System.out.println("  Door closed"); }
}

class Display {
    int floor = 0;
    Direction direction = Direction.IDLE;
    void update(int f, Direction d) { floor = f; direction = d; }
}

class Elevator {
    int id, currentFloor = 0;
    Direction direction = Direction.IDLE;
    Door door = new Door();
    Display display = new Display();
    TreeSet<Integer> upRequests = new TreeSet<>();
    TreeSet<Integer> downRequests = new TreeSet<>();

    Elevator(int id) { this.id = id; }

    void addRequest(int floor) {
        if (floor > currentFloor) upRequests.add(floor);
        else if (floor < currentFloor) downRequests.add(floor);
        else stopAtFloor();
    }

    private void stopAtFloor() {
        door.open();
        display.update(currentFloor, direction);
        System.out.printf("  Elevator %d stopped at floor %d%n", id, currentFloor);
        door.close();
    }

    void move() {
        if (upRequests.isEmpty() && downRequests.isEmpty()) {
            direction = Direction.IDLE;
            return;
        }
        if (direction == Direction.IDLE)
            direction = !upRequests.isEmpty() ? Direction.UP : Direction.DOWN;

        if (direction == Direction.UP) {
            if (!upRequests.isEmpty()) { moveTo(upRequests.pollFirst()); }
            else { direction = Direction.DOWN; move(); }
        } else {
            if (!downRequests.isEmpty()) { moveTo(downRequests.pollLast()); }
            else { direction = Direction.UP; move(); }
        }
    }

    private void moveTo(int floor) {
        System.out.printf("  Elevator %d: floor %d -> %d%n", id, currentFloor, floor);
        currentFloor = floor;
        stopAtFloor();
    }

    int pendingCount() { return upRequests.size() + downRequests.size(); }
}

// ---- Scheduling Strategy ----
interface SchedulingStrategy {
    Elevator selectElevator(List<Elevator> elevators, int floor, Direction dir);
}

class NearestElevatorStrategy implements SchedulingStrategy {
    public Elevator selectElevator(List<Elevator> elevators, int floor, Direction dir) {
        Elevator best = null;
        int bestScore = Integer.MAX_VALUE;

        for (Elevator e : elevators) {
            int score;
            if (e.direction == Direction.IDLE) {
                score = Math.abs(e.currentFloor - floor);
            } else if (e.direction == dir) {
                int diff = floor - e.currentFloor;
                if (dir == Direction.UP && diff >= 0) score = diff;
                else if (dir == Direction.DOWN && diff <= 0) score = Math.abs(diff);
                else score = Integer.MAX_VALUE;
            } else {
                score = Integer.MAX_VALUE;
            }
            if (score < bestScore) { bestScore = score; best = e; }
        }

        if (bestScore == Integer.MAX_VALUE) {
            best = elevators.stream()
                .min(Comparator.comparingInt(Elevator::pendingCount))
                .orElse(elevators.get(0));
        }
        return best;
    }
}

class ElevatorSystem {
    List<Elevator> elevators;
    SchedulingStrategy strategy;

    ElevatorSystem(int numElevators, SchedulingStrategy strategy) {
        this.elevators = new ArrayList<>();
        for (int i = 0; i < numElevators; i++) elevators.add(new Elevator(i));
        this.strategy = strategy;
    }

    void handleExternalRequest(int floor, Direction dir) {
        Elevator e = strategy.selectElevator(elevators, floor, dir);
        System.out.printf("Request: floor %d %s -> Elevator %d%n", floor, dir, e.id);
        e.addRequest(floor);
    }

    void handleInternalRequest(int elevatorId, int floor) {
        elevators.get(elevatorId).addRequest(floor);
    }

    void step() { elevators.forEach(Elevator::move); }
}

The LOOK Algorithm (Elevator Algorithm)

This is the core scheduling logic that interviewers want to see. Here’s how it works:

  1. The elevator moves in one direction (say UP)
  2. It serves all requests in that direction in order
  3. When no more requests in that direction, it reverses
  4. Repeat

It’s called LOOK because the elevator “looks ahead” — if there are no more requests above, it doesn’t go all the way to the top floor. It just reverses. This is how real elevators work.

We split requests into up_requests and down_requests sets. Going up? Serve from up_requests (lowest first). Going down? Serve from down_requests (highest first). Simple.

Design Patterns Used

Strategy — SchedulingStrategy lets us swap the elevator selection algorithm. NearestElevator is one approach. We could add RoundRobin, LeastBusy, or ZoneBasedScheduler without touching the ElevatorSystem class.

State — The elevator’s behavior changes based on direction (UP/DOWN/IDLE). In a more detailed implementation, we’d use the full State pattern with MovingUpState, MovingDownState, IdleState classes.

Observer (optional extension) — Floors could observe elevator positions to update their displays. We didn’t add it here to keep things focused.

Extensions

  • Priority floors — Lobby and parking levels get priority. Add a weight to certain floors in the scheduling algorithm.
  • Emergency mode — All elevators go to ground floor, doors open. Add an EmergencyState that overrides normal behavior.
  • Weight limit — Elevator tracks current weight. If above threshold, skip pickup requests and only serve internal drop-off requests.
  • Zone-based scheduling — Elevator 1 serves floors 1-10, Elevator 2 serves 11-20. Add a ZoneScheduler strategy.
  • VIP elevator — Dedicated elevator for certain floors. Filter it out of the general pool in the scheduler.

In simple language, the elevator system is about two things: which elevator do we pick for a request, and how does each elevator decide where to go next. Strategy pattern handles the first question. The LOOK algorithm handles the second. Nail these two and the rest is just wiring classes together.


28

Design BookMyShow

advanced 4-7 YOE lld bookmyshow design-question concurrency

BookMyShow (or any movie ticket booking system) is a top-tier LLD question. What makes it interesting isn’t the movie/theater modeling — that part is straightforward. The real challenge is seat locking. Two people looking at the same show, same seats, at the same time. Who gets them? That concurrency problem is what separates a good answer from a great one.

Let’s design it from the ground up.

Requirements

Functional:

  • Browse movies playing in a city
  • View theaters and shows for a movie
  • Select seats from an available seat map
  • Book selected seats (with temporary lock while paying)
  • Process payment and confirm booking
  • Cancel booking

The Big Constraint:

  • Two users CANNOT book the same seat. When User A selects seats, those seats are temporarily locked for 5 minutes. If User A doesn’t pay in time, the lock expires and the seats go back to available.

Key Classes & Relationships

BookMyShow Class Diagram
Movie
title, duration, genre
Theater
name, city, screens[]
Screen
screenNumber, seats[]
│ a Show ties Movie + Screen + Time
Show
movie, screen, startTime
seat_status: Map<Seat, SeatStatus>
│ booking locks seats
Booking
show, seats[], status
lock_time, user
Seat
row, number, type
REGULAR / PREMIUM / VIP
Payment
amount, method
status, timestamp

Core Implementation

from enum import Enum
from datetime import datetime, timedelta
from threading import Lock
import uuid

# ---- Enums ----
class SeatType(Enum):
    REGULAR = "REGULAR"
    PREMIUM = "PREMIUM"
    VIP = "VIP"

class SeatStatus(Enum):
    AVAILABLE = "AVAILABLE"
    LOCKED = "LOCKED"      # temporarily held during payment
    BOOKED = "BOOKED"

class BookingStatus(Enum):
    PENDING = "PENDING"    # seats locked, waiting for payment
    CONFIRMED = "CONFIRMED"
    CANCELLED = "CANCELLED"
    EXPIRED = "EXPIRED"    # lock timed out

# ---- Core Models ----
class Movie:
    def __init__(self, title: str, duration_mins: int, genre: str):
        self.movie_id = str(uuid.uuid4())[:8]
        self.title = title
        self.duration_mins = duration_mins
        self.genre = genre

class Seat:
    def __init__(self, row: str, number: int, seat_type: SeatType):
        self.seat_id = f"{row}{number}"
        self.row = row
        self.number = number
        self.seat_type = seat_type

SEAT_PRICES = {
    SeatType.REGULAR: 200,
    SeatType.PREMIUM: 350,
    SeatType.VIP: 500,
}

class Screen:
    def __init__(self, screen_number: int, seats: list):
        self.screen_number = screen_number
        self.seats = seats  # list of Seat

class Theater:
    def __init__(self, name: str, city: str, screens: list):
        self.name = name
        self.city = city
        self.screens = screens  # list of Screen

# ---- Show: the heart of the system ----
class Show:
    LOCK_DURATION = timedelta(minutes=5)

    def __init__(self, movie: Movie, screen: Screen, start_time: datetime):
        self.show_id = str(uuid.uuid4())[:8]
        self.movie = movie
        self.screen = screen
        self.start_time = start_time
        # Per-show seat status tracking
        self.seat_status = {seat.seat_id: SeatStatus.AVAILABLE for seat in screen.seats}
        self.seat_locks = {}  # seat_id -> lock_expiry time
        self._lock = Lock()   # thread safety!

    def get_available_seats(self) -> list:
        self._expire_locks()
        return [sid for sid, status in self.seat_status.items()
                if status == SeatStatus.AVAILABLE]

    def lock_seats(self, seat_ids: list) -> bool:
        """Try to lock seats for a user. Returns True if ALL seats locked."""
        with self._lock:  # Thread-safe!
            self._expire_locks()

            # Check all seats are available FIRST
            for sid in seat_ids:
                if self.seat_status.get(sid) != SeatStatus.AVAILABLE:
                    return False

            # Lock them all
            expiry = datetime.now() + self.LOCK_DURATION
            for sid in seat_ids:
                self.seat_status[sid] = SeatStatus.LOCKED
                self.seat_locks[sid] = expiry

            return True

    def confirm_seats(self, seat_ids: list):
        """Convert locked seats to booked."""
        with self._lock:
            for sid in seat_ids:
                self.seat_status[sid] = SeatStatus.BOOKED
                self.seat_locks.pop(sid, None)

    def release_seats(self, seat_ids: list):
        """Release locked/booked seats back to available."""
        with self._lock:
            for sid in seat_ids:
                self.seat_status[sid] = SeatStatus.AVAILABLE
                self.seat_locks.pop(sid, None)

    def _expire_locks(self):
        """Release any seats whose lock has expired."""
        now = datetime.now()
        expired = [sid for sid, expiry in self.seat_locks.items() if now > expiry]
        for sid in expired:
            self.seat_status[sid] = SeatStatus.AVAILABLE
            del self.seat_locks[sid]

# ---- Booking ----
class Booking:
    def __init__(self, user_id: str, show: Show, seat_ids: list):
        self.booking_id = str(uuid.uuid4())[:8]
        self.user_id = user_id
        self.show = show
        self.seat_ids = seat_ids
        self.status = BookingStatus.PENDING
        self.created_at = datetime.now()
        self.amount = self._calculate_amount()

    def _calculate_amount(self) -> float:
        total = 0
        seat_map = {s.seat_id: s for s in self.show.screen.seats}
        for sid in self.seat_ids:
            seat = seat_map[sid]
            total += SEAT_PRICES[seat.seat_type]
        return total

# ---- Payment ----
class Payment:
    def __init__(self, booking: Booking, method: str):
        self.payment_id = str(uuid.uuid4())[:8]
        self.booking = booking
        self.amount = booking.amount
        self.method = method
        self.paid_at = datetime.now()

# ---- Booking Service (ties it all together) ----
class BookingService:
    def __init__(self):
        self.bookings = {}  # booking_id -> Booking

    def create_booking(self, user_id: str, show: Show, seat_ids: list) -> Booking:
        """Step 1: Lock seats and create pending booking."""
        if not show.lock_seats(seat_ids):
            print(f"Seats {seat_ids} not available -- someone beat us to it!")
            return None

        booking = Booking(user_id, show, seat_ids)
        self.bookings[booking.booking_id] = booking
        print(f"Booking {booking.booking_id} created. Amount: ${booking.amount}")
        print(f"Seats locked for 5 minutes. Complete payment to confirm.")
        return booking

    def confirm_booking(self, booking_id: str, payment_method: str) -> Payment:
        """Step 2: Pay and confirm the booking."""
        booking = self.bookings.get(booking_id)
        if not booking or booking.status != BookingStatus.PENDING:
            print("Invalid booking!")
            return None

        # Check if lock expired
        elapsed = datetime.now() - booking.created_at
        if elapsed > Show.LOCK_DURATION:
            booking.status = BookingStatus.EXPIRED
            booking.show.release_seats(booking.seat_ids)
            print("Booking expired! Seats released.")
            return None

        booking.show.confirm_seats(booking.seat_ids)
        booking.status = BookingStatus.CONFIRMED
        payment = Payment(booking, payment_method)
        print(f"Booking {booking_id} confirmed! Payment: ${payment.amount}")
        return payment

    def cancel_booking(self, booking_id: str):
        """Cancel a confirmed booking."""
        booking = self.bookings.get(booking_id)
        if not booking:
            return
        booking.show.release_seats(booking.seat_ids)
        booking.status = BookingStatus.CANCELLED
        print(f"Booking {booking_id} cancelled. Seats released.")

# ---- Usage ----
# Setup
seats = ([Seat("A", i, SeatType.VIP) for i in range(1, 6)]
       + [Seat("B", i, SeatType.PREMIUM) for i in range(1, 11)]
       + [Seat("C", i, SeatType.REGULAR) for i in range(1, 16)])

screen = Screen(1, seats)
theater = Theater("PVR Phoenix", "Mumbai", [screen])
movie = Movie("Inception", 148, "Sci-Fi")
show = Show(movie, screen, datetime(2025, 1, 15, 18, 30))

# Booking flow
service = BookingService()

# User 1 selects seats
booking = service.create_booking("user_1", show, ["A1", "A2"])
# Booking abc123 created. Amount: $1000

# User 2 tries SAME seats -- fails!
booking2 = service.create_booking("user_2", show, ["A1", "A3"])
# Seats ['A1', 'A3'] not available -- someone beat us to it!

# User 1 pays
service.confirm_booking(booking.booking_id, "credit_card")
# Booking abc123 confirmed!
// ---- Enums ----
const SeatType = Object.freeze({ REGULAR: "REGULAR", PREMIUM: "PREMIUM", VIP: "VIP" });
const SeatStatus = Object.freeze({ AVAILABLE: "AVAILABLE", LOCKED: "LOCKED", BOOKED: "BOOKED" });
const BookingStatus = Object.freeze({
  PENDING: "PENDING", CONFIRMED: "CONFIRMED", CANCELLED: "CANCELLED", EXPIRED: "EXPIRED"
});

const SEAT_PRICES = { [SeatType.REGULAR]: 200, [SeatType.PREMIUM]: 350, [SeatType.VIP]: 500 };
const LOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes

// ---- Models ----
class Movie {
  constructor(title, durationMins, genre) {
    this.movieId = crypto.randomUUID().slice(0, 8);
    this.title = title;
    this.durationMins = durationMins;
    this.genre = genre;
  }
}

class Seat {
  constructor(row, number, seatType) {
    this.seatId = `${row}${number}`;
    this.row = row;
    this.number = number;
    this.seatType = seatType;
  }
}

class Screen {
  constructor(screenNumber, seats) {
    this.screenNumber = screenNumber;
    this.seats = seats;
  }
}

// ---- Show (with seat locking) ----
class Show {
  constructor(movie, screen, startTime) {
    this.showId = crypto.randomUUID().slice(0, 8);
    this.movie = movie;
    this.screen = screen;
    this.startTime = startTime;
    this.seatStatus = new Map(screen.seats.map(s => [s.seatId, SeatStatus.AVAILABLE]));
    this.seatLocks = new Map(); // seatId -> expiry timestamp
  }

  getAvailableSeats() {
    this.#expireLocks();
    return [...this.seatStatus.entries()]
      .filter(([_, status]) => status === SeatStatus.AVAILABLE)
      .map(([id]) => id);
  }

  lockSeats(seatIds) {
    this.#expireLocks();
    // Check all available first
    if (seatIds.some(id => this.seatStatus.get(id) !== SeatStatus.AVAILABLE)) return false;

    const expiry = Date.now() + LOCK_DURATION_MS;
    seatIds.forEach(id => {
      this.seatStatus.set(id, SeatStatus.LOCKED);
      this.seatLocks.set(id, expiry);
    });
    return true;
  }

  confirmSeats(seatIds) {
    seatIds.forEach(id => {
      this.seatStatus.set(id, SeatStatus.BOOKED);
      this.seatLocks.delete(id);
    });
  }

  releaseSeats(seatIds) {
    seatIds.forEach(id => {
      this.seatStatus.set(id, SeatStatus.AVAILABLE);
      this.seatLocks.delete(id);
    });
  }

  #expireLocks() {
    const now = Date.now();
    for (const [id, expiry] of this.seatLocks) {
      if (now > expiry) {
        this.seatStatus.set(id, SeatStatus.AVAILABLE);
        this.seatLocks.delete(id);
      }
    }
  }
}

// ---- Booking & Payment ----
class Booking {
  constructor(userId, show, seatIds) {
    this.bookingId = crypto.randomUUID().slice(0, 8);
    this.userId = userId;
    this.show = show;
    this.seatIds = seatIds;
    this.status = BookingStatus.PENDING;
    this.createdAt = Date.now();
    this.amount = this.#calculateAmount();
  }

  #calculateAmount() {
    const seatMap = Object.fromEntries(this.show.screen.seats.map(s => [s.seatId, s]));
    return this.seatIds.reduce((sum, id) => sum + SEAT_PRICES[seatMap[id].seatType], 0);
  }
}

// ---- Booking Service ----
class BookingService {
  constructor() { this.bookings = new Map(); }

  createBooking(userId, show, seatIds) {
    if (!show.lockSeats(seatIds)) {
      console.log(`Seats ${seatIds} not available!`);
      return null;
    }
    const booking = new Booking(userId, show, seatIds);
    this.bookings.set(booking.bookingId, booking);
    console.log(`Booking ${booking.bookingId}: $${booking.amount}. Pay within 5 min.`);
    return booking;
  }

  confirmBooking(bookingId, paymentMethod) {
    const booking = this.bookings.get(bookingId);
    if (!booking || booking.status !== BookingStatus.PENDING) return null;

    if (Date.now() - booking.createdAt > LOCK_DURATION_MS) {
      booking.status = BookingStatus.EXPIRED;
      booking.show.releaseSeats(booking.seatIds);
      console.log("Booking expired!");
      return null;
    }

    booking.show.confirmSeats(booking.seatIds);
    booking.status = BookingStatus.CONFIRMED;
    console.log(`Booking ${bookingId} confirmed!`);
    return { bookingId, amount: booking.amount, method: paymentMethod };
  }

  cancelBooking(bookingId) {
    const booking = this.bookings.get(bookingId);
    if (!booking) return;
    booking.show.releaseSeats(booking.seatIds);
    booking.status = BookingStatus.CANCELLED;
    console.log(`Booking ${bookingId} cancelled.`);
  }
}

// Usage
const seats = [
  ...Array.from({ length: 5 }, (_, i) => new Seat("A", i + 1, SeatType.VIP)),
  ...Array.from({ length: 10 }, (_, i) => new Seat("B", i + 1, SeatType.PREMIUM)),
  ...Array.from({ length: 15 }, (_, i) => new Seat("C", i + 1, SeatType.REGULAR)),
];
const screen = new Screen(1, seats);
const movie = new Movie("Inception", 148, "Sci-Fi");
const show = new Show(movie, screen, new Date("2025-01-15T18:30:00"));

const service = new BookingService();
const b1 = service.createBooking("user1", show, ["A1", "A2"]);
service.createBooking("user2", show, ["A1", "A3"]); // Fails!
service.confirmBooking(b1.bookingId, "credit_card");
import java.util.*;
import java.time.*;
import java.util.concurrent.ConcurrentHashMap;

enum SeatType { REGULAR, PREMIUM, VIP }
enum SeatStatus { AVAILABLE, LOCKED, BOOKED }
enum BookingStatus { PENDING, CONFIRMED, CANCELLED, EXPIRED }

class Movie {
    String movieId = UUID.randomUUID().toString().substring(0, 8);
    String title;
    int durationMins;
    Movie(String title, int mins) { this.title = title; this.durationMins = mins; }
}

class Seat {
    String seatId;
    String row;
    int number;
    SeatType seatType;
    Seat(String row, int num, SeatType type) {
        this.seatId = row + num;
        this.row = row;
        this.number = num;
        this.seatType = type;
    }
}

class Screen {
    int screenNumber;
    List<Seat> seats;
    Screen(int num, List<Seat> seats) { this.screenNumber = num; this.seats = seats; }
}

class Show {
    static final Duration LOCK_DURATION = Duration.ofMinutes(5);
    String showId = UUID.randomUUID().toString().substring(0, 8);
    Movie movie;
    Screen screen;
    LocalDateTime startTime;
    ConcurrentHashMap<String, SeatStatus> seatStatus = new ConcurrentHashMap<>();
    ConcurrentHashMap<String, Instant> seatLocks = new ConcurrentHashMap<>();

    Show(Movie movie, Screen screen, LocalDateTime startTime) {
        this.movie = movie;
        this.screen = screen;
        this.startTime = startTime;
        screen.seats.forEach(s -> seatStatus.put(s.seatId, SeatStatus.AVAILABLE));
    }

    synchronized boolean lockSeats(List<String> seatIds) {
        expireLocks();
        for (String id : seatIds) {
            if (seatStatus.get(id) != SeatStatus.AVAILABLE) return false;
        }
        Instant expiry = Instant.now().plus(LOCK_DURATION);
        seatIds.forEach(id -> {
            seatStatus.put(id, SeatStatus.LOCKED);
            seatLocks.put(id, expiry);
        });
        return true;
    }

    synchronized void confirmSeats(List<String> ids) {
        ids.forEach(id -> { seatStatus.put(id, SeatStatus.BOOKED); seatLocks.remove(id); });
    }

    synchronized void releaseSeats(List<String> ids) {
        ids.forEach(id -> { seatStatus.put(id, SeatStatus.AVAILABLE); seatLocks.remove(id); });
    }

    private void expireLocks() {
        Instant now = Instant.now();
        seatLocks.forEach((id, expiry) -> {
            if (now.isAfter(expiry)) {
                seatStatus.put(id, SeatStatus.AVAILABLE);
                seatLocks.remove(id);
            }
        });
    }
}

class Booking {
    String bookingId = UUID.randomUUID().toString().substring(0, 8);
    String userId;
    Show show;
    List<String> seatIds;
    BookingStatus status = BookingStatus.PENDING;
    Instant createdAt = Instant.now();
    double amount;

    Booking(String userId, Show show, List<String> seatIds) {
        this.userId = userId;
        this.show = show;
        this.seatIds = seatIds;
        Map<SeatType, Integer> prices = Map.of(
            SeatType.REGULAR, 200, SeatType.PREMIUM, 350, SeatType.VIP, 500);
        Map<String, Seat> seatMap = new HashMap<>();
        show.screen.seats.forEach(s -> seatMap.put(s.seatId, s));
        this.amount = seatIds.stream()
            .mapToInt(id -> prices.get(seatMap.get(id).seatType)).sum();
    }
}

class BookingService {
    Map<String, Booking> bookings = new ConcurrentHashMap<>();

    Booking createBooking(String userId, Show show, List<String> seatIds) {
        if (!show.lockSeats(seatIds)) {
            System.out.println("Seats not available!");
            return null;
        }
        Booking b = new Booking(userId, show, seatIds);
        bookings.put(b.bookingId, b);
        System.out.printf("Booking %s: $%.0f. Pay within 5 min.%n", b.bookingId, b.amount);
        return b;
    }

    boolean confirmBooking(String bookingId) {
        Booking b = bookings.get(bookingId);
        if (b == null || b.status != BookingStatus.PENDING) return false;
        if (Instant.now().isAfter(b.createdAt.plus(Show.LOCK_DURATION))) {
            b.status = BookingStatus.EXPIRED;
            b.show.releaseSeats(b.seatIds);
            return false;
        }
        b.show.confirmSeats(b.seatIds);
        b.status = BookingStatus.CONFIRMED;
        System.out.printf("Booking %s confirmed!%n", bookingId);
        return true;
    }
}

The Seat Locking Problem

This is the part interviewers really care about. Here’s the flow:

  1. User selects seats — we call lockSeats(). This atomically checks all seats are available and locks them.
  2. User has 5 minutes to complete payment. During this time, no one else can book those seats.
  3. User pays — we call confirmSeats(). Seats go from LOCKED to BOOKED. Done.
  4. User doesn’t pay in time_expire_locks() runs on the next request and releases the seats back to AVAILABLE.

The synchronized keyword (Java) or Lock (Python) is critical here. Without it, two threads could both check that seat A1 is available, both see True, and both try to lock it. Classic race condition.

In simple language, it’s like putting a “hold” sticker on items at a store. We hold them at the counter for 5 minutes while the customer goes to get their wallet. If they don’t come back, items go back on the shelf.

Design Patterns Used

Repository/Service pattern — BookingService manages the booking lifecycle. Show manages seat state. Clean separation of concerns.

Concurrency control — Pessimistic locking via synchronized blocks. We lock first, ask questions later. This prevents double-booking at the cost of some waiting.

Enum-based state machine — SeatStatus transitions: AVAILABLE -> LOCKED -> BOOKED (or back to AVAILABLE on expiry/cancellation).

Extensions

  • Offers/Coupons — Add a CouponService that validates and applies discounts before payment. Strategy pattern for different discount types (flat, percentage, BOGO).
  • Food ordering — Add FoodItem and FoodOrder classes. Attach a food order to a Booking. Process food payment together with ticket payment.
  • Cancellation with refund policy — Time-based refund: full refund if 24h before show, 50% if 4h before, no refund after. Strategy pattern for refund calculation.
  • Waitlist — If all seats are booked, let users join a waitlist. Observer pattern: notify waitlisted users when seats are released.
  • Seat categories with dynamic pricing — Weekend shows cost more, morning shows cost less. Add a PricingStrategy that takes show time, day, and demand into account.

29

Design a Vending Machine

intermediate 2-4 YOE lld vending-machine design-question state-pattern

The vending machine is the classic State pattern interview question. We already learned the State pattern earlier — now we’re applying it to a full design problem with real inventory, money handling, and change calculation. Interviewers love this because it tests whether we actually know when and how to use design patterns in practice.

The whole point: the machine behaves completely differently depending on what state it’s in. Insert money when idle? Cool, accepted. Insert money when already dispensing? Nope, wait. Same action, different behavior based on state.

Requirements

Functional:

  • Machine holds multiple products, each with a price and quantity
  • User inserts coins/money (we track the running total)
  • User selects a product
  • Machine dispenses product if enough money and in stock
  • Machine returns change if overpaid
  • Machine handles edge cases: out of stock, insufficient money, no change available

State Transitions:

  • Idle -> (insert money) -> HasMoney
  • HasMoney -> (select product, enough money) -> Dispensing
  • HasMoney -> (cancel) -> Idle (refund money)
  • Dispensing -> (product dispensed) -> Idle

Key Classes & Relationships

Vending Machine State Diagram
IdleState
accepts money
rejects select/dispense
--insert $-->
HasMoneyState
accepts more money
allows product selection
--select-->
DispensingState
dispenses product
returns change
goes back to Idle
cancel at any point -> refund money -> Idle
Product
name, price, code
Inventory
products + quantities
VendingMachine
state, balance, inventory

Core Implementation

from abc import ABC, abstractmethod

# ---- Product & Inventory ----
class Product:
    def __init__(self, code: str, name: str, price: float):
        self.code = code
        self.name = name
        self.price = price

    def __repr__(self):
        return f"{self.name} (${self.price})"

class Inventory:
    def __init__(self):
        self.products = {}   # code -> Product
        self.quantities = {} # code -> int

    def add_product(self, product: Product, quantity: int):
        self.products[product.code] = product
        self.quantities[product.code] = self.quantities.get(product.code, 0) + quantity

    def get_product(self, code: str) -> Product:
        return self.products.get(code)

    def is_available(self, code: str) -> bool:
        return self.quantities.get(code, 0) > 0

    def dispense(self, code: str):
        if self.is_available(code):
            self.quantities[code] -= 1

# ---- State Interface ----
class State(ABC):
    @abstractmethod
    def insert_money(self, machine, amount: float) -> None:
        pass

    @abstractmethod
    def select_product(self, machine, code: str) -> None:
        pass

    @abstractmethod
    def dispense(self, machine) -> None:
        pass

    @abstractmethod
    def cancel(self, machine) -> None:
        pass

# ---- Concrete States ----
class IdleState(State):
    def insert_money(self, machine, amount):
        machine.balance += amount
        print(f"Inserted ${amount}. Balance: ${machine.balance}")
        machine.set_state(HasMoneyState())

    def select_product(self, machine, code):
        print("Insert money first!")

    def dispense(self, machine):
        print("Insert money and select a product first!")

    def cancel(self, machine):
        print("Nothing to cancel.")

class HasMoneyState(State):
    def insert_money(self, machine, amount):
        machine.balance += amount
        print(f"Inserted ${amount}. Balance: ${machine.balance}")

    def select_product(self, machine, code):
        product = machine.inventory.get_product(code)

        if not product:
            print(f"Invalid code: {code}")
            return

        if not machine.inventory.is_available(code):
            print(f"{product.name} is out of stock!")
            return

        if machine.balance < product.price:
            print(f"Not enough money! {product.name} costs ${product.price}, "
                  f"balance is ${machine.balance}. Insert ${product.price - machine.balance} more.")
            return

        machine.selected_product = product
        print(f"Selected: {product.name}. Dispensing...")
        machine.set_state(DispensingState())
        machine.dispense()  # auto-trigger dispense

    def dispense(self, machine):
        print("Select a product first!")

    def cancel(self, machine):
        print(f"Cancelled. Refunding ${machine.balance}")
        machine.balance = 0
        machine.set_state(IdleState())

class DispensingState(State):
    def insert_money(self, machine, amount):
        print("Please wait, dispensing in progress.")

    def select_product(self, machine, code):
        print("Already dispensing, please wait.")

    def dispense(self, machine):
        product = machine.selected_product
        machine.inventory.dispense(product.code)

        change = machine.balance - product.price
        print(f"Dispensed: {product.name}")
        if change > 0:
            print(f"Change returned: ${change}")

        # Reset
        machine.balance = 0
        machine.selected_product = None
        machine.set_state(IdleState())

    def cancel(self, machine):
        print("Cannot cancel during dispensing.")

# ---- Vending Machine ----
class VendingMachine:
    def __init__(self):
        self.inventory = Inventory()
        self.balance = 0.0
        self.selected_product = None
        self._state = IdleState()

    def set_state(self, state: State):
        self._state = state

    def insert_money(self, amount: float):
        self._state.insert_money(self, amount)

    def select_product(self, code: str):
        self._state.select_product(self, code)

    def dispense(self):
        self._state.dispense(self)

    def cancel(self):
        self._state.cancel(self)

    def show_products(self):
        print("\n--- Available Products ---")
        for code, product in self.inventory.products.items():
            qty = self.inventory.quantities[code]
            status = f"({qty} left)" if qty > 0 else "(OUT OF STOCK)"
            print(f"  [{code}] {product.name} - ${product.price} {status}")
        print()

# ---- Usage ----
machine = VendingMachine()

# Stock the machine
machine.inventory.add_product(Product("A1", "Coca Cola", 1.50), 5)
machine.inventory.add_product(Product("A2", "Pepsi", 1.50), 3)
machine.inventory.add_product(Product("B1", "Lays Chips", 2.00), 2)
machine.inventory.add_product(Product("B2", "Snickers", 1.75), 0)  # out of stock!

machine.show_products()

# Happy path
machine.insert_money(2.00)       # Inserted $2.0. Balance: $2.0
machine.select_product("A1")     # Selected: Coca Cola. Dispensing...
                                  # Dispensed: Coca Cola
                                  # Change returned: $0.5

# Not enough money
machine.insert_money(1.00)       # Inserted $1.0. Balance: $1.0
machine.select_product("B1")     # Not enough money! Lays costs $2.0...

# Add more money
machine.insert_money(1.00)       # Inserted $1.0. Balance: $2.0
machine.select_product("B1")     # Selected: Lays Chips. Dispensing...

# Out of stock
machine.insert_money(2.00)
machine.select_product("B2")     # Snickers is out of stock!
machine.cancel()                 # Cancelled. Refunding $2.0
// ---- Product & Inventory ----
class Product {
  constructor(code, name, price) {
    this.code = code;
    this.name = name;
    this.price = price;
  }
}

class Inventory {
  constructor() {
    this.products = new Map();
    this.quantities = new Map();
  }
  addProduct(product, qty) {
    this.products.set(product.code, product);
    this.quantities.set(product.code, (this.quantities.get(product.code) || 0) + qty);
  }
  getProduct(code) { return this.products.get(code) || null; }
  isAvailable(code) { return (this.quantities.get(code) || 0) > 0; }
  dispense(code) {
    if (this.isAvailable(code)) this.quantities.set(code, this.quantities.get(code) - 1);
  }
}

// ---- States ----
class IdleState {
  insertMoney(machine, amount) {
    machine.balance += amount;
    console.log(`Inserted $${amount}. Balance: $${machine.balance}`);
    machine.setState(new HasMoneyState());
  }
  selectProduct(machine, code) { console.log("Insert money first!"); }
  dispense(machine) { console.log("Insert money and select a product first!"); }
  cancel(machine) { console.log("Nothing to cancel."); }
}

class HasMoneyState {
  insertMoney(machine, amount) {
    machine.balance += amount;
    console.log(`Inserted $${amount}. Balance: $${machine.balance}`);
  }

  selectProduct(machine, code) {
    const product = machine.inventory.getProduct(code);
    if (!product) { console.log(`Invalid code: ${code}`); return; }
    if (!machine.inventory.isAvailable(code)) {
      console.log(`${product.name} is out of stock!`); return;
    }
    if (machine.balance < product.price) {
      console.log(`Not enough! ${product.name} costs $${product.price}, ` +
        `balance is $${machine.balance}`);
      return;
    }
    machine.selectedProduct = product;
    console.log(`Selected: ${product.name}. Dispensing...`);
    machine.setState(new DispensingState());
    machine.dispense();
  }

  dispense(machine) { console.log("Select a product first!"); }
  cancel(machine) {
    console.log(`Cancelled. Refunding $${machine.balance}`);
    machine.balance = 0;
    machine.setState(new IdleState());
  }
}

class DispensingState {
  insertMoney(machine, amount) { console.log("Please wait, dispensing."); }
  selectProduct(machine, code) { console.log("Already dispensing, wait."); }
  dispense(machine) {
    const product = machine.selectedProduct;
    machine.inventory.dispense(product.code);
    const change = machine.balance - product.price;
    console.log(`Dispensed: ${product.name}`);
    if (change > 0) console.log(`Change: $${change}`);
    machine.balance = 0;
    machine.selectedProduct = null;
    machine.setState(new IdleState());
  }
  cancel(machine) { console.log("Cannot cancel during dispensing."); }
}

// ---- Vending Machine ----
class VendingMachine {
  constructor() {
    this.inventory = new Inventory();
    this.balance = 0;
    this.selectedProduct = null;
    this.state = new IdleState();
  }
  setState(state) { this.state = state; }
  insertMoney(amount) { this.state.insertMoney(this, amount); }
  selectProduct(code) { this.state.selectProduct(this, code); }
  dispense() { this.state.dispense(this); }
  cancel() { this.state.cancel(this); }
}

// Usage
const machine = new VendingMachine();
machine.inventory.addProduct(new Product("A1", "Coca Cola", 1.50), 5);
machine.inventory.addProduct(new Product("B1", "Lays Chips", 2.00), 2);

machine.insertMoney(2.00);     // Inserted $2. Balance: $2
machine.selectProduct("A1");   // Selected: Coca Cola -> Dispensed + $0.5 change
import java.util.*;

class Product {
    String code, name;
    double price;
    Product(String code, String name, double price) {
        this.code = code; this.name = name; this.price = price;
    }
}

class Inventory {
    Map<String, Product> products = new HashMap<>();
    Map<String, Integer> quantities = new HashMap<>();

    void addProduct(Product p, int qty) {
        products.put(p.code, p);
        quantities.merge(p.code, qty, Integer::sum);
    }
    Product getProduct(String code) { return products.get(code); }
    boolean isAvailable(String code) { return quantities.getOrDefault(code, 0) > 0; }
    void dispense(String code) { quantities.computeIfPresent(code, (k, v) -> v - 1); }
}

// ---- State Interface ----
interface State {
    void insertMoney(VendingMachine m, double amount);
    void selectProduct(VendingMachine m, String code);
    void dispense(VendingMachine m);
    void cancel(VendingMachine m);
}

class IdleState implements State {
    public void insertMoney(VendingMachine m, double amount) {
        m.balance += amount;
        System.out.printf("Inserted $%.2f. Balance: $%.2f%n", amount, m.balance);
        m.setState(new HasMoneyState());
    }
    public void selectProduct(VendingMachine m, String code) {
        System.out.println("Insert money first!");
    }
    public void dispense(VendingMachine m) {
        System.out.println("Insert money and select a product first!");
    }
    public void cancel(VendingMachine m) {
        System.out.println("Nothing to cancel.");
    }
}

class HasMoneyState implements State {
    public void insertMoney(VendingMachine m, double amount) {
        m.balance += amount;
        System.out.printf("Inserted $%.2f. Balance: $%.2f%n", amount, m.balance);
    }

    public void selectProduct(VendingMachine m, String code) {
        Product product = m.inventory.getProduct(code);
        if (product == null) { System.out.println("Invalid code!"); return; }
        if (!m.inventory.isAvailable(code)) {
            System.out.println(product.name + " is out of stock!"); return;
        }
        if (m.balance < product.price) {
            System.out.printf("Not enough! %s costs $%.2f, balance $%.2f%n",
                product.name, product.price, m.balance);
            return;
        }
        m.selectedProduct = product;
        System.out.println("Selected: " + product.name + ". Dispensing...");
        m.setState(new DispensingState());
        m.dispense();
    }

    public void dispense(VendingMachine m) { System.out.println("Select a product first!"); }
    public void cancel(VendingMachine m) {
        System.out.printf("Cancelled. Refunding $%.2f%n", m.balance);
        m.balance = 0;
        m.setState(new IdleState());
    }
}

class DispensingState implements State {
    public void insertMoney(VendingMachine m, double amount) {
        System.out.println("Please wait, dispensing.");
    }
    public void selectProduct(VendingMachine m, String code) {
        System.out.println("Already dispensing, wait.");
    }
    public void dispense(VendingMachine m) {
        Product p = m.selectedProduct;
        m.inventory.dispense(p.code);
        double change = m.balance - p.price;
        System.out.println("Dispensed: " + p.name);
        if (change > 0) System.out.printf("Change: $%.2f%n", change);
        m.balance = 0;
        m.selectedProduct = null;
        m.setState(new IdleState());
    }
    public void cancel(VendingMachine m) {
        System.out.println("Cannot cancel during dispensing.");
    }
}

class VendingMachine {
    Inventory inventory = new Inventory();
    double balance = 0;
    Product selectedProduct = null;
    private State state = new IdleState();

    void setState(State s) { this.state = s; }
    void insertMoney(double amount) { state.insertMoney(this, amount); }
    void selectProduct(String code) { state.selectProduct(this, code); }
    void dispense() { state.dispense(this); }
    void cancel() { state.cancel(this); }
}

Why State Pattern Here?

Look at what each state does differently for the same actions:

ActionIdleStateHasMoneyStateDispensingState
insert_moneyAccept, go to HasMoneyAccept, stayReject
select_productRejectValidate & go to DispensingReject
dispenseRejectRejectDispense, go to Idle
cancelNo-opRefund, go to IdleReject

Without the State pattern, every method would have a big if-else checking which state we’re in. With it, each state class handles its own behavior. Adding a new state (like MaintenanceState) means adding one class — zero changes to existing code.

Design Patterns Used

State — The star of the show. Each state (Idle, HasMoney, Dispensing) is its own class. The VendingMachine delegates all actions to the current state. States transition themselves.

Composition — VendingMachine composes Inventory and State. Inventory composes Products. No deep inheritance trees.

Separation of concerns — Product knows about itself. Inventory manages stock. States manage behavior. VendingMachine is just the glue.

Extensions

  • Card payment — Add a PaymentMethod interface with CashPayment and CardPayment implementations. The HasMoneyState becomes more of a “HasPayment” state.
  • Admin refill — Add an AdminService class with refill(code, quantity) and collectMoney() methods. Only accessible with an admin key/PIN.
  • Multiple product selection — Let users add multiple items to a “cart” before dispensing. The HasMoneyState tracks a list of selected products instead of one.
  • Coin denomination handling — Instead of tracking a simple balance, track individual coins inserted. Makes change calculation more realistic (can we even make change with the coins we have?).
  • Display and notifications — Add a Display class that shows current state, balance, and product info. Observer pattern to update display when state changes.

In simple language, the vending machine is the perfect warm-up for State pattern in interviews. The states are obvious, the transitions are clear, and the code practically writes itself once we identify the states. When the interviewer says “design a vending machine,” they’re really asking “show me you understand the State pattern.”


30

Design Snake & Ladder Game

intermediate 2-4 YOE lld snake-ladder design-question

Snake & Ladder is one of the most fun LLD questions we’ll get in an interview. It’s deceptively simple — a board, some snakes, some ladders, roll a dice, move forward. But designing it cleanly tests our OOP fundamentals: composition, encapsulation, and separation of concerns.

Let’s design it from scratch.

Requirements

Functional:

  • Board with 100 cells (numbered 1 to 100)
  • Snakes take a player DOWN from head to tail
  • Ladders take a player UP from bottom to top
  • Players take turns rolling a dice (1-6)
  • First player to reach exactly cell 100 wins
  • If a roll would take a player beyond 100, they stay put

Constraints:

  • 2-4 players
  • Snakes and ladders don’t overlap (no snake head on a ladder bottom or vice versa)
  • No snake or ladder on cell 1 or cell 100

Key Classes & Relationships

Snake & Ladder — Class Diagram
Game
- board: Board
- players: Player[]
- dice: Dice
- currentTurn: int
+ play() → runs game loop
│ has-a
Board
- size: int
- snakes: Snake[]
- ladders: Ladder[]
+ getNewPosition(pos)
Player
- name: string
- position: int
Dice
- sides: int
+ roll() → int
Board contains ↓
Snake
- head: int (higher)
- tail: int (lower)
Ladder
- bottom: int (lower)
- top: int (higher)

The key composition here: Game has a Board, Players, and a Dice. Board has Snakes and Ladders. Clean separation — the Board handles position logic, the Game handles turn management.

Core Implementation

import random

class Dice:
    def __init__(self, sides: int = 6):
        self.sides = sides

    def roll(self) -> int:
        return random.randint(1, self.sides)

class Snake:
    def __init__(self, head: int, tail: int):
        if head <= tail:
            raise ValueError("Snake head must be above tail")
        self.head = head
        self.tail = tail

class Ladder:
    def __init__(self, bottom: int, top: int):
        if bottom >= top:
            raise ValueError("Ladder bottom must be below top")
        self.bottom = bottom
        self.top = top

class Player:
    def __init__(self, name: str):
        self.name = name
        self.position = 0  # 0 means not on the board yet

class Board:
    def __init__(self, size: int, snakes: list[Snake], ladders: list[Ladder]):
        self.size = size
        self.snakes = {s.head: s.tail for s in snakes}
        self.ladders = {l.bottom: l.top for l in ladders}

    def get_new_position(self, current: int, dice_value: int) -> int:
        new_pos = current + dice_value

        # Can't go beyond the board
        if new_pos > self.size:
            return current

        # Check for snake or ladder at the new position
        if new_pos in self.snakes:
            print(f"  🐍 Snake! Sliding down from {new_pos} to {self.snakes[new_pos]}")
            return self.snakes[new_pos]

        if new_pos in self.ladders:
            print(f"  🪜 Ladder! Climbing up from {new_pos} to {self.ladders[new_pos]}")
            return self.ladders[new_pos]

        return new_pos

class Game:
    def __init__(self, players: list[Player], board: Board, dice: Dice):
        self.players = players
        self.board = board
        self.dice = dice
        self.current_turn = 0

    def play(self) -> Player:
        while True:
            player = self.players[self.current_turn]
            dice_value = self.dice.roll()
            old_pos = player.position
            player.position = self.board.get_new_position(old_pos, dice_value)
            print(f"{player.name} rolled {dice_value}: {old_pos}{player.position}")

            if player.position == self.board.size:
                print(f"\n{player.name} wins!")
                return player

            # Next player's turn
            self.current_turn = (self.current_turn + 1) % len(self.players)

# Setup and play
snakes = [Snake(62, 5), Snake(33, 6), Snake(49, 9), Snake(88, 16)]
ladders = [Ladder(2, 37), Ladder(27, 46), Ladder(56, 95), Ladder(78, 98)]
board = Board(100, snakes, ladders)
dice = Dice()
players = [Player("Alice"), Player("Bob")]

game = Game(players, board, dice)
winner = game.play()
class Dice {
  constructor(sides = 6) {
    this.sides = sides;
  }
  roll() {
    return Math.floor(Math.random() * this.sides) + 1;
  }
}

class Snake {
  constructor(head, tail) {
    this.head = head;
    this.tail = tail;
  }
}

class Ladder {
  constructor(bottom, top) {
    this.bottom = bottom;
    this.top = top;
  }
}

class Player {
  constructor(name) {
    this.name = name;
    this.position = 0;
  }
}

class Board {
  constructor(size, snakes, ladders) {
    this.size = size;
    this.snakes = new Map(snakes.map((s) => [s.head, s.tail]));
    this.ladders = new Map(ladders.map((l) => [l.bottom, l.top]));
  }

  getNewPosition(current, diceValue) {
    let newPos = current + diceValue;

    if (newPos > this.size) return current;

    if (this.snakes.has(newPos)) {
      console.log(`  Snake! ${newPos} → ${this.snakes.get(newPos)}`);
      return this.snakes.get(newPos);
    }
    if (this.ladders.has(newPos)) {
      console.log(`  Ladder! ${newPos} → ${this.ladders.get(newPos)}`);
      return this.ladders.get(newPos);
    }
    return newPos;
  }
}

class Game {
  constructor(players, board, dice) {
    this.players = players;
    this.board = board;
    this.dice = dice;
    this.currentTurn = 0;
  }

  play() {
    while (true) {
      const player = this.players[this.currentTurn];
      const diceValue = this.dice.roll();
      const oldPos = player.position;
      player.position = this.board.getNewPosition(oldPos, diceValue);
      console.log(`${player.name} rolled ${diceValue}: ${oldPos} → ${player.position}`);

      if (player.position === this.board.size) {
        console.log(`\n${player.name} wins!`);
        return player;
      }
      this.currentTurn = (this.currentTurn + 1) % this.players.length;
    }
  }
}

// Setup
const snakes = [new Snake(62, 5), new Snake(33, 6), new Snake(49, 9)];
const ladders = [new Ladder(2, 37), new Ladder(27, 46), new Ladder(56, 95)];
const board = new Board(100, snakes, ladders);
const game = new Game([new Player("Alice"), new Player("Bob")], board, new Dice());
game.play();
import java.util.*;

class Dice {
    private final int sides;
    private final Random random = new Random();

    public Dice(int sides) { this.sides = sides; }

    public int roll() { return random.nextInt(sides) + 1; }
}

class Snake {
    final int head, tail;
    public Snake(int head, int tail) { this.head = head; this.tail = tail; }
}

class Ladder {
    final int bottom, top;
    public Ladder(int bottom, int top) { this.bottom = bottom; this.top = top; }
}

class Player {
    final String name;
    int position = 0;
    public Player(String name) { this.name = name; }
}

class Board {
    private final int size;
    private final Map<Integer, Integer> snakes = new HashMap<>();
    private final Map<Integer, Integer> ladders = new HashMap<>();

    public Board(int size, List<Snake> snakeList, List<Ladder> ladderList) {
        this.size = size;
        snakeList.forEach(s -> snakes.put(s.head, s.tail));
        ladderList.forEach(l -> ladders.put(l.bottom, l.top));
    }

    public int getNewPosition(int current, int diceValue) {
        int newPos = current + diceValue;
        if (newPos > size) return current;

        if (snakes.containsKey(newPos)) {
            System.out.println("  Snake! " + newPos + " → " + snakes.get(newPos));
            return snakes.get(newPos);
        }
        if (ladders.containsKey(newPos)) {
            System.out.println("  Ladder! " + newPos + " → " + ladders.get(newPos));
            return ladders.get(newPos);
        }
        return newPos;
    }

    public int getSize() { return size; }
}

class Game {
    private final List<Player> players;
    private final Board board;
    private final Dice dice;
    private int currentTurn = 0;

    public Game(List<Player> players, Board board, Dice dice) {
        this.players = players;
        this.board = board;
        this.dice = dice;
    }

    public Player play() {
        while (true) {
            Player player = players.get(currentTurn);
            int diceValue = dice.roll();
            int oldPos = player.position;
            player.position = board.getNewPosition(oldPos, diceValue);
            System.out.println(player.name + " rolled " + diceValue
                + ": " + oldPos + " → " + player.position);

            if (player.position == board.getSize()) {
                System.out.println("\n" + player.name + " wins!");
                return player;
            }
            currentTurn = (currentTurn + 1) % players.size();
        }
    }
}

Design Patterns Used

Composition over Inheritance — This is the big one here. Game HAS a Board, Board HAS Snakes and Ladders. We didn’t create a BoardEntity base class that Snake and Ladder inherit from. Why? Because they’re fundamentally different things — one moves us down, the other moves us up. Composition keeps it clean.

Encapsulation — The Board hides HOW it calculates positions. The Game doesn’t care about snakes or ladders — it just asks the Board “given this position and dice roll, where does the player end up?” Single place for all position logic.

SRP (Single Responsibility) — Each class does one thing:

  • Dice — random number generation
  • Board — position calculation (snakes, ladders, boundaries)
  • Player — tracks name and position
  • Game — orchestrates turns and win detection

Extensions

These are common follow-up questions interviewers love to ask:

“Support multiple dice” — Change Dice.roll() to accept a count, or have the Game hold a list of Dice objects. Sum all rolls.

“Add special cells (power-ups)” — This is where we might introduce a Cell class with a landOn(player) method. Snakes, Ladders, and PowerUps would all be types of cell effects. Strategy pattern for cell behavior.

“Save and resume a game” — Serialize the game state: player positions, whose turn it is, board config. Memento pattern. Store as JSON and reload.

“What if a snake’s tail lands on another snake’s head?” — We could allow chaining: keep checking the new position until we land on a neutral cell. Just make the getNewPosition method loop until stable.

In simple language, Snake & Ladder is a composition exercise. Board owns the position logic, Game owns the turns, and everything talks through clean interfaces. No inheritance needed. Keep it flat, keep it simple.


31

Design Chess Game

advanced 4-7 YOE lld chess design-question

Chess is the ultimate LLD interview question. It tests inheritance, polymorphism, encapsulation — basically everything. Six different piece types, each with unique movement rules, all interacting on the same board. If we can design this cleanly, we can design anything.

Let’s build it step by step.

Requirements

Functional:

  • 8x8 board with standard initial piece placement
  • 6 piece types: King, Queen, Rook, Bishop, Knight, Pawn — each with unique movement
  • Two players (White and Black), turns alternate
  • Pieces can capture opponent pieces by moving to their square
  • Detect check (king under attack) and checkmate (no legal moves to escape check)
  • Game ends on checkmate, stalemate, or resignation

Constraints:

  • A move is invalid if it leaves own king in check
  • Pawns move forward but capture diagonally
  • Knights jump over pieces

Key Classes & Relationships

Chess — Class Diagram
Game
- board: Board
- players: Player[2]
- currentTurn: Color
- status: GameStatus
+ makeMove(from, to) → bool
│ has-a
Board
- grid: Square[8][8]
+ getPiece(row, col)
+ movePiece(from, to)
+ isPathClear(from, to)
Player
- name: string
- color: Color
Board contains Squares, each may hold ↓
Piece (abstract)
- color: Color
+ canMove(board, from, to) → bool
+ getSymbol() → string
│ extended by
King
Queen
Rook
Bishop
Knight
Pawn

The core idea: every piece type overrides canMove() with its own movement rules. The Game doesn’t care what type of piece it is — it just calls piece.canMove(). That’s polymorphism doing the heavy lifting.

Core Implementation

from abc import ABC, abstractmethod
from enum import Enum

class Color(Enum):
    WHITE = "white"
    BLACK = "black"

class GameStatus(Enum):
    ACTIVE = "active"
    WHITE_WIN = "white_win"
    BLACK_WIN = "black_win"
    STALEMATE = "stalemate"

# --- Pieces ---

class Piece(ABC):
    def __init__(self, color: Color):
        self.color = color

    @abstractmethod
    def can_move(self, board, fr: int, fc: int, tr: int, tc: int) -> bool:
        pass

    @abstractmethod
    def symbol(self) -> str:
        pass

class King(Piece):
    def can_move(self, board, fr, fc, tr, tc) -> bool:
        # One square in any direction
        return max(abs(tr - fr), abs(tc - fc)) == 1

    def symbol(self): return "K"

class Queen(Piece):
    def can_move(self, board, fr, fc, tr, tc) -> bool:
        # Straight or diagonal, path must be clear
        dr, dc = tr - fr, tc - fc
        is_straight = dr == 0 or dc == 0
        is_diagonal = abs(dr) == abs(dc)
        return (is_straight or is_diagonal) and board.is_path_clear(fr, fc, tr, tc)

    def symbol(self): return "Q"

class Rook(Piece):
    def can_move(self, board, fr, fc, tr, tc) -> bool:
        # Straight lines only
        dr, dc = tr - fr, tc - fc
        return (dr == 0 or dc == 0) and board.is_path_clear(fr, fc, tr, tc)

    def symbol(self): return "R"

class Bishop(Piece):
    def can_move(self, board, fr, fc, tr, tc) -> bool:
        # Diagonals only
        return abs(tr - fr) == abs(tc - fc) and board.is_path_clear(fr, fc, tr, tc)

    def symbol(self): return "B"

class Knight(Piece):
    def can_move(self, board, fr, fc, tr, tc) -> bool:
        # L-shape: 2+1 or 1+2, can jump over pieces
        dr, dc = abs(tr - fr), abs(tc - fc)
        return (dr == 2 and dc == 1) or (dr == 1 and dc == 2)

    def symbol(self): return "N"

class Pawn(Piece):
    def can_move(self, board, fr, fc, tr, tc) -> bool:
        direction = -1 if self.color == Color.WHITE else 1
        start_row = 6 if self.color == Color.WHITE else 1
        dr, dc = tr - fr, tc - fc

        # Move forward one square (must be empty)
        if dc == 0 and dr == direction and board.get_piece(tr, tc) is None:
            return True
        # Move forward two from start (both squares must be empty)
        if dc == 0 and dr == 2 * direction and fr == start_row:
            mid_r = fr + direction
            if board.get_piece(mid_r, fc) is None and board.get_piece(tr, tc) is None:
                return True
        # Diagonal capture
        if abs(dc) == 1 and dr == direction:
            target = board.get_piece(tr, tc)
            return target is not None and target.color != self.color
        return False

    def symbol(self): return "P"

# --- Board ---

class Board:
    def __init__(self):
        self.grid: list[list[Piece | None]] = [[None] * 8 for _ in range(8)]
        self._setup()

    def _setup(self):
        # Black pieces (rows 0-1), White pieces (rows 6-7)
        order = [Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook]
        for col, PieceClass in enumerate(order):
            self.grid[0][col] = PieceClass(Color.BLACK)
            self.grid[7][col] = PieceClass(Color.WHITE)
        for col in range(8):
            self.grid[1][col] = Pawn(Color.BLACK)
            self.grid[6][col] = Pawn(Color.WHITE)

    def get_piece(self, row: int, col: int) -> Piece | None:
        return self.grid[row][col]

    def move_piece(self, fr: int, fc: int, tr: int, tc: int):
        self.grid[tr][tc] = self.grid[fr][fc]
        self.grid[fr][fc] = None

    def is_path_clear(self, fr: int, fc: int, tr: int, tc: int) -> bool:
        dr = 0 if tr == fr else (1 if tr > fr else -1)
        dc = 0 if tc == fc else (1 if tc > fc else -1)
        r, c = fr + dr, fc + dc
        while (r, c) != (tr, tc):
            if self.grid[r][c] is not None:
                return False
            r += dr
            c += dc
        return True

    def find_king(self, color: Color) -> tuple[int, int]:
        for r in range(8):
            for c in range(8):
                p = self.grid[r][c]
                if isinstance(p, King) and p.color == color:
                    return (r, c)
        raise ValueError("King not found")

    def is_under_attack(self, row: int, col: int, by_color: Color) -> bool:
        for r in range(8):
            for c in range(8):
                p = self.grid[r][c]
                if p and p.color == by_color and p.can_move(self, r, c, row, col):
                    return True
        return False

# --- Game ---

class Player:
    def __init__(self, name: str, color: Color):
        self.name = name
        self.color = color

class Game:
    def __init__(self, white: Player, black: Player):
        self.board = Board()
        self.players = {Color.WHITE: white, Color.BLACK: black}
        self.current_turn = Color.WHITE
        self.status = GameStatus.ACTIVE

    def make_move(self, fr: int, fc: int, tr: int, tc: int) -> bool:
        piece = self.board.get_piece(fr, fc)
        if not piece or piece.color != self.current_turn:
            print("Not your piece!")
            return False

        target = self.board.get_piece(tr, tc)
        if target and target.color == self.current_turn:
            print("Can't capture your own piece!")
            return False

        if not piece.can_move(self.board, fr, fc, tr, tc):
            print("Invalid move for this piece.")
            return False

        # Make the move, then check if it leaves our king in check
        self.board.move_piece(fr, fc, tr, tc)
        opponent = Color.BLACK if self.current_turn == Color.WHITE else Color.WHITE
        kr, kc = self.board.find_king(self.current_turn)

        if self.board.is_under_attack(kr, kc, opponent):
            # Undo -- can't leave own king in check
            self.board.move_piece(tr, tc, fr, fc)
            self.board.grid[tr][tc] = target
            print("Move leaves your king in check!")
            return False

        captured = f" (captured {target.symbol()})" if target else ""
        print(f"{piece.symbol()} {fr},{fc}{tr},{tc}{captured}")
        self.current_turn = opponent
        return True

# Usage
game = Game(Player("Alice", Color.WHITE), Player("Bob", Color.BLACK))
game.make_move(6, 4, 4, 4)  # White pawn e2 → e4
game.make_move(1, 4, 3, 4)  # Black pawn e7 → e5
game.make_move(7, 3, 3, 7)  # White queen d1 → h5
const Color = Object.freeze({ WHITE: "white", BLACK: "black" });

// --- Pieces ---
class Piece {
  constructor(color) { this.color = color; }
  canMove(board, fr, fc, tr, tc) { throw new Error("Override me"); }
}

class King extends Piece {
  canMove(board, fr, fc, tr, tc) {
    return Math.max(Math.abs(tr - fr), Math.abs(tc - fc)) === 1;
  }
  get symbol() { return "K"; }
}

class Queen extends Piece {
  canMove(board, fr, fc, tr, tc) {
    const [dr, dc] = [tr - fr, tc - fc];
    const straight = dr === 0 || dc === 0;
    const diagonal = Math.abs(dr) === Math.abs(dc);
    return (straight || diagonal) && board.isPathClear(fr, fc, tr, tc);
  }
  get symbol() { return "Q"; }
}

class Rook extends Piece {
  canMove(board, fr, fc, tr, tc) {
    const [dr, dc] = [tr - fr, tc - fc];
    return (dr === 0 || dc === 0) && board.isPathClear(fr, fc, tr, tc);
  }
  get symbol() { return "R"; }
}

class Bishop extends Piece {
  canMove(board, fr, fc, tr, tc) {
    return Math.abs(tr - fr) === Math.abs(tc - fc) && board.isPathClear(fr, fc, tr, tc);
  }
  get symbol() { return "B"; }
}

class Knight extends Piece {
  canMove(board, fr, fc, tr, tc) {
    const [dr, dc] = [Math.abs(tr - fr), Math.abs(tc - fc)];
    return (dr === 2 && dc === 1) || (dr === 1 && dc === 2);
  }
  get symbol() { return "N"; }
}

class Pawn extends Piece {
  canMove(board, fr, fc, tr, tc) {
    const dir = this.color === Color.WHITE ? -1 : 1;
    const startRow = this.color === Color.WHITE ? 6 : 1;
    const [dr, dc] = [tr - fr, tc - fc];

    if (dc === 0 && dr === dir && !board.getPiece(tr, tc)) return true;
    if (dc === 0 && dr === 2 * dir && fr === startRow
        && !board.getPiece(fr + dir, fc) && !board.getPiece(tr, tc)) return true;
    if (Math.abs(dc) === 1 && dr === dir) {
      const target = board.getPiece(tr, tc);
      return target !== null && target.color !== this.color;
    }
    return false;
  }
  get symbol() { return "P"; }
}

// --- Board ---
class Board {
  constructor() {
    this.grid = Array.from({ length: 8 }, () => Array(8).fill(null));
    this._setup();
  }

  _setup() {
    const order = [Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook];
    order.forEach((Cls, col) => {
      this.grid[0][col] = new Cls(Color.BLACK);
      this.grid[7][col] = new Cls(Color.WHITE);
    });
    for (let c = 0; c < 8; c++) {
      this.grid[1][c] = new Pawn(Color.BLACK);
      this.grid[6][c] = new Pawn(Color.WHITE);
    }
  }

  getPiece(r, c) { return this.grid[r][c]; }

  movePiece(fr, fc, tr, tc) {
    this.grid[tr][tc] = this.grid[fr][fc];
    this.grid[fr][fc] = null;
  }

  isPathClear(fr, fc, tr, tc) {
    const dr = tr === fr ? 0 : (tr > fr ? 1 : -1);
    const dc = tc === fc ? 0 : (tc > fc ? 1 : -1);
    let [r, c] = [fr + dr, fc + dc];
    while (r !== tr || c !== tc) {
      if (this.grid[r][c]) return false;
      r += dr; c += dc;
    }
    return true;
  }

  findKing(color) {
    for (let r = 0; r < 8; r++)
      for (let c = 0; c < 8; c++) {
        const p = this.grid[r][c];
        if (p instanceof King && p.color === color) return [r, c];
      }
  }

  isUnderAttack(row, col, byColor) {
    for (let r = 0; r < 8; r++)
      for (let c = 0; c < 8; c++) {
        const p = this.grid[r][c];
        if (p && p.color === byColor && p.canMove(this, r, c, row, col))
          return true;
      }
    return false;
  }
}

// --- Game ---
class Game {
  constructor(whiteName, blackName) {
    this.board = new Board();
    this.currentTurn = Color.WHITE;
  }

  makeMove(fr, fc, tr, tc) {
    const piece = this.board.getPiece(fr, fc);
    if (!piece || piece.color !== this.currentTurn) return false;

    const target = this.board.getPiece(tr, tc);
    if (target && target.color === this.currentTurn) return false;
    if (!piece.canMove(this.board, fr, fc, tr, tc)) return false;

    this.board.movePiece(fr, fc, tr, tc);
    const opponent = this.currentTurn === Color.WHITE ? Color.BLACK : Color.WHITE;
    const [kr, kc] = this.board.findKing(this.currentTurn);

    if (this.board.isUnderAttack(kr, kc, opponent)) {
      this.board.movePiece(tr, tc, fr, fc);
      this.board.grid[tr][tc] = target;
      return false;
    }

    console.log(`${piece.symbol} ${fr},${fc} → ${tr},${tc}`);
    this.currentTurn = opponent;
    return true;
  }
}

const game = new Game("Alice", "Bob");
game.makeMove(6, 4, 4, 4); // e2 → e4
game.makeMove(1, 4, 3, 4); // e7 → e5
import java.util.*;

enum Color { WHITE, BLACK }

abstract class Piece {
    final Color color;
    Piece(Color color) { this.color = color; }
    abstract boolean canMove(Board board, int fr, int fc, int tr, int tc);
    abstract String symbol();
}

class King extends Piece {
    King(Color c) { super(c); }
    boolean canMove(Board b, int fr, int fc, int tr, int tc) {
        return Math.max(Math.abs(tr - fr), Math.abs(tc - fc)) == 1;
    }
    String symbol() { return "K"; }
}

class Queen extends Piece {
    Queen(Color c) { super(c); }
    boolean canMove(Board b, int fr, int fc, int tr, int tc) {
        int dr = tr - fr, dc = tc - fc;
        boolean straight = dr == 0 || dc == 0;
        boolean diagonal = Math.abs(dr) == Math.abs(dc);
        return (straight || diagonal) && b.isPathClear(fr, fc, tr, tc);
    }
    String symbol() { return "Q"; }
}

class Rook extends Piece {
    Rook(Color c) { super(c); }
    boolean canMove(Board b, int fr, int fc, int tr, int tc) {
        return (tr - fr == 0 || tc - fc == 0) && b.isPathClear(fr, fc, tr, tc);
    }
    String symbol() { return "R"; }
}

class Bishop extends Piece {
    Bishop(Color c) { super(c); }
    boolean canMove(Board b, int fr, int fc, int tr, int tc) {
        return Math.abs(tr - fr) == Math.abs(tc - fc) && b.isPathClear(fr, fc, tr, tc);
    }
    String symbol() { return "B"; }
}

class Knight extends Piece {
    Knight(Color c) { super(c); }
    boolean canMove(Board b, int fr, int fc, int tr, int tc) {
        int dr = Math.abs(tr - fr), dc = Math.abs(tc - fc);
        return (dr == 2 && dc == 1) || (dr == 1 && dc == 2);
    }
    String symbol() { return "N"; }
}

class Pawn extends Piece {
    Pawn(Color c) { super(c); }
    boolean canMove(Board b, int fr, int fc, int tr, int tc) {
        int dir = color == Color.WHITE ? -1 : 1;
        int startRow = color == Color.WHITE ? 6 : 1;
        int dr = tr - fr, dc = tc - fc;

        if (dc == 0 && dr == dir && b.getPiece(tr, tc) == null) return true;
        if (dc == 0 && dr == 2 * dir && fr == startRow
            && b.getPiece(fr + dir, fc) == null && b.getPiece(tr, tc) == null) return true;
        if (Math.abs(dc) == 1 && dr == dir) {
            Piece target = b.getPiece(tr, tc);
            return target != null && target.color != this.color;
        }
        return false;
    }
    String symbol() { return "P"; }
}

class Board {
    Piece[][] grid = new Piece[8][8];

    Board() { setup(); }

    private void setup() {
        Piece[] blackBack = {new Rook(Color.BLACK), new Knight(Color.BLACK),
            new Bishop(Color.BLACK), new Queen(Color.BLACK), new King(Color.BLACK),
            new Bishop(Color.BLACK), new Knight(Color.BLACK), new Rook(Color.BLACK)};
        Piece[] whiteBack = {new Rook(Color.WHITE), new Knight(Color.WHITE),
            new Bishop(Color.WHITE), new Queen(Color.WHITE), new King(Color.WHITE),
            new Bishop(Color.WHITE), new Knight(Color.WHITE), new Rook(Color.WHITE)};
        grid[0] = blackBack;
        grid[7] = whiteBack;
        for (int c = 0; c < 8; c++) {
            grid[1][c] = new Pawn(Color.BLACK);
            grid[6][c] = new Pawn(Color.WHITE);
        }
    }

    Piece getPiece(int r, int c) { return grid[r][c]; }

    void movePiece(int fr, int fc, int tr, int tc) {
        grid[tr][tc] = grid[fr][fc];
        grid[fr][fc] = null;
    }

    boolean isPathClear(int fr, int fc, int tr, int tc) {
        int dr = Integer.compare(tr, fr);
        int dc = Integer.compare(tc, fc);
        int r = fr + dr, c = fc + dc;
        while (r != tr || c != tc) {
            if (grid[r][c] != null) return false;
            r += dr; c += dc;
        }
        return true;
    }

    int[] findKing(Color color) {
        for (int r = 0; r < 8; r++)
            for (int c = 0; c < 8; c++)
                if (grid[r][c] instanceof King && grid[r][c].color == color)
                    return new int[]{r, c};
        throw new RuntimeException("King not found");
    }

    boolean isUnderAttack(int row, int col, Color byColor) {
        for (int r = 0; r < 8; r++)
            for (int c = 0; c < 8; c++) {
                Piece p = grid[r][c];
                if (p != null && p.color == byColor && p.canMove(this, r, c, row, col))
                    return true;
            }
        return false;
    }
}

Design Patterns Used

Polymorphism (the star of the show) — Every piece overrides canMove(). The Game calls piece.canMove() without knowing or caring what type of piece it is. Adding a new piece type? Just create a new class and override canMove(). Zero changes to Game or Board.

Template Method (implicit) — The move validation follows a fixed sequence: check ownership → check destination → check piece movement → check king safety. The variable step is canMove() which each piece customizes.

Encapsulation — Board encapsulates grid operations. isPathClear() is a helper that pieces like Queen, Rook, and Bishop delegate to. Knight doesn’t need it because it jumps.

SRP — Each piece only knows its own movement rules. The Board handles grid operations. The Game handles turns, validation, and win detection.

Extensions

These are the follow-up questions that separate good from great:

“Add castling” — Track whether King and Rook have moved (add a hasMoved flag to Piece). Castling is valid only if: neither piece has moved, path is clear, king isn’t in check, and king doesn’t pass through check.

“Add en passant” — Track the last move made. If a pawn just moved two squares, the adjacent opponent pawn can capture it diagonally as if it only moved one square. Needs a lastMove field on Game.

“Add pawn promotion” — When a pawn reaches the opposite end (row 0 for white, row 7 for black), the player picks a new piece type (usually Queen). Add a promotePawn(row, col, PieceType) method to Game.

“Add game history and undo” — Use the Command pattern. Each move becomes a MoveCommand with execute() and undo(). Store captured pieces so we can restore them on undo. Memento pattern for full board state snapshots.

“Detect checkmate” — For each of the current player’s pieces, try every possible move. If no move results in the king NOT being in check, it’s checkmate. Brute force but correct.

In simple language, Chess is a polymorphism showpiece. One abstract Piece class, six subclasses, each with its own canMove(). The Board and Game don’t care which piece is which — they just call the method and trust the piece to know its own rules. That’s the whole point of OOP right there.


32

Design Library Management System

intermediate 2-4 YOE lld library-management design-question

Library Management is the classic CRUD-meets-business-rules LLD question. It sounds simple at first — add books, search, borrow, return. But then the interviewer asks about multiple copies, fines, reservations, and suddenly there’s real design thinking needed.

The key insight that makes or breaks this design: a Book is not the same as a BookItem. “Clean Code” is a Book. The three physical copies on the shelf are BookItems. Get this right and everything else falls into place.

Requirements

Functional:

  • Add, remove, and search books (by title, author, or ISBN)
  • Each book can have multiple physical copies (BookItems)
  • Members can borrow and return books
  • Maximum 5 books per member at a time
  • Loan period is 14 days. Late returns incur a fine ($1/day)
  • Members can reserve a book that’s currently loaned out
  • Notify members when their reserved book becomes available

Constraints:

  • Members must have an active account to borrow
  • A BookItem can only be loaned to one member at a time
  • Librarians can add/remove books and manage members

Key Classes & Relationships

Library Management — Class Diagram
Library
- books: Map<ISBN, Book>
- members: Map<id, Member>
+ searchByTitle(title)
+ borrowBook(memberId, isbn)
+ returnBook(memberId, itemId)
│ manages
Book
- isbn: string
- title: string
- author: string
- items: BookItem[]
Member
- id: string
- name: string
- activeLoans: Loan[]
- totalFines: float
Book has many ↓       Member has many ↓
BookItem
- barcode: string
- status: BookStatus
- isbn: string (→ Book)
Loan
- item: BookItem
- member: Member
- borrowDate: Date
- dueDate: Date
- returnDate: Date?

The Book vs BookItem distinction is the most important modeling decision. A Book is the catalog entry (metadata). A BookItem is a physical copy that can be loaned, returned, lost. One Book can have 5 BookItems, each with its own status.

Core Implementation

from enum import Enum
from datetime import datetime, timedelta

class BookStatus(Enum):
    AVAILABLE = "available"
    LOANED = "loaned"
    RESERVED = "reserved"
    LOST = "lost"

# --- Core Models ---

class BookItem:
    def __init__(self, barcode: str, isbn: str):
        self.barcode = barcode
        self.isbn = isbn
        self.status = BookStatus.AVAILABLE

class Book:
    def __init__(self, isbn: str, title: str, author: str):
        self.isbn = isbn
        self.title = title
        self.author = author
        self.items: list[BookItem] = []

    def add_item(self, barcode: str) -> BookItem:
        item = BookItem(barcode, self.isbn)
        self.items.append(item)
        return item

    def get_available_item(self) -> BookItem | None:
        for item in self.items:
            if item.status == BookStatus.AVAILABLE:
                return item
        return None

class Loan:
    LOAN_DAYS = 14
    FINE_PER_DAY = 1.0

    def __init__(self, item: BookItem, member_id: str):
        self.item = item
        self.member_id = member_id
        self.borrow_date = datetime.now()
        self.due_date = self.borrow_date + timedelta(days=self.LOAN_DAYS)
        self.return_date: datetime | None = None

    def calculate_fine(self) -> float:
        end = self.return_date or datetime.now()
        if end <= self.due_date:
            return 0.0
        overdue_days = (end - self.due_date).days
        return overdue_days * self.FINE_PER_DAY

class Member:
    MAX_LOANS = 5

    def __init__(self, member_id: str, name: str):
        self.member_id = member_id
        self.name = name
        self.active_loans: list[Loan] = []
        self.total_fines = 0.0

    def can_borrow(self) -> bool:
        return len(self.active_loans) < self.MAX_LOANS

# --- Library (Facade) ---

class Library:
    def __init__(self):
        self.books: dict[str, Book] = {}       # isbn → Book
        self.members: dict[str, Member] = {}   # id → Member
        self.observers: dict[str, list[str]] = {}  # isbn → [member_ids waiting]

    def add_book(self, isbn: str, title: str, author: str) -> Book:
        if isbn not in self.books:
            self.books[isbn] = Book(isbn, title, author)
        return self.books[isbn]

    def add_member(self, member_id: str, name: str) -> Member:
        member = Member(member_id, name)
        self.members[member_id] = member
        return member

    def search_by_title(self, query: str) -> list[Book]:
        query = query.lower()
        return [b for b in self.books.values() if query in b.title.lower()]

    def search_by_author(self, query: str) -> list[Book]:
        query = query.lower()
        return [b for b in self.books.values() if query in b.author.lower()]

    def borrow_book(self, member_id: str, isbn: str) -> Loan | None:
        member = self.members.get(member_id)
        book = self.books.get(isbn)

        if not member or not book:
            print("Member or book not found.")
            return None
        if not member.can_borrow():
            print(f"{member.name} has reached max loans ({Member.MAX_LOANS}).")
            return None

        item = book.get_available_item()
        if not item:
            print(f"No copies available for '{book.title}'.")
            return None

        item.status = BookStatus.LOANED
        loan = Loan(item, member_id)
        member.active_loans.append(loan)
        print(f"{member.name} borrowed '{book.title}' (due: {loan.due_date.date()})")
        return loan

    def return_book(self, member_id: str, barcode: str) -> float:
        member = self.members.get(member_id)
        if not member:
            print("Member not found.")
            return 0.0

        loan = next((l for l in member.active_loans if l.item.barcode == barcode), None)
        if not loan:
            print("No active loan found for this item.")
            return 0.0

        loan.return_date = datetime.now()
        fine = loan.calculate_fine()
        member.total_fines += fine
        loan.item.status = BookStatus.AVAILABLE
        member.active_loans.remove(loan)

        isbn = loan.item.isbn
        print(f"Returned '{self.books[isbn].title}'. Fine: ${fine:.2f}")

        # Notify waiting members
        self._notify_reservation(isbn)
        return fine

    def reserve_book(self, member_id: str, isbn: str):
        if isbn not in self.observers:
            self.observers[isbn] = []
        self.observers[isbn].append(member_id)
        print(f"Member {member_id} added to waitlist for ISBN {isbn}")

    def _notify_reservation(self, isbn: str):
        if isbn in self.observers and self.observers[isbn]:
            book = self.books[isbn]
            if book.get_available_item():
                next_member_id = self.observers[isbn].pop(0)
                member = self.members.get(next_member_id)
                if member:
                    print(f"  Notification: {member.name}, '{book.title}' is now available!")

# Usage
lib = Library()

book = lib.add_book("978-0132350884", "Clean Code", "Robert C. Martin")
book.add_item("CC-001")
book.add_item("CC-002")

alice = lib.add_member("M001", "Alice")
bob = lib.add_member("M002", "Bob")

lib.borrow_book("M001", "978-0132350884")  # Alice borrows copy 1
lib.borrow_book("M002", "978-0132350884")  # Bob borrows copy 2
lib.return_book("M001", "CC-001")           # Alice returns
const BookStatus = Object.freeze({
  AVAILABLE: "available",
  LOANED: "loaned",
  RESERVED: "reserved",
  LOST: "lost",
});

class BookItem {
  constructor(barcode, isbn) {
    this.barcode = barcode;
    this.isbn = isbn;
    this.status = BookStatus.AVAILABLE;
  }
}

class Book {
  constructor(isbn, title, author) {
    this.isbn = isbn;
    this.title = title;
    this.author = author;
    this.items = [];
  }
  addItem(barcode) {
    const item = new BookItem(barcode, this.isbn);
    this.items.push(item);
    return item;
  }
  getAvailableItem() {
    return this.items.find((i) => i.status === BookStatus.AVAILABLE) || null;
  }
}

class Loan {
  static LOAN_DAYS = 14;
  static FINE_PER_DAY = 1.0;

  constructor(item, memberId) {
    this.item = item;
    this.memberId = memberId;
    this.borrowDate = new Date();
    this.dueDate = new Date(Date.now() + Loan.LOAN_DAYS * 86400000);
    this.returnDate = null;
  }

  calculateFine() {
    const end = this.returnDate || new Date();
    if (end <= this.dueDate) return 0;
    const overdueDays = Math.ceil((end - this.dueDate) / 86400000);
    return overdueDays * Loan.FINE_PER_DAY;
  }
}

class Member {
  static MAX_LOANS = 5;
  constructor(id, name) {
    this.id = id;
    this.name = name;
    this.activeLoans = [];
    this.totalFines = 0;
  }
  canBorrow() { return this.activeLoans.length < Member.MAX_LOANS; }
}

class Library {
  constructor() {
    this.books = new Map();    // isbn → Book
    this.members = new Map();  // id → Member
    this.waitlists = new Map(); // isbn → [memberIds]
  }

  addBook(isbn, title, author) {
    if (!this.books.has(isbn)) this.books.set(isbn, new Book(isbn, title, author));
    return this.books.get(isbn);
  }

  addMember(id, name) {
    const m = new Member(id, name);
    this.members.set(id, m);
    return m;
  }

  searchByTitle(query) {
    const q = query.toLowerCase();
    return [...this.books.values()].filter((b) => b.title.toLowerCase().includes(q));
  }

  borrowBook(memberId, isbn) {
    const member = this.members.get(memberId);
    const book = this.books.get(isbn);
    if (!member || !book) return null;
    if (!member.canBorrow()) { console.log("Max loans reached."); return null; }

    const item = book.getAvailableItem();
    if (!item) { console.log(`No copies of '${book.title}' available.`); return null; }

    item.status = BookStatus.LOANED;
    const loan = new Loan(item, memberId);
    member.activeLoans.push(loan);
    console.log(`${member.name} borrowed '${book.title}'`);
    return loan;
  }

  returnBook(memberId, barcode) {
    const member = this.members.get(memberId);
    if (!member) return 0;
    const idx = member.activeLoans.findIndex((l) => l.item.barcode === barcode);
    if (idx === -1) return 0;

    const loan = member.activeLoans.splice(idx, 1)[0];
    loan.returnDate = new Date();
    const fine = loan.calculateFine();
    member.totalFines += fine;
    loan.item.status = BookStatus.AVAILABLE;

    const book = this.books.get(loan.item.isbn);
    console.log(`Returned '${book.title}'. Fine: $${fine.toFixed(2)}`);
    this._notifyWaitlist(loan.item.isbn);
    return fine;
  }

  _notifyWaitlist(isbn) {
    const list = this.waitlists.get(isbn);
    if (!list || list.length === 0) return;
    const book = this.books.get(isbn);
    if (book.getAvailableItem()) {
      const nextId = list.shift();
      const m = this.members.get(nextId);
      if (m) console.log(`  Notification: ${m.name}, '${book.title}' is available!`);
    }
  }
}

// Usage
const lib = new Library();
const book = lib.addBook("978-0132350884", "Clean Code", "Robert C. Martin");
book.addItem("CC-001");
lib.addMember("M001", "Alice");
lib.borrowBook("M001", "978-0132350884");
lib.returnBook("M001", "CC-001");
import java.util.*;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

enum BookStatus { AVAILABLE, LOANED, RESERVED, LOST }

class BookItem {
    String barcode, isbn;
    BookStatus status = BookStatus.AVAILABLE;
    BookItem(String barcode, String isbn) { this.barcode = barcode; this.isbn = isbn; }
}

class Book {
    String isbn, title, author;
    List<BookItem> items = new ArrayList<>();

    Book(String isbn, String title, String author) {
        this.isbn = isbn; this.title = title; this.author = author;
    }
    BookItem addItem(String barcode) {
        BookItem item = new BookItem(barcode, isbn);
        items.add(item);
        return item;
    }
    BookItem getAvailableItem() {
        return items.stream().filter(i -> i.status == BookStatus.AVAILABLE)
                    .findFirst().orElse(null);
    }
}

class Loan {
    static final int LOAN_DAYS = 14;
    static final double FINE_PER_DAY = 1.0;

    BookItem item;
    String memberId;
    LocalDate borrowDate, dueDate;
    LocalDate returnDate;

    Loan(BookItem item, String memberId) {
        this.item = item;
        this.memberId = memberId;
        this.borrowDate = LocalDate.now();
        this.dueDate = borrowDate.plusDays(LOAN_DAYS);
    }

    double calculateFine() {
        LocalDate end = returnDate != null ? returnDate : LocalDate.now();
        if (!end.isAfter(dueDate)) return 0;
        long overdue = ChronoUnit.DAYS.between(dueDate, end);
        return overdue * FINE_PER_DAY;
    }
}

class Member {
    static final int MAX_LOANS = 5;
    String id, name;
    List<Loan> activeLoans = new ArrayList<>();
    double totalFines = 0;

    Member(String id, String name) { this.id = id; this.name = name; }
    boolean canBorrow() { return activeLoans.size() < MAX_LOANS; }
}

class Library {
    Map<String, Book> books = new HashMap<>();
    Map<String, Member> members = new HashMap<>();
    Map<String, Queue<String>> waitlists = new HashMap<>();

    Book addBook(String isbn, String title, String author) {
        books.putIfAbsent(isbn, new Book(isbn, title, author));
        return books.get(isbn);
    }

    Member addMember(String id, String name) {
        Member m = new Member(id, name);
        members.put(id, m);
        return m;
    }

    Loan borrowBook(String memberId, String isbn) {
        Member member = members.get(memberId);
        Book book = books.get(isbn);
        if (member == null || book == null) return null;
        if (!member.canBorrow()) return null;

        BookItem item = book.getAvailableItem();
        if (item == null) {
            System.out.println("No copies available.");
            return null;
        }
        item.status = BookStatus.LOANED;
        Loan loan = new Loan(item, memberId);
        member.activeLoans.add(loan);
        System.out.println(member.name + " borrowed '" + book.title + "'");
        return loan;
    }

    double returnBook(String memberId, String barcode) {
        Member member = members.get(memberId);
        if (member == null) return 0;

        Loan loan = member.activeLoans.stream()
            .filter(l -> l.item.barcode.equals(barcode))
            .findFirst().orElse(null);
        if (loan == null) return 0;

        loan.returnDate = LocalDate.now();
        double fine = loan.calculateFine();
        member.totalFines += fine;
        loan.item.status = BookStatus.AVAILABLE;
        member.activeLoans.remove(loan);
        System.out.println("Returned. Fine: $" + fine);
        return fine;
    }
}

Design Patterns Used

Facade — The Library class is a facade over the entire system. Members don’t interact with BookItem or Loan directly. They call library.borrowBook() and the library handles all the internal coordination — finding available copies, creating loans, updating statuses.

Observer — The reservation/waitlist system. When a book is returned, the library checks if anyone is waiting and notifies them. We store a list of member IDs per ISBN. When a copy becomes available, we pop the first waiter and notify them.

Composition — Book HAS BookItems. Member HAS Loans. Library HAS Books and Members. Everything is connected through references, not inheritance.

Value Objects — Loan captures a point-in-time transaction. It has a borrow date, due date, and optional return date. The fine calculation is derived from these dates. Clean and testable.

Extensions

“Add a reservation system” — When no copies are available, a member can reserve. We already have the waitlist. Extend it so the next available copy gets status RESERVED and is held for 24 hours.

“Support renewal” — Add a renewLoan(memberId, barcode) method. Extend the due date by another 14 days. But only if: no one has reserved it, and the member hasn’t already renewed once.

“Different member tiers” — Gold members get 10 books, Silver gets 7, Basic gets 5. Use a MemberTier enum and look up the limit. Or use Strategy pattern where each tier defines its own borrowing rules.

“Support digital books (e-books)” — This is where we might introduce an interface like Borrowable that both BookItem and DigitalBook implement. Digital books don’t have a barcode or physical status, but they still have loan limits.

“Search with multiple filters” — Add a SearchCriteria builder: search().byTitle("clean").byAuthor("martin").available().execute(). This is a nice spot for the Builder pattern.

In simple language, the Library question is really about modeling the right entities. The moment we separate Book (catalog entry) from BookItem (physical copy), everything clicks. One Book, many copies. Each copy has its own lifecycle. The Library facade ties it all together so nobody has to juggle the internals.


33

Design ATM Machine

advanced 4-7 YOE lld atm design-question state-pattern

The ATM question is an interviewer favorite because it naturally maps to the State pattern. An ATM behaves completely differently depending on what step we’re at: idle and waiting for a card, authenticating a PIN, showing the main menu, dispensing cash. Same machine, wildly different behavior based on state.

If we did the State pattern note earlier, this is where we put it to work in a real design.

Requirements

Functional:

  • Insert card and authenticate with PIN (3 attempts max)
  • Check balance
  • Withdraw cash (must have sufficient balance, ATM must have enough cash)
  • Deposit cash
  • Transfer between accounts
  • Print receipt after each transaction
  • Eject card and return to idle

Constraints:

  • Daily withdrawal limit of $5,000 per card
  • ATM has finite cash in its dispenser
  • Card gets retained after 3 failed PIN attempts
  • Each operation is a transaction that gets logged

Key Classes & Relationships

ATM — Class Diagram
ATM
- state: ATMState
- cashDispenser: CashDispenser
- currentCard: Card?
- currentAccount: Account?
+ insertCard(card)
+ enterPin(pin)
+ selectOption(option)
+ ejectCard()
│ delegates to current state
ATMState (interface)
+ insertCard(atm, card)
+ enterPin(atm, pin)
+ selectOption(atm, option)
+ ejectCard(atm)
│ implemented by
IdleState
AuthState
MenuState
TransactionState
ATM also has ↓
CashDispenser
- cashAvailable: float
+ dispense(amount)
+ deposit(amount)
Transaction
- type: TransactionType
- amount: float
- timestamp: Date
- status: success/failed
Account
- id: string
- balance: float
- dailyWithdrawn: float

The ATM delegates ALL user actions to its current state. Each state knows which actions make sense and which don’t. Inserting a card in idle? Great, move to auth. Inserting a card while already authenticated? Nope, ignore it.

Core Implementation

from abc import ABC, abstractmethod
from enum import Enum
from datetime import datetime

class TransactionType(Enum):
    WITHDRAWAL = "withdrawal"
    DEPOSIT = "deposit"
    BALANCE_CHECK = "balance_check"
    TRANSFER = "transfer"

# --- Supporting Classes ---

class Account:
    DAILY_LIMIT = 5000.0

    def __init__(self, account_id: str, balance: float):
        self.account_id = account_id
        self.balance = balance
        self.daily_withdrawn = 0.0

    def can_withdraw(self, amount: float) -> bool:
        return (self.balance >= amount
                and self.daily_withdrawn + amount <= self.DAILY_LIMIT)

class Card:
    def __init__(self, card_number: str, pin: str, account: Account):
        self.card_number = card_number
        self.pin = pin
        self.account = account

class CashDispenser:
    def __init__(self, initial_cash: float):
        self.cash_available = initial_cash

    def can_dispense(self, amount: float) -> bool:
        return self.cash_available >= amount

    def dispense(self, amount: float):
        self.cash_available -= amount
        print(f"  Dispensing ${amount:.2f}... (ATM has ${self.cash_available:.2f} left)")

    def deposit(self, amount: float):
        self.cash_available += amount

class Transaction:
    def __init__(self, tx_type: TransactionType, amount: float, account_id: str):
        self.tx_type = tx_type
        self.amount = amount
        self.account_id = account_id
        self.timestamp = datetime.now()
        self.success = False

    def __str__(self):
        status = "SUCCESS" if self.success else "FAILED"
        return f"[{self.timestamp:%H:%M}] {self.tx_type.value} ${self.amount:.2f} - {status}"

# --- State Pattern ---

class ATMState(ABC):
    @abstractmethod
    def insert_card(self, atm, card: Card): pass
    @abstractmethod
    def enter_pin(self, atm, pin: str): pass
    @abstractmethod
    def select_option(self, atm, option: str, amount: float = 0): pass
    @abstractmethod
    def eject_card(self, atm): pass

class IdleState(ATMState):
    def insert_card(self, atm, card):
        atm.current_card = card
        atm.pin_attempts = 0
        print(f"Card {card.card_number} inserted. Enter your PIN.")
        atm.set_state(AuthState())

    def enter_pin(self, atm, pin):
        print("Please insert a card first.")

    def select_option(self, atm, option, amount=0):
        print("Please insert a card first.")

    def eject_card(self, atm):
        print("No card to eject.")

class AuthState(ATMState):
    MAX_ATTEMPTS = 3

    def insert_card(self, atm, card):
        print("Card already inserted.")

    def enter_pin(self, atm, pin):
        if atm.current_card.pin == pin:
            atm.current_account = atm.current_card.account
            print("PIN correct. Welcome!")
            atm.set_state(MenuState())
        else:
            atm.pin_attempts += 1
            remaining = self.MAX_ATTEMPTS - atm.pin_attempts
            if remaining <= 0:
                print("Too many failed attempts. Card retained.")
                atm.current_card = None
                atm.set_state(IdleState())
            else:
                print(f"Wrong PIN. {remaining} attempt(s) left.")

    def select_option(self, atm, option, amount=0):
        print("Please enter your PIN first.")

    def eject_card(self, atm):
        print("Card ejected.")
        atm.current_card = None
        atm.set_state(IdleState())

class MenuState(ATMState):
    def insert_card(self, atm, card):
        print("Session in progress.")

    def enter_pin(self, atm, pin):
        print("Already authenticated.")

    def select_option(self, atm, option, amount=0):
        if option == "balance":
            print(f"  Your balance: ${atm.current_account.balance:.2f}")
            tx = Transaction(TransactionType.BALANCE_CHECK, 0, atm.current_account.account_id)
            tx.success = True
            atm.transactions.append(tx)

        elif option == "withdraw":
            atm.set_state(TransactionState())
            atm.state.process_withdrawal(atm, amount)

        elif option == "deposit":
            atm.set_state(TransactionState())
            atm.state.process_deposit(atm, amount)

        elif option == "eject":
            atm.eject_card()

        else:
            print("Invalid option.")

    def eject_card(self, atm):
        print("Card ejected. Goodbye!")
        atm.current_card = None
        atm.current_account = None
        atm.set_state(IdleState())

class TransactionState(ATMState):
    def insert_card(self, atm, card):
        print("Transaction in progress.")

    def enter_pin(self, atm, pin):
        print("Transaction in progress.")

    def select_option(self, atm, option, amount=0):
        print("Transaction in progress. Please wait.")

    def eject_card(self, atm):
        print("Cannot eject during transaction.")

    def process_withdrawal(self, atm, amount: float):
        account = atm.current_account
        tx = Transaction(TransactionType.WITHDRAWAL, amount, account.account_id)

        if not account.can_withdraw(amount):
            print(f"  Cannot withdraw ${amount:.2f} (balance: ${account.balance:.2f}, "
                  f"daily used: ${account.daily_withdrawn:.2f})")
            atm.transactions.append(tx)
            atm.set_state(MenuState())
            return

        if not atm.cash_dispenser.can_dispense(amount):
            print("  ATM doesn't have enough cash.")
            atm.transactions.append(tx)
            atm.set_state(MenuState())
            return

        account.balance -= amount
        account.daily_withdrawn += amount
        atm.cash_dispenser.dispense(amount)
        tx.success = True
        atm.transactions.append(tx)
        print(f"  New balance: ${account.balance:.2f}")
        atm.set_state(MenuState())

    def process_deposit(self, atm, amount: float):
        account = atm.current_account
        tx = Transaction(TransactionType.DEPOSIT, amount, account.account_id)
        account.balance += amount
        atm.cash_dispenser.deposit(amount)
        tx.success = True
        atm.transactions.append(tx)
        print(f"  Deposited ${amount:.2f}. New balance: ${account.balance:.2f}")
        atm.set_state(MenuState())

# --- ATM (Context) ---

class ATM:
    def __init__(self, cash: float):
        self.cash_dispenser = CashDispenser(cash)
        self.state: ATMState = IdleState()
        self.current_card: Card | None = None
        self.current_account: Account | None = None
        self.pin_attempts = 0
        self.transactions: list[Transaction] = []

    def set_state(self, state: ATMState):
        self.state = state

    def insert_card(self, card: Card):
        self.state.insert_card(self, card)

    def enter_pin(self, pin: str):
        self.state.enter_pin(self, pin)

    def select_option(self, option: str, amount: float = 0):
        self.state.select_option(self, option, amount)

    def eject_card(self):
        self.state.eject_card(self)

    def print_receipt(self):
        if self.transactions:
            print("\n--- Receipt ---")
            for tx in self.transactions[-3:]:  # last 3 transactions
                print(f"  {tx}")
            print("---------------")

# Usage
account = Account("ACC-001", 10000.0)
card = Card("4111-1111-1111-1111", "1234", account)
atm = ATM(50000.0)

atm.insert_card(card)             # Card inserted. Enter your PIN.
atm.enter_pin("0000")            # Wrong PIN. 2 attempt(s) left.
atm.enter_pin("1234")            # PIN correct. Welcome!
atm.select_option("balance")     # Your balance: $10000.00
atm.select_option("withdraw", 500)  # Dispensing $500.00...
atm.select_option("deposit", 200)   # Deposited $200.00.
atm.print_receipt()
atm.select_option("eject")       # Card ejected. Goodbye!
const TransactionType = Object.freeze({
  WITHDRAWAL: "withdrawal",
  DEPOSIT: "deposit",
  BALANCE_CHECK: "balance_check",
});

class Account {
  static DAILY_LIMIT = 5000;
  constructor(id, balance) {
    this.id = id;
    this.balance = balance;
    this.dailyWithdrawn = 0;
  }
  canWithdraw(amount) {
    return this.balance >= amount && this.dailyWithdrawn + amount <= Account.DAILY_LIMIT;
  }
}

class Card {
  constructor(number, pin, account) {
    this.number = number;
    this.pin = pin;
    this.account = account;
  }
}

class CashDispenser {
  constructor(cash) { this.cashAvailable = cash; }
  canDispense(amount) { return this.cashAvailable >= amount; }
  dispense(amount) {
    this.cashAvailable -= amount;
    console.log(`  Dispensing $${amount}...`);
  }
  deposit(amount) { this.cashAvailable += amount; }
}

class Transaction {
  constructor(type, amount, accountId) {
    this.type = type;
    this.amount = amount;
    this.accountId = accountId;
    this.timestamp = new Date();
    this.success = false;
  }
}

// --- States ---
class IdleState {
  insertCard(atm, card) {
    atm.currentCard = card;
    atm.pinAttempts = 0;
    console.log("Card inserted. Enter PIN.");
    atm.setState(new AuthState());
  }
  enterPin(atm, pin) { console.log("Insert a card first."); }
  selectOption(atm) { console.log("Insert a card first."); }
  ejectCard(atm) { console.log("No card to eject."); }
}

class AuthState {
  static MAX_ATTEMPTS = 3;
  insertCard(atm) { console.log("Card already inserted."); }
  enterPin(atm, pin) {
    if (atm.currentCard.pin === pin) {
      atm.currentAccount = atm.currentCard.account;
      console.log("PIN correct. Welcome!");
      atm.setState(new MenuState());
    } else {
      atm.pinAttempts++;
      const left = AuthState.MAX_ATTEMPTS - atm.pinAttempts;
      if (left <= 0) {
        console.log("Card retained. Too many failures.");
        atm.currentCard = null;
        atm.setState(new IdleState());
      } else {
        console.log(`Wrong PIN. ${left} attempt(s) left.`);
      }
    }
  }
  selectOption(atm) { console.log("Enter PIN first."); }
  ejectCard(atm) {
    console.log("Card ejected.");
    atm.currentCard = null;
    atm.setState(new IdleState());
  }
}

class MenuState {
  insertCard(atm) { console.log("Session in progress."); }
  enterPin(atm) { console.log("Already authenticated."); }
  selectOption(atm, option, amount = 0) {
    const account = atm.currentAccount;
    if (option === "balance") {
      console.log(`  Balance: $${account.balance}`);
    } else if (option === "withdraw") {
      if (!account.canWithdraw(amount)) { console.log("  Insufficient funds/limit."); return; }
      if (!atm.cashDispenser.canDispense(amount)) { console.log("  ATM low on cash."); return; }
      account.balance -= amount;
      account.dailyWithdrawn += amount;
      atm.cashDispenser.dispense(amount);
      console.log(`  New balance: $${account.balance}`);
    } else if (option === "deposit") {
      account.balance += amount;
      atm.cashDispenser.deposit(amount);
      console.log(`  Deposited $${amount}. Balance: $${account.balance}`);
    } else if (option === "eject") {
      atm.ejectCard();
    }
  }
  ejectCard(atm) {
    console.log("Card ejected. Goodbye!");
    atm.currentCard = null;
    atm.currentAccount = null;
    atm.setState(new IdleState());
  }
}

// --- ATM ---
class ATM {
  constructor(cash) {
    this.cashDispenser = new CashDispenser(cash);
    this.state = new IdleState();
    this.currentCard = null;
    this.currentAccount = null;
    this.pinAttempts = 0;
  }
  setState(s) { this.state = s; }
  insertCard(card) { this.state.insertCard(this, card); }
  enterPin(pin) { this.state.enterPin(this, pin); }
  selectOption(opt, amt) { this.state.selectOption(this, opt, amt); }
  ejectCard() { this.state.ejectCard(this); }
}

// Usage
const acc = new Account("ACC-001", 10000);
const card = new Card("4111-XXXX", "1234", acc);
const atm = new ATM(50000);

atm.insertCard(card);
atm.enterPin("1234");
atm.selectOption("balance");
atm.selectOption("withdraw", 500);
atm.selectOption("eject");
import java.util.*;

enum TransactionType { WITHDRAWAL, DEPOSIT, BALANCE_CHECK }

class Account {
    static final double DAILY_LIMIT = 5000;
    String id;
    double balance, dailyWithdrawn;

    Account(String id, double balance) { this.id = id; this.balance = balance; }
    boolean canWithdraw(double amount) {
        return balance >= amount && dailyWithdrawn + amount <= DAILY_LIMIT;
    }
}

class Card {
    String number, pin;
    Account account;
    Card(String number, String pin, Account account) {
        this.number = number; this.pin = pin; this.account = account;
    }
}

class CashDispenser {
    double cashAvailable;
    CashDispenser(double cash) { this.cashAvailable = cash; }
    boolean canDispense(double amount) { return cashAvailable >= amount; }
    void dispense(double amount) {
        cashAvailable -= amount;
        System.out.println("  Dispensing $" + amount);
    }
    void deposit(double amount) { cashAvailable += amount; }
}

// --- State Interface ---
interface ATMState {
    void insertCard(ATM atm, Card card);
    void enterPin(ATM atm, String pin);
    void selectOption(ATM atm, String option, double amount);
    void ejectCard(ATM atm);
}

class IdleState implements ATMState {
    public void insertCard(ATM atm, Card card) {
        atm.currentCard = card;
        atm.pinAttempts = 0;
        System.out.println("Card inserted. Enter PIN.");
        atm.setState(new AuthState());
    }
    public void enterPin(ATM atm, String pin) { System.out.println("Insert card first."); }
    public void selectOption(ATM atm, String opt, double amt) { System.out.println("Insert card first."); }
    public void ejectCard(ATM atm) { System.out.println("No card."); }
}

class AuthState implements ATMState {
    static final int MAX_ATTEMPTS = 3;
    public void insertCard(ATM atm, Card card) { System.out.println("Card already in."); }
    public void enterPin(ATM atm, String pin) {
        if (atm.currentCard.pin.equals(pin)) {
            atm.currentAccount = atm.currentCard.account;
            System.out.println("PIN correct. Welcome!");
            atm.setState(new MenuState());
        } else {
            atm.pinAttempts++;
            int left = MAX_ATTEMPTS - atm.pinAttempts;
            if (left <= 0) {
                System.out.println("Card retained.");
                atm.currentCard = null;
                atm.setState(new IdleState());
            } else {
                System.out.println("Wrong PIN. " + left + " left.");
            }
        }
    }
    public void selectOption(ATM atm, String opt, double amt) { System.out.println("Enter PIN first."); }
    public void ejectCard(ATM atm) {
        atm.currentCard = null;
        atm.setState(new IdleState());
        System.out.println("Card ejected.");
    }
}

class MenuState implements ATMState {
    public void insertCard(ATM atm, Card card) { System.out.println("Session active."); }
    public void enterPin(ATM atm, String pin) { System.out.println("Already authenticated."); }
    public void selectOption(ATM atm, String option, double amount) {
        Account acc = atm.currentAccount;
        switch (option) {
            case "balance":
                System.out.println("  Balance: $" + acc.balance);
                break;
            case "withdraw":
                if (!acc.canWithdraw(amount)) { System.out.println("  Insufficient funds/limit."); return; }
                if (!atm.cashDispenser.canDispense(amount)) { System.out.println("  ATM low."); return; }
                acc.balance -= amount;
                acc.dailyWithdrawn += amount;
                atm.cashDispenser.dispense(amount);
                System.out.println("  New balance: $" + acc.balance);
                break;
            case "deposit":
                acc.balance += amount;
                atm.cashDispenser.deposit(amount);
                System.out.println("  Deposited. Balance: $" + acc.balance);
                break;
            case "eject":
                atm.ejectCard();
                break;
        }
    }
    public void ejectCard(ATM atm) {
        System.out.println("Goodbye!");
        atm.currentCard = null;
        atm.currentAccount = null;
        atm.setState(new IdleState());
    }
}

class ATM {
    CashDispenser cashDispenser;
    ATMState state;
    Card currentCard;
    Account currentAccount;
    int pinAttempts;

    ATM(double cash) {
        this.cashDispenser = new CashDispenser(cash);
        this.state = new IdleState();
    }
    void setState(ATMState s) { this.state = s; }
    void insertCard(Card card) { state.insertCard(this, card); }
    void enterPin(String pin) { state.enterPin(this, pin); }
    void selectOption(String opt, double amt) { state.selectOption(this, opt, amt); }
    void ejectCard() { state.ejectCard(this); }
}

Design Patterns Used

State Pattern (the core) — This is THE pattern for ATMs. The ATM delegates every action to its current state object. IdleState only accepts card insertion. AuthState only accepts PIN entry. MenuState handles option selection. Each state manages its own transitions. No massive if-else chain in the ATM class.

Facade — The ATM is a facade over the cash dispenser, card reader, and account system. The user doesn’t interact with CashDispenser directly — they call atm.selectOption("withdraw", 500) and the ATM coordinates everything behind the scenes.

Command Pattern (for transactions) — Each Transaction is a record of what happened. In a more advanced version, we’d make transactions into command objects with execute() and rollback() methods. If dispensing cash fails after debiting the account, we’d roll back the debit.

SRP at work:

  • CashDispenser — only knows about physical cash
  • Account — only knows about balances and limits
  • ATMState implementations — only know about behavior for that state
  • ATM — just delegates and holds shared context

Extensions

“Add multi-currency support” — The CashDispenser holds multiple denominations. We’d add a DenominationStrategy that figures out how to make up the requested amount (e.g., 3x $100 + 1x $50 + 2x $20 + 1x $10 = $500). This is actually a greedy algorithm problem.

“Add daily withdrawal limit tracking” — We already have daily_withdrawn on Account. In production, this resets at midnight. We’d add a last_withdrawal_date field and reset daily_withdrawn if the date has changed.

“Add receipt printing” — Create a ReceiptPrinter class with a print(transaction) method. The ATM calls it after each successful transaction. Nice spot for the Observer pattern — register the printer as a listener for transaction events.

“Add admin mode” — A special state (AdminState) that a technician enters with a service card. Allows refilling cash, viewing transaction logs, and resetting the machine. Just another state in our state machine.

“Handle hardware failures” — What if the cash dispenser jams mid-withdrawal? This is where the Command pattern really shines. The withdrawal command would: 1) debit account, 2) dispense cash. If step 2 fails, step 1 gets rolled back. Each step is reversible.

In simple language, the ATM is a state machine wearing a trench coat. Every button press gets routed to whatever state the machine is in. The state decides what to do and what state comes next. Adding a new flow (like transfers) means adding a new state class — nothing else changes. That’s the beauty of the State pattern in action.