72 min read

Low Level Design: A Complete Curriculum

For engineers who build real things and want to understand why the patterns exist


This curriculum assumes you know JavaScript well. It does not assume you know CS theory. Every concept here will be explained at the layer below the abstraction — the physical reason before the name, the problem before the solution. By the end, you won't recall these patterns. You'll derive them.


Chapter 1: OOP From Scratch — For Someone Who Only Knows Functions

You already know something the theory books will never teach you: how to build a real product. You've shipped code. You've felt the pain of a codebase that worked for three months and then became impossible to change. That pain has a name and a cause, and this chapter is about understanding both.

By the end of this chapter, you won't just know what OOP is. You'll know the specific moment in a product's growth when functions stop being enough — and why objects are the natural response to that moment, not a religious doctrine handed down by computer science professors.


Part 1: The Moment Functions Break

Start with something real. You're building the payment module for one of your SaaS products. Early version. Two users. You write:

javascript
function createPayment(userId, amount, currency) {
  const id = generateId();
  const status = "pending";
  const createdAt = Date.now();
  return { id, userId, amount, currency, status, createdAt };
}

function processPayment(payment) {
  // call Stripe, update status
  payment.status = "completed";
  return payment;
}

function refundPayment(payment) {
  payment.status = "refunded";
  return payment;
}

This is clean. It works. Nothing wrong with it.

Now the product grows. You add subscriptions. Subscriptions have a different lifecycle — they can be paused, resumed, cancelled. You add invoices. Invoices can be disputed. You add credits. Credits expire.

Here's what happens to your codebase: it starts looking like this:

javascript
function processPayment(payment) { ... }
function processSubscription(subscription) { ... }
function processInvoice(invoice) { ... }

function refundPayment(payment) { ... }
function cancelSubscription(subscription) { ... }
// refundInvoice? disputeInvoice? What's the right name?

function getPaymentStatus(payment) { ... }
function getSubscriptionStatus(subscription) { ... }

You have three problems, and they're all the same underlying problem:

Problem 1: Data and the functions that operate on that data live in different places. Nothing stops you from calling refundPayment on a subscription object. The language won't catch it. The bug will be subtle and expensive.

Problem 2: There's no single place that owns a piece of data. Anyone can reach into the payment object and set payment.status = "refunded" directly, bypassing any validation logic you've carefully written in refundPayment. The moment you have two engineers or two code paths, you will get inconsistency.

Problem 3: Adding a new type of thing (say, a "credit note") requires you to scatter new functions across the entire codebase — and remember to add them everywhere they should exist. Forget one, and you have a runtime bug, not a compile-time error.

These are not JavaScript problems. They are structural problems. The structure you've chosen — data here, functions there — doesn't scale when the number of types grows, when data has rules about how it can change, or when behavior needs to vary by type.

The solution the industry converged on over 50 years is: bundle the data and the functions that operate on that data into a single unit. That unit is called an object.


Part 2: What an Object Actually Is

An object is not a fancy data structure. An object is the answer to the question: who is responsible for this data and what can you do with it?

javascript
const payment = {
  id: "pay_123",
  amount: 1000,
  currency: "INR",
  status: "pending",

  process() {
    if (this.status !== "pending") {
      throw new Error("Can only process pending payments");
    }
    // call Stripe...
    this.status = "completed";
  },

  refund() {
    if (this.status !== "completed") {
      throw new Error("Can only refund completed payments");
    }
    this.status = "refunded";
  },

  getStatus() {
    return this.status;
  }
};

Now the data and the operations on that data live together. The payment object is the single owner of payment state. Nobody can set payment.status directly without going through a method that validates the transition. You've encoded the business rules at the point where the data lives.

Notice this — that's the object referring to itself. When process() runs, this.status means "the status property of this specific payment object." This is how an object's methods access the object's own data.

This single object literal works, but it has a new problem: if you need 1,000 payment objects, you'd have to write this same structure 1,000 times. You need a factory for objects — something that stamps out new objects of the same shape. That's a class.


Part 3: Classes — A Factory for Objects

A class is a template. It describes the structure and behavior that every object of this type will have. When you create an object from a class, that object is called an instance.

javascript
class Payment {
  constructor(userId, amount, currency) {
    this.id = generateId();
    this.userId = userId;
    this.amount = amount;
    this.currency = currency;
    this.status = "pending";
    this.createdAt = Date.now();
  }

  process() {
    if (this.status !== "pending") {
      throw new Error("Can only process pending payments");
    }
    this.status = "completed";
  }

  refund() {
    if (this.status !== "completed") {
      throw new Error("Can only refund completed payments");
    }
    this.status = "refunded";
  }

  getStatus() {
    return this.status;
  }
}

const p1 = new Payment("user_1", 1000, "INR");
const p2 = new Payment("user_2", 500, "USD");
// p1 and p2 are independent instances — same structure, different data

The constructor is the function that runs when you do new Payment(...). It sets up the initial state of the new object. Every call to new Payment(...) creates a separate object in memory, with its own copy of all the properties. The methods (process, refund, getStatus) are shared across all instances — JavaScript puts them on the prototype so only one copy exists in memory, no matter how many instances you create.

Now you have a class. But a class alone isn't OOP. OOP is built on four properties that work together. The pattern books call them the "four pillars." Let's understand each one as a solution to a real problem.


Part 4: Encapsulation — Hide the Dangerous Levers

You've moved data and methods into the same class. But there's still a problem: anybody can still do p1.status = "refunded" directly, bypassing your validation in refund(). The guard you wrote is optional. It can be ignored.

Encapsulation is the practice of hiding internal state and requiring all interaction to go through methods. You expose a public interface (the methods you want others to use) and hide everything else.

JavaScript didn't have true private fields until ES2022. The syntax uses #:

javascript
class Payment {
  #status;   // private — cannot be accessed outside the class
  #amount;

  constructor(userId, amount, currency) {
    this.id = generateId();
    this.userId = userId;  // public — intentionally accessible
    this.#amount = amount; // private — only accessible inside the class
    this.currency = currency;
    this.#status = "pending";
  }

  process() {
    if (this.#status !== "pending") throw new Error("...");
    this.#status = "completed";
  }

  refund() {
    if (this.#status !== "completed") throw new Error("...");
    this.#status = "refunded";
  }

  getStatus() { return this.#status; }    // controlled read
  getAmount() { return this.#amount; }    // controlled read
}

const p = new Payment("user_1", 1000, "INR");
p.process();
console.log(p.getStatus()); // "completed"
p.#status = "refunded"; // ❌ SyntaxError — can't reach it

Now the payment object is the sole owner and guardian of its own state. The only way to change the status is through a method. The method validates the transition. Business rules are enforced at the data level, not at the caller's discretion.

This matters enormously in a real codebase. Every time state can be changed directly, you need to audit every callsite to understand the current state. With encapsulation, you audit one place: the method. This is not a style preference — it directly reduces the surface area for bugs.


Part 5: Abstraction — Show Only What Matters

You've built a Payment class with encapsulated state. But the Payment class internally does a lot: it calls Stripe's API, logs to a database, sends an email notification. The caller — the checkout controller — doesn't need to know any of that. It just needs to know: call process(), it either works or throws.

Abstraction is the practice of hiding implementation complexity behind a simple interface. The caller knows what an object does, not how it does it.

javascript
class Payment {
  #status;

  process() {
    this.#validateTransition("pending", "processing");
    this.#chargeStripe();
    this.#saveToDatabase();
    this.#sendConfirmationEmail();
    this.#status = "completed";
  }

  // These are private implementation details — callers don't know or care
  #validateTransition(from, to) { ... }
  #chargeStripe() { ... }
  #saveToDatabase() { ... }
  #sendConfirmationEmail() { ... }
}

The checkout controller calls payment.process(). It has no idea that four separate operations happen internally. When you switch from Stripe to Razorpay, you change #chargeStripe(). The checkout controller doesn't change. It doesn't even know you changed anything.

Abstraction and encapsulation are related but distinct. Encapsulation is about protecting state. Abstraction is about hiding complexity. Encapsulation is how you implement abstraction — but abstraction is the reason you want encapsulation.


Part 6: Inheritance — Reusing Structure, Not Just Code

Now you have Payment. You realize a SubscriptionPayment is mostly like a Payment — it has the same id, userId, amount, currency, status. But it also has planId, billingCycle, and methods like pause() and resume() that a regular payment doesn't have.

You have two options. Copy-paste the entire Payment class and add the new stuff (terrible — now you maintain two copies of identical code). Or use inheritance, where one class extends another and gets all its structure for free.

javascript
class SubscriptionPayment extends Payment {
  #planId;
  #billingCycle;
  #paused;

  constructor(userId, amount, currency, planId, billingCycle) {
    super(userId, amount, currency); // calls Payment's constructor
    this.#planId = planId;
    this.#billingCycle = billingCycle;
    this.#paused = false;
  }

  pause() {
    if (this.#paused) throw new Error("Already paused");
    this.#paused = true;
    // cancel Stripe subscription...
  }

  resume() {
    if (!this.#paused) throw new Error("Not paused");
    this.#paused = false;
    // reactivate Stripe subscription...
  }

  // process() and refund() are inherited from Payment — no need to rewrite them
}

SubscriptionPayment is a Payment — it inherits all its properties and methods. This relationship is called an "is-a" relationship. SubscriptionPayment is a type of Payment. This is the test for whether inheritance is appropriate.

Contrast with the wrong use of inheritance: if you tried to make User extend Payment because a user has a payment method — that's wrong. A User is not a type of Payment. That relationship should be composition ("has-a"), not inheritance. We'll come back to this in Chapter 2 when we talk about the Liskov Substitution Principle.

The dangerous thing about inheritance is that it creates tight coupling between the parent and child. If you change Payment's constructor, all subclasses break. Inheritance is powerful but should be used sparingly. The industry maxim is: prefer composition over inheritance. We'll understand exactly why in the design patterns chapters.


Part 7: Polymorphism — Same Interface, Different Behavior

You now have Payment and SubscriptionPayment. You also have WalletPayment (uses internal credits). Each has a process() method, but the internals are different: regular payment charges a card, subscription payment sets up recurring billing, wallet payment deducts from a credit balance.

Now imagine you have a checkout system that processes orders. It doesn't know ahead of time what type of payment it's dealing with. Polymorphism lets you write code that works on any payment type without knowing the specific type:

javascript
class RegularPayment extends Payment {
  process() {
    console.log("Charging card...");
    super.process(); // optionally call parent's process
  }
}

class WalletPayment extends Payment {
  process() {
    console.log("Deducting from wallet...");
    // completely different implementation
  }
}

// Checkout doesn't care what type of payment this is
function processOrder(order, payment) {
  // payment could be RegularPayment, WalletPayment, SubscriptionPayment
  // doesn't matter — they all have process()
  payment.process();
  order.markAsPaid();
}

The same method name process() does different things depending on which object it's called on. At runtime, JavaScript figures out which process() to call based on the actual type of the object. This is called dynamic dispatch — the "dispatch" (routing to the right method) happens dynamically at runtime, not statically at write-time.

This is the mechanism that makes design patterns possible. The Strategy pattern, the Observer pattern, the Command pattern — all of them rely on polymorphism to let you swap implementations without changing the code that uses them. Every design pattern chapter builds on this foundation.


The Four Pillars: What They Actually Are

Let's be honest about what these four ideas are. They're not religious doctrine. They're solutions to specific engineering problems:

Encapsulation solves: "anyone can corrupt my object's state." Solution: hide the state, force all changes through validated methods.

Abstraction solves: "callers shouldn't need to know how complex this is." Solution: expose a simple interface, hide the implementation.

Inheritance solves: "I need a new type that's mostly like an existing type." Solution: extend the parent class, inherit structure and behavior, override what's different.

Polymorphism solves: "I need code that works on multiple types without knowing which one." Solution: define a consistent interface; let each type implement it differently; write your calling code against the interface.

When you understand them as solutions, you'll know when to use them and when not to. You'll use encapsulation almost always. You'll use abstraction almost always. You'll use inheritance carefully and sparingly. You'll use polymorphism constantly — it's the mechanism behind every design pattern you'll learn.


Vocabulary Map

TermWhat it actually is
ObjectA bundle of data (properties) + the functions (methods) that operate on that data, packaged together
ClassA template/factory that describes what properties and methods instances will have
InstanceA specific object created from a class. new Payment(...) creates an instance
ConstructorThe function that runs when you create a new instance, setting its initial state
thisInside a method, this refers to the specific object the method was called on
EncapsulationMaking state private — only accessible through the object's own methods
AbstractionHiding implementation complexity — callers see a simple interface, not the internals
InheritanceOne class getting all the properties and methods of another class automatically
PolymorphismThe ability to call the same method name on different types and get type-specific behavior
Dynamic dispatchThe runtime mechanism that figures out which class's version of a method to call
"is-a" relationshipThe test for inheritance: A SubscriptionPayment IS A Payment. If this sentence sounds wrong, don't inherit
"has-a" relationshipThe test for composition: A User HAS A payment method. Use composition, not inheritance
Private field (#)A property that literally cannot be read or written from outside the class
PrototypeJavaScript's internal mechanism for sharing methods across instances without duplicating them in memory
super()Inside a subclass constructor, calls the parent class's constructor to set up inherited state

Practice Problems

Problem 1 — Failure Analysis

You're building the user profile module for one of your SaaS products. A junior engineer writes:

javascript
class User {
  constructor(name, email, plan) {
    this.name = name;
    this.email = email;
    this.plan = plan;
    this.isActive = true;
  }

  deactivate() {
    this.isActive = false;
  }
}

Six months later, you find that users are being deactivated in two places in the codebase: one calls user.deactivate(), another sets user.isActive = false directly. The second path was added by someone who didn't know deactivate() existed. The second path never fires the "account deactivated" email.

Name the violated OOP principle. Explain the physical mechanism that allowed the violation to happen. Rewrite the class so this class of bug is structurally impossible — not just unlikely.


Problem 2 — Design from Scratch

You're adding a notification system to your SaaS. You need to send notifications via three channels: email, SMS, and in-app. Each channel has completely different API calls and response structures. But the rest of your codebase just wants to say "notify this user about this event" without caring about the channel.

Design the class hierarchy. What's the base class? What do the subclasses look like? Which method is polymorphic? Write the code skeleton (not the full implementations — just the structure). Then explain: when a new channel (say, WhatsApp) is added, what exactly changes and what doesn't change?


Problem 3 — Synthesis

A junior engineer argues: "Encapsulation and abstraction are the same thing. If you hide your fields with #, you're automatically abstracting."

Are they right? Find a case where you have encapsulation without abstraction, and a case where you have abstraction without encapsulation. Code both cases in JavaScript.


Problem 4 — Inheritance vs Composition

You're building a PremiumUser class. Premium users are users with an active subscription, access to advanced features, and a dedicated support tier. A junior engineer writes class PremiumUser extends User.

Another engineer argues: don't inherit, use composition — have a User that holds a Subscription object, and check the subscription to determine premium status.

Who is right and why? What specific failure will occur in 12 months if you choose inheritance? What's the smell that tells you a class shouldn't extend another class?


Problem 5 — Go Deeper

JavaScript's class syntax with # private fields is relatively new (2022). Before that, engineers achieved encapsulation using closures — the same closures you already understand deeply from Chapter 3 of your JavaScript book.

Rewrite the Payment class without using the class keyword at all — using only a factory function and closures — such that status and amount are genuinely private (not just conventionally private with _ prefix, but structurally inaccessible). Then explain: the class keyword with # fields and the closure factory function achieve the same observable behavior — but one has a significant memory advantage. Which one? Why? (Hint: think about how JavaScript handles methods in classes vs functions in closures for 10,000 instances.)


What's Coming Next

You now understand what OOP is and why it exists. But having classes isn't enough. OOP can — and very often does — produce code that's even harder to change than the function soup you started with. Classes can grow into god objects that know too much. Inheritance hierarchies can become fragile and brittle. The problem isn't OOP itself; it's that OOP without discipline collapses under its own weight. Chapter 2 covers the five principles that prevent that collapse: SOLID. Two of them you already follow without knowing it. We'll name them, sharpen them, and show you the three that you probably don't follow — and why violating them is the source of the "why is this so hard to change" feeling you've had in real codebases.


Chapter 2: SOLID Principles — The Five Rules That Make OOP Not Collapse

Every codebase you've worked on that felt increasingly miserable to change was probably violating at least three of these five rules. That misery isn't inevitable. It's a structural problem with a structural solution.

SOLID is an acronym: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. The acronym is useful for memorisation. What's more useful is understanding the failure mode each principle prevents — because once you see the failure mode clearly, the principle is obvious.


Part 1: Single Responsibility Principle — One Reason to Change

Here's a class from a real-world codebase pattern:

javascript
class User {
  constructor(name, email, plan) {
    this.name = name;
    this.email = email;
    this.plan = plan;
  }

  getUserDetails() {
    return { name: this.name, email: this.email };
  }

  saveToDatabase() {
    db.query(`INSERT INTO users (name, email, plan) VALUES (?, ?, ?)`,
      [this.name, this.email, this.plan]);
  }

  sendWelcomeEmail() {
    emailService.send(this.email, "Welcome!", "...");
  }

  formatForInvoice() {
    return `${this.name} <${this.email}>`;
  }
}

This class does four different things. Count the reasons it could need to change:

  1. The getUserDetails format changes (product decision).
  2. The database query changes (you move from MySQL to Postgres).
  3. The email template changes (marketing decision).
  4. The invoice format changes (accounting decision).

Four completely different teams — product, infrastructure, marketing, accounting — might need to change this class for completely unrelated reasons. Every time any of them touches it, they risk breaking something the other teams rely on. The class is a collision point.

The Single Responsibility Principle says: a class should have one and only one reason to change.

Not "one method." Not "one feature." One reason to change — which means one team or one domain owns it.

javascript
class User {
  constructor(name, email, plan) {
    this.name = name;
    this.email = email;
    this.plan = plan;
  }

  getDetails() {
    return { name: this.name, email: this.email };
  }
}

class UserRepository {
  save(user) {
    db.query(`INSERT INTO users ...`, [user.name, user.email, user.plan]);
  }
}

class UserEmailService {
  sendWelcome(user) {
    emailService.send(user.email, "Welcome!", "...");
  }
}

class UserInvoiceFormatter {
  format(user) {
    return `${user.name} <${user.email}>`;
  }
}

Now each class changes for exactly one reason. The database layer changes when the schema changes. The email service changes when the template changes. They're independent — a change to one can't accidentally break another.

You almost certainly follow this instinctively sometimes — you probably don't put database logic inside your React components. But you may not apply it consistently inside your backend classes. The test: if I describe this class's responsibility, do I use the word "and"? "This class handles user data and sends emails and formats invoices." Every "and" is a violation.


Part 2: Open/Closed Principle — Extend Without Modifying

You've built a payment processing system with support for Stripe. Six months later, the business wants to add Razorpay for Indian customers. Here's the naive approach:

javascript
class PaymentProcessor {
  process(payment, provider) {
    if (provider === "stripe") {
      stripe.charge(payment.amount, payment.cardToken);
    } else if (provider === "razorpay") {
      razorpay.order(payment.amount, payment.currency);
    }
    // Next month: } else if (provider === "paypal") { ...
  }
}

Every time you add a payment provider, you modify PaymentProcessor. You open a file that was working, reach in, and add code next to code that's been in production for months. Every modification is a chance to break existing functionality. The risk grows linearly with every new provider.

The Open/Closed Principle says: classes should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.

The mechanism for this is the polymorphism you learned in Chapter 1:

javascript
// The abstraction — define the interface every provider must satisfy
class PaymentProvider {
  process(payment) {
    throw new Error("process() must be implemented");
  }
}

// Each provider is an extension — new file, doesn't touch existing code
class StripeProvider extends PaymentProvider {
  process(payment) {
    stripe.charge(payment.amount, payment.cardToken);
  }
}

class RazorpayProvider extends PaymentProvider {
  process(payment) {
    razorpay.order(payment.amount, payment.currency);
  }
}

// PaymentProcessor never changes again
class PaymentProcessor {
  constructor(provider) {
    this.provider = provider; // inject the provider
  }

  process(payment) {
    this.provider.process(payment); // delegates to the provider
  }
}

// Usage:
const processor = new PaymentProcessor(new RazorpayProvider());
processor.process(payment);

When PayPal needs to be added, you create a PayPalProvider class in a new file. The PaymentProcessor class doesn't change. The StripeProvider class doesn't change. Production code stays untouched.

The key insight is that OCP requires you to anticipate variation points and design abstractions around them. The variation point here is "payment provider." You extracted it into a class hierarchy. The OCP works hand-in-hand with the Strategy pattern (Chapter 4) — you'll recognise this pattern again there.


Part 3: Liskov Substitution Principle — Subclasses That Actually Substitute

This is the principle most engineers violate without realising it, and it's the reason "prefer composition over inheritance" became a maxim.

Named after Barbara Liskov, it states: if S is a subclass of T, you should be able to use an S anywhere a T is expected, and the program should still work correctly.

In plain English: subclasses must be fully substitutable for their parent. They can't require more from the caller than the parent does. They can't promise less than the parent does.

Here's a classic violation:

javascript
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

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

class Square extends Rectangle {
  setWidth(w) {
    this.width = w;
    this.height = w; // square must have equal sides
  }

  setHeight(h) {
    this.height = h;
    this.width = h; // square must have equal sides
  }
}

Mathematically, a square is a rectangle. So extends seems right. But watch what happens:

javascript
function testRectangle(rect) {
  rect.setWidth(5);
  rect.setHeight(4);
  // I expect area to be 20
  console.assert(rect.getArea() === 20, "Expected area to be 20");
}

testRectangle(new Rectangle(0, 0)); // ✅ passes
testRectangle(new Square(0));       // ❌ fails — area is 16, not 20

The Square is not substitutable for Rectangle. Code that works correctly with a Rectangle breaks when given a Square. The LSP is violated.

The root problem: Square and Rectangle have different invariants. A rectangle's width and height are independent. A square's are not. Inheriting from Rectangle and then breaking its fundamental contract (setWidth doesn't change height) is the violation.

The correct solution: don't inherit. Use composition, or make both extend a common abstract Shape class with only getArea():

javascript
class Shape {
  getArea() { throw new Error("getArea() must be implemented"); }
}

class Rectangle extends Shape {
  constructor(w, h) { super(); this.width = w; this.height = h; }
  getArea() { return this.width * this.height; }
  setWidth(w) { this.width = w; }
  setHeight(h) { this.height = h; }
}

class Square extends Shape {
  constructor(side) { super(); this.side = side; }
  getArea() { return this.side * this.side; }
  setSide(s) { this.side = s; }
}

Now both are Shapes, and you can call getArea() on either. But you can't call setWidth() on a Square because Square doesn't have that method — the contract is honest.

The LSP test: Before inheriting, ask: can I substitute every instance of the parent class with an instance of the child class, in every context, and have the program behave identically? If no, don't inherit.


Part 4: Interface Segregation Principle — Don't Force Interfaces You Don't Use

JavaScript doesn't have formal interfaces (TypeScript does). But the principle still applies. It says: don't force a class to implement methods it doesn't need.

Imagine you're building a plugin system for your SaaS, and you define a Plugin interface:

javascript
// In TypeScript (to illustrate the principle)
interface Plugin {
  initialize(): void;
  process(data: any): any;
  cleanup(): void;
  handleError(err: Error): void;
  renderUI(): HTMLElement;
  exportData(): string;
}

You have two plugins: a data processing plugin (needs process, cleanup) and a UI widget plugin (needs renderUI, initialize). Both are forced to implement all six methods. The data processing plugin has to implement renderUI() — which makes no sense for it. The UI widget has to implement exportData() — also nonsense.

When renderUI() changes its signature, both plugins need to be updated — even though the data processing plugin doesn't use it at all. You've created unnecessary coupling.

The fix: split the fat interface into focused ones:

javascript
interface DataPlugin {
  process(data: any): any;
  cleanup(): void;
}

interface UIPlugin {
  initialize(): void;
  renderUI(): HTMLElement;
}

interface ExportPlugin {
  exportData(): string;
}

A plugin implements only the interfaces relevant to it. Changes to UIPlugin only affect plugins that actually render UI.

In JavaScript without TypeScript, this manifests as: don't put methods in a base class that not all subclasses meaningfully implement. If you catch yourself writing throw new Error("Not implemented") in a subclass for a method it doesn't need, you've violated ISP.


Part 5: Dependency Inversion Principle — Depend on Abstractions, Not Concretions

This is the principle most directly responsible for whether your code is testable and flexible. It has two rules:

  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.

Here's a common violation:

javascript
class OrderService {
  constructor() {
    this.emailService = new SendGridEmailService(); // hardcoded dependency
    this.db = new PostgresDatabase();              // hardcoded dependency
  }

  placeOrder(order) {
    this.db.save(order);
    this.emailService.sendConfirmation(order.userId, order.total);
  }
}

OrderService (high-level, business logic) directly creates and uses SendGridEmailService and PostgresDatabase (low-level, infrastructure). You cannot test OrderService without actually sending emails and hitting a database. You cannot switch to a different email provider without modifying OrderService. These are infrastructure concerns leaking into business logic.

The fix is dependency injection: give the dependencies to the class from the outside instead of creating them inside.

javascript
// OrderService depends on abstractions (whatever has these methods), not concretions
class OrderService {
  constructor(database, emailService) {
    this.db = database;           // injected
    this.emailService = emailService; // injected
  }

  placeOrder(order) {
    this.db.save(order);
    this.emailService.sendConfirmation(order.userId, order.total);
  }
}

// In production:
const orderService = new OrderService(
  new PostgresDatabase(),
  new SendGridEmailService()
);

// In tests:
const orderService = new OrderService(
  new InMemoryDatabase(),   // fake database
  new MockEmailService()    // fake email — never sends a real email
);

OrderService now depends on "something that has a save method" and "something that has a sendConfirmation method." It doesn't care whether that's Postgres or SQLite, SendGrid or Mailchimp. The details depend on the abstraction, not the other way around.

This is why your Next.js apps that are hard to test have their fetch calls directly in components or services — the component depends concretely on fetch, making it impossible to test without a network. Dependency inversion would have you inject a httpClient that you can swap with a mock.


The Real Relationship Between the Five Principles

They're not independent. They reinforce each other:

SRP ensures classes are small and focused. Small focused classes are easier to keep OCP-compliant, because variation points are clearer. LSP ensures your inheritance hierarchies don't break OCP by introducing unexpected behavior. ISP prevents fat abstractions that couple unrelated behavior. DIP is what makes OCP actually work — you can only be "closed for modification" if you depend on abstractions you can swap.

The common thread: manage where things change, and limit the blast radius when they do.


Vocabulary Map

TermWhat it actually is
SRPA class has one team that owns it — one domain, one reason to change
OCPAdd new behavior via new code (extensions), not by modifying existing code
LSPA subclass is a genuine substitute for the parent — no surprises when swapped
ISPDon't bundle unrelated capabilities into one interface — split it
DIPPass dependencies in from outside (inject them) rather than creating them inside
Dependency InjectionGiving a class its dependencies through the constructor or a method, not creating them internally
Abstraction (in DIP)An interface or base class that defines what something can do, without specifying how
ConcretionA specific implementation: PostgresDatabase, SendGridEmailService, etc.
Variation pointA place in a system where behavior needs to vary — the target for abstraction
"Prefer composition over inheritance"Instead of IS-A, use HAS-A — hold an instance of the other class rather than extending it

Practice Problems

Problem 1 — Failure Analysis

You inherit a codebase with this class:

javascript
class Report {
  constructor(data) { this.data = data; }
  generate() { /* complex report generation logic */ }
  saveAsPDF() { /* PDF generation */ }
  saveAsCSV() { /* CSV export */ }
  emailReport(to) { /* sends email with attachment */ }
  logToSlack(channel) { /* posts to Slack */ }
}

List every SOLID violation. For each violation, describe a concrete scenario that would force you to modify this class — and who (product, infra, marketing, etc.) would demand that change. Redesign the class hierarchy.


Problem 2 — Design from Scratch

You're building a file storage abstraction for your SaaS that currently uses Cloudflare R2. Requirements: upload files, download files, delete files, list files in a folder.

Design it so that: (a) switching from R2 to S3 or local disk requires zero changes to any code that uses the storage, (b) you can run tests without touching R2 at all, (c) you can add a new storage backend without modifying existing ones.

Write the class structure. What principle does each design decision correspond to?


Problem 3 — LSP in practice

Your User class has an updateEmail(newEmail) method. You create an AdminUser extends User. An admin can update any user's email, but their own email is locked to their company domain and cannot be changed. You override updateEmail in AdminUser to throw an error if the new email doesn't match the company domain.

Is this an LSP violation? What's the exact test to determine this? If it is a violation, what are the two ways to fix it?


Problem 4 — DIP + Testability

You have a UserAnalyticsService that tracks which features users click on. It currently calls posthog.capture() directly inside the service. Your team wants to: (a) run unit tests without sending real events to PostHog, (b) switch to Mixpanel later without touching the service.

Refactor the service to satisfy both requirements. Then explain: what exactly makes this service more testable after the refactor, at a mechanical level?


Problem 5 — Go Deeper (connects to Chapter 1)

The DIP says "depend on abstractions, not concretions." But in JavaScript, there are no formal interfaces — you can't declare that a class must implement process(). A developer on your team argues: "DIP doesn't really apply to JavaScript because there's nothing to enforce it."

Argue against them. Without formal interfaces, what enforces the contract in JavaScript? When does violating the contract get caught — at what point in the development cycle? How does TypeScript change the answer? And: is there a runtime mechanism in JavaScript (not TypeScript) that can enforce interface conformance? Build one.


What's Coming Next

You have the foundation: OOP mechanics from Chapter 1, and the five principles that keep OOP healthy from Chapter 2. Now we move to design patterns — reusable solutions to commonly recurring design problems that experienced engineers have named and documented. Patterns are not code you copy. They're vocabulary for conversations about structure, and a toolbox of battle-tested solutions. We start with creational patterns: not how things behave, but how things are created. Factory, Singleton, Builder. Each one solves a specific awkwardness in object creation that you've definitely encountered — we just haven't named it yet.


Chapter 3: Design Patterns — Creational

You're about to meet solutions to problems you've already had. That's the right order to encounter design patterns — you should feel "oh, that's what that is" before you feel "let me use that pattern." If you encounter a pattern before you've felt the problem, you'll misapply it everywhere.

Creational patterns deal with how objects are created. Specifically, they deal with situations where the simple new ClassName() call becomes awkward: when you don't know which class to instantiate until runtime, when you need exactly one instance, or when the construction process is complex enough to deserve its own logic.


Part 1: Factory Method — "I Don't Know Which Class to Create Until Runtime"

You're building the notification system from Chapter 1's practice problem. You have three notification types: EmailNotification, SMSNotification, PushNotification. Each is a different class.

The caller — let's say a NotificationService — receives an event and needs to send a notification. But the channel depends on user preferences, stored in the database. At the time you write NotificationService, you don't know which class to instantiate.

The naive approach:

javascript
class NotificationService {
  notify(user, event) {
    if (user.preferredChannel === "email") {
      const n = new EmailNotification(user, event);
      n.send();
    } else if (user.preferredChannel === "sms") {
      const n = new SMSNotification(user, event);
      n.send();
    } else if (user.preferredChannel === "push") {
      const n = new PushNotification(user, event);
      n.send();
    }
  }
}

This violates OCP: every new channel requires modifying NotificationService. The if/else chain grows with every new channel.

The Factory Method pattern solves this. Define a method whose job is to create objects — and let subclasses decide which object to create:

javascript
class Notification {
  send() { throw new Error("send() must be implemented"); }
}

class EmailNotification extends Notification {
  constructor(user, event) { super(); this.user = user; this.event = event; }
  send() { console.log(`Sending email to ${this.user.email}`); }
}

class SMSNotification extends Notification {
  constructor(user, event) { super(); this.user = user; this.event = event; }
  send() { console.log(`Sending SMS to ${this.user.phone}`); }
}

class PushNotification extends Notification {
  constructor(user, event) { super(); this.user = user; this.event = event; }
  send() { console.log(`Sending push to ${this.user.deviceToken}`); }
}

// The factory: maps a key to a class
class NotificationFactory {
  static create(channel, user, event) {
    const map = {
      email: EmailNotification,
      sms:   SMSNotification,
      push:  PushNotification,
    };

    const NotificationClass = map[channel];
    if (!NotificationClass) throw new Error(`Unknown channel: ${channel}`);
    return new NotificationClass(user, event);
  }
}

class NotificationService {
  notify(user, event) {
    const notification = NotificationFactory.create(user.preferredChannel, user, event);
    notification.send();
  }
}

Now adding a WhatsApp channel means: create WhatsAppNotification, add one line to the factory map. NotificationService doesn't change. The if/else is gone.

The factory doesn't have to be a class — a simple function works too:

javascript
function createNotification(channel, user, event) {
  const map = {
    email: () => new EmailNotification(user, event),
    sms:   () => new SMSNotification(user, event),
    push:  () => new PushNotification(user, event),
  };
  if (!map[channel]) throw new Error(`Unknown channel: ${channel}`);
  return map[channel]();
}

When to use: whenever you find yourself with an if/else or switch that creates different types of objects based on some condition. That condition-to-creation mapping belongs in a factory.


Part 2: Singleton — "There Must Be Exactly One"

Your application has a database connection pool. Creating a new pool is expensive — it opens 10 connections, each requiring a handshake with the database server. If two parts of your application accidentally create two separate pools, you've doubled your connection count and have two separate pools that don't share load.

You need exactly one instance. Not "usually one." Exactly one. If something tries to create a second one, it should get the first one back instead.

javascript
class DatabasePool {
  static #instance = null; // class-level, not instance-level

  #connections = [];

  constructor() {
    if (DatabasePool.#instance) {
      return DatabasePool.#instance; // return the existing one
    }
    // First time: actually create the pool
    this.#initialize();
    DatabasePool.#instance = this;
  }

  #initialize() {
    // open 10 database connections...
    console.log("Initializing database pool");
  }

  static getInstance() {
    if (!DatabasePool.#instance) {
      new DatabasePool(); // creates it and stores in #instance
    }
    return DatabasePool.#instance;
  }

  getConnection() { /* ... */ }
}

// Usage:
const pool1 = DatabasePool.getInstance();
const pool2 = DatabasePool.getInstance();
console.log(pool1 === pool2); // true — same object

The more idiomatic JavaScript approach uses a module-level variable, because JavaScript modules are singletons by default (a module's exports are cached after the first require/import):

javascript
// db-pool.js
let instance = null;

class DatabasePool {
  constructor() {
    this.connections = [];
    this.#initialize();
  }
  #initialize() { /* ... */ }
  getConnection() { /* ... */ }
}

export function getPool() {
  if (!instance) instance = new DatabasePool();
  return instance;
}

When to use: shared resources that are expensive to create and should have a single source of truth: database connection pools, configuration managers, loggers, caches.

Warning: Singletons make testing hard. If your singleton holds state, tests affect each other. Use dependency injection (DIP from Chapter 2) to inject the singleton rather than accessing it globally — then you can inject a fresh instance in tests.


Part 3: Builder — "Construction is Complex"

Imagine creating a QueryBuilder for database queries. A query can have a table, conditions, ordering, limits, joins, selected columns — but not all are required. The naive class:

javascript
new Query("users", ["id", "name"], [["age", ">", 18]], "name", "asc", 10, 0, [])

Eight arguments, all positional. Do you remember what argument 6 is? Neither will the person reading your code in 6 months. And what if you want a query with only a table and a limit — you still need to pass undefined for all the other parameters.

The Builder pattern separates the construction of a complex object from its representation. Instead of one massive constructor, you build the object step by step with named methods:

javascript
class QueryBuilder {
  #table = null;
  #columns = ["*"];
  #conditions = [];
  #orderBy = null;
  #orderDirection = "asc";
  #limit = null;
  #offset = 0;

  from(table) {
    this.#table = table;
    return this; // return this for chaining
  }

  select(...columns) {
    this.#columns = columns;
    return this;
  }

  where(column, operator, value) {
    this.#conditions.push({ column, operator, value });
    return this;
  }

  orderBy(column, direction = "asc") {
    this.#orderBy = column;
    this.#orderDirection = direction;
    return this;
  }

  limit(n) {
    this.#limit = n;
    return this;
  }

  offset(n) {
    this.#offset = n;
    return this;
  }

  build() {
    if (!this.#table) throw new Error("Table is required");
    
    let sql = `SELECT ${this.#columns.join(", ")} FROM ${this.#table}`;
    
    if (this.#conditions.length) {
      const where = this.#conditions
        .map(c => `${c.column} ${c.operator} ?`)
        .join(" AND ");
      sql += ` WHERE ${where}`;
    }

    if (this.#orderBy) sql += ` ORDER BY ${this.#orderBy} ${this.#orderDirection}`;
    if (this.#limit)   sql += ` LIMIT ${this.#limit}`;
    if (this.#offset)  sql += ` OFFSET ${this.#offset}`;

    return {
      sql,
      params: this.#conditions.map(c => c.value)
    };
  }
}

// Usage — each line reads like English
const query = new QueryBuilder()
  .from("users")
  .select("id", "name", "email")
  .where("age", ">", 18)
  .where("plan", "=", "premium")
  .orderBy("name")
  .limit(20)
  .offset(40)
  .build();

Every method returns this, enabling method chaining. build() performs final validation and produces the finished object. The caller only specifies what they care about — any unspecified properties get sensible defaults.

When to use: when an object has many optional properties, when construction requires multiple steps, or when the construction process needs to produce different representations of the same kind of object. You've used this pattern without naming it — Prisma's query API and Mongoose's query builder are both Builder patterns.


Vocabulary Map

TermWhat it actually is
Creational PatternA solution to an object-creation problem — when new ClassName() isn't enough
Factory MethodA function/method that creates objects of varying types based on a condition — the if/else for instantiation
Factory MapA dictionary mapping keys to classes (or factory functions), replacing if/else chains
SingletonA class that guarantees exactly one instance exists and provides a global access point to it
BuilderAn API that constructs a complex object step by step via chained methods, culminating in a build() call
Method ChainingReturning this from methods so they can be called in sequence on the same object
Static memberA property or method on the class itself (not on instances) — DatabasePool.#instance

Practice Problems

Problem 1 — Factory in the wild

In your SaaS products, you use multiple third-party services: Stripe for payments, SendGrid for email, Twilio for SMS, Cloudflare for storage. In tests, you want to use fake versions of all of these.

Design a ServiceFactory that: in production, returns real implementations; in test environment, returns mock implementations. What information does the factory need? Where should it live? What happens when you add a new service?


Problem 2 — Singleton pathology

A teammate adds a ConfigManager singleton that loads .env values on startup. Three months later, you need to run tests with different configuration values. You discover tests affect each other because they all share the same ConfigManager instance. How do you fix this without removing the singleton? What structural change prevents this entire category of problem?


Problem 3 — Builder vs. options object

Two engineers debate. Engineer A wants: new User({ name: "Alice", email: "a@b.com", plan: "pro", referrer: "bob" }) — an options object. Engineer B wants: new UserBuilder().name("Alice").email("a@b.com").plan("pro").referrer("bob").build(). When is each approach appropriate? What does the Builder give you that the options object doesn't? What does the options object give you that the Builder doesn't?


Problem 4 — Synthesis (Factory + OCP)

Your notification factory from the chapter has a map object hardcoded inside it. A strict interpretation of OCP says even the factory map shouldn't require modification to add new channels. Design a notification factory with a register(channel, NotificationClass) method that allows new channels to be added at startup without touching the factory file. Draw out the complete design. What is this pattern called when a factory accepts registrations at runtime?


Problem 5 — Go Deeper

JavaScript module caching means that the first import of a module runs its code and caches the result. All subsequent imports return the same cached module. Prove this: write two files that import the same module that has side effects (like incrementing a counter on load) and show that the side effect only runs once. Then explain: does this mean every JavaScript module is implicitly a Singleton? What's the implication for testing? Under what condition does module caching not give you Singleton guarantees?


What's Coming Next

Creational patterns are about object creation. Now we move to behavioral patterns — about how objects communicate and interact. These are the patterns that appear in 80% of LLD interview problems, because LLD is fundamentally about modeling behavior. Strategy lets you swap algorithms. Observer lets objects react to each other's state changes. Command turns an action into a first-class object you can queue, log, or undo. Each pattern will connect directly back to OCP, DIP, and polymorphism from the previous chapters.


Chapter 4: Design Patterns — Behavioural

If the creational patterns were about object birth, behavioral patterns are about object conversation. They model how objects interact with each other — how they delegate work, react to changes, and coordinate behavior. These are the patterns that show up in system design: the Observer is how event streaming works, the Strategy is how pluggable algorithms work, the Command is how undo/redo and job queues work.

You've implemented all three of these patterns in your products without knowing their names. We're about to name them and, more importantly, understand the underlying structure they all share.


Part 1: Strategy — Swap the Algorithm, Not the Code

You're building a ride-sharing app. When a user requests a ride, you need to calculate the fare. There are multiple fare calculation strategies: standard pricing, surge pricing (peak hours), flat rate (pre-booked), and promotional pricing. Each is a different algorithm. Tomorrow, the business adds a "corporate rate" strategy.

Here's the violation of OCP you'll be tempted to write:

javascript
class FareCalculator {
  calculate(ride, pricingType) {
    if (pricingType === "standard") { return ride.distance * 12; }
    if (pricingType === "surge")    { return ride.distance * 12 * 2.5; }
    if (pricingType === "flat")     { return 200; }
    if (pricingType === "promo")    { return ride.distance * 8; }
  }
}

This is the same problem as the notification channels. Adding a new pricing strategy requires modifying the calculator. You're opening a file every time the business adds a pricing option.

The Strategy pattern: Extract each algorithm into its own class. Inject the algorithm (the strategy) into the context that uses it.

javascript
// The abstraction — what every strategy must be able to do
class PricingStrategy {
  calculate(ride) { throw new Error("calculate() must be implemented"); }
}

// Each strategy — a separate, isolated implementation
class StandardPricing extends PricingStrategy {
  calculate(ride) { return ride.distance * 12; }
}

class SurgePricing extends PricingStrategy {
  #multiplier;
  constructor(multiplier) { super(); this.#multiplier = multiplier; }
  calculate(ride) { return ride.distance * 12 * this.#multiplier; }
}

class FlatRatePricing extends PricingStrategy {
  calculate(ride) { return 200; }
}

class PromoPricing extends PricingStrategy {
  #discount;
  constructor(discount) { super(); this.#discount = discount; }
  calculate(ride) { return ride.distance * 12 * (1 - this.#discount); }
}

// The context — uses a strategy, doesn't care which one
class FareCalculator {
  #strategy;

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

  setStrategy(strategy) {
    this.#strategy = strategy; // can change at runtime
  }

  calculate(ride) {
    return this.#strategy.calculate(ride);
  }
}

// Usage:
const isSurge = checkIfSurge(Time.now());
const strategy = isSurge ? new SurgePricing(2.5) : new StandardPricing();
const calculator = new FareCalculator(strategy);

const fare = calculator.calculate(ride);

The pattern is: define a family of algorithms, encapsulate each one, and make them interchangeable. The context (FareCalculator) doesn't know or care which algorithm is active. The strategy is injected — this is the DIP from Chapter 2 in action.

The crucial addition: the strategy can change at runtime. The same FareCalculator instance can switch from StandardPricing to SurgePricing dynamically, based on the time of day, without creating a new calculator.

When you've already used this: Sorting with a custom comparator is Strategy. Array.sort takes a strategy function. [].sort((a, b) => a - b) — the (a, b) => a - b function is the strategy. You've been doing it all along.

When to reach for it: any time you have multiple ways to do the same type of operation, and the choice of which way to use can vary — per user, per environment, per runtime condition, per request.


Part 2: Observer — Notify Dependents of State Changes

You're building BookMyShow. When a booking is confirmed, multiple things need to happen: send a confirmation email, send an SMS, update the seat availability, generate the invoice, log for analytics.

The naive approach: call all of them from the booking confirmation handler:

javascript
function confirmBooking(booking) {
  booking.status = "confirmed";
  emailService.sendConfirmation(booking);
  smsService.sendConfirmation(booking);
  seatService.markSeatsAsBooked(booking.seatIds);
  invoiceService.generate(booking);
  analytics.track("booking_confirmed", booking);
}

This violates SRP: the confirmation handler now knows about email, SMS, seats, invoices, and analytics. When the analytics team wants to track a new event, they modify the booking handler. When the email team changes the template, they're in the booking handler. Five teams, one function, guaranteed collision.

The deeper problem: the booking confirmation handler shouldn't know about email and SMS at all. That's a business logic concern (booking) leaking into notification concerns.

The Observer pattern: The booking confirmation handler doesn't call anyone. It emits an event. Anyone who cares about that event subscribes to it. The handler doesn't know who's listening — and doesn't need to.

javascript
class EventEmitter {
  #listeners = new Map();

  on(event, handler) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, []);
    this.#listeners.get(event).push(handler);
    return () => this.off(event, handler); // return unsubscribe function
  }

  off(event, handler) {
    const handlers = this.#listeners.get(event) || [];
    this.#listeners.set(event, handlers.filter(h => h !== handler));
  }

  emit(event, data) {
    const handlers = this.#listeners.get(event) || [];
    handlers.forEach(handler => handler(data));
  }
}

class BookingService extends EventEmitter {
  confirm(booking) {
    booking.status = "confirmed";
    this.emit("booking:confirmed", booking); // that's it. done.
  }
}

// Each concern subscribes independently
const bookingService = new BookingService();

bookingService.on("booking:confirmed", (booking) => {
  emailService.sendConfirmation(booking);
});

bookingService.on("booking:confirmed", (booking) => {
  smsService.sendConfirmation(booking);
});

bookingService.on("booking:confirmed", (booking) => {
  seatService.markSeatsAsBooked(booking.seatIds);
});

bookingService.on("booking:confirmed", (booking) => {
  analytics.track("booking_confirmed", booking);
});

Now BookingService knows nothing about email, SMS, or analytics. When the analytics team wants to add a new tracker, they add a new subscriber — they don't touch the booking service. When a new notification channel is added, it's a new subscription. The booking service never changes for notification reasons.

This is exactly how Node.js's EventEmitter works. It's how React's synthetic events work. It's how browser addEventListener works. It's how Kafka works at scale. The pattern is universal because the underlying problem — "a thing changes, other things need to react" — is universal.

The vocabulary: The thing that emits events is the Subject (or Publisher). The things that listen are Observers (or Subscribers). The Subject maintains a list of Observers and notifies them of state changes.

When to use: when a state change in one object requires updating others, and you don't know ahead of time how many objects need to be updated. Also when you want to decouple the thing that changes from the things that react.

Warning: Be careful with memory leaks. If you subscribe to an event but forget to unsubscribe when the listener is no longer needed, the listener stays in memory. That's why the on() method above returns an unsubscribe function.


Part 3: Command — Actions as Objects

You're building Splitwise. Users create expenses, which split a bill among group members. Users can edit expenses. Users can delete expenses. You want to implement undo — so if a user accidentally deletes an expense, they can undo it.

The problem: undo means you need to remember what happened, in what order, and how to reverse it. If your expense deletion is just a function call deleteExpense(id), there's nothing to hold onto. The action is gone as soon as it completes.

The Command pattern: turn each action into an object. An object with an execute() method and an undo() method. Now you can store commands in a list (the history), and walk backward through them to undo.

javascript
// The abstraction — every command must support execute and undo
class Command {
  execute() { throw new Error("execute() required"); }
  undo()    { throw new Error("undo() required"); }
}

class CreateExpenseCommand extends Command {
  #expenseService;
  #expenseData;
  #createdExpense = null;

  constructor(expenseService, expenseData) {
    super();
    this.#expenseService = expenseService;
    this.#expenseData = expenseData;
  }

  execute() {
    this.#createdExpense = this.#expenseService.create(this.#expenseData);
    return this.#createdExpense;
  }

  undo() {
    if (this.#createdExpense) {
      this.#expenseService.delete(this.#createdExpense.id);
    }
  }
}

class DeleteExpenseCommand extends Command {
  #expenseService;
  #expenseId;
  #deletedExpense = null; // store it so we can restore it

  constructor(expenseService, expenseId) {
    super();
    this.#expenseService = expenseService;
    this.#expenseId = expenseId;
  }

  execute() {
    this.#deletedExpense = this.#expenseService.getById(this.#expenseId);
    this.#expenseService.delete(this.#expenseId);
  }

  undo() {
    if (this.#deletedExpense) {
      this.#expenseService.restore(this.#deletedExpense);
    }
  }
}

// The invoker — executes commands and manages history
class CommandHistory {
  #history = [];

  execute(command) {
    command.execute();
    this.#history.push(command);
  }

  undo() {
    const last = this.#history.pop();
    if (last) last.undo();
  }
}

// Usage:
const history = new CommandHistory();
history.execute(new CreateExpenseCommand(expenseService, { amount: 300, description: "Dinner" }));
history.execute(new DeleteExpenseCommand(expenseService, "exp_456"));
history.undo(); // restores the deleted expense

The Command pattern gives you four things for free: undo/redo (store executed commands, walk backward), logging/audit trails (log each command as it executes), queueing (commands are objects — put them in a queue for async processing), and transaction-like behavior (a sequence of commands either all succeed or all roll back).

Your BullMQ job queue is a Command pattern. Each job is a command object. The queue is the invoker. The worker is what calls execute(). The job data is the command's parameters. Once you see the pattern, you see it everywhere.

When to use: when you need undo/redo, when you need to log all user actions for an audit trail, when you want to queue actions for later processing, or when you need to support transactions across multiple operations.


Vocabulary Map

TermWhat it actually is
StrategyAn algorithm extracted into a class — the context holds a reference and calls it, without knowing which one
Context (Strategy)The class that uses a strategy — it delegates the algorithm to the strategy object
Observer / SubscriberA function or object that registers to be called when a specific event happens
Subject / PublisherThe object that emits events — knows nothing about who's listening
Event-driven architectureSystem design where components communicate via events rather than direct calls — Observer at scale
Memory leak (observers)When a subscriber remains registered after it's no longer needed — stays in memory forever
CommandAn action encapsulated as an object, with execute() and undo() methods
Invoker (Command)The object that calls execute() on commands and manages history
Command QueueA list of command objects waiting to be executed — the basis of job queues
Audit trailA log of every Command that was executed — undo history, but write-only

Practice Problems

Problem 1 — Strategy + OCP

You're adding sorting to your SaaS dashboard. Users can sort a list of records by: name (alphabetical), date created (newest first), value (highest first), and relevance score (AI-assigned). Product wants to add more sort options regularly. Design a solution using Strategy that: adds new sort options without modifying existing code, lets the frontend pass a sort key as a string, and fails gracefully when an unknown sort key is provided.

Problem 2 — Observer memory leak

You build an ActivityFeed component in React that subscribes to a UserActivityService's events to update in real-time. Every time the user navigates to the Activity Feed page, it subscribes. Every time they navigate away, nothing happens. Six page visits later, there are six duplicate subscriptions. Each navigation that triggers an event fires the handler six times. Explain the exact memory model of what happened. Fix it in the component lifecycle.

Problem 3 — Command + Synthesis

You're building a text editor (think Notion or Google Docs lite). You need to support: typing text, deleting text, applying bold formatting, applying italic formatting, and unlimited undo/redo. Design the Command pattern for this. What does each command's execute() and undo() need to store? What does the CommandHistory look like when you support redo as well as undo?

Problem 4 — Observer at Scale

In your current design, Observer is synchronous: when a booking is confirmed, all handlers run sequentially before the confirm function returns. In production, you have 15 subscribers, some of which call external APIs that can be slow. What are the two problems this causes? Design an AsyncEventEmitter where emit() is non-blocking and returns a Promise that resolves when all handlers complete. Then explain: should handler failures be isolated from each other?

Problem 5 — Go Deeper

The Observer pattern has two variants: push model (the subject pushes data to observers — your emit(event, data) approach above) and pull model (the subject just says "something changed," and observers ask the subject for the new state). React's useEffect with dependencies is closer to which model? Redux + React-Redux is closer to which model? What are the trade-offs? Under what conditions does the push model become a problem for large applications?


What's Coming Next

You've seen patterns that change behavior at runtime (Strategy), patterns that decouple producers from consumers (Observer), and patterns that make actions first-class objects (Command). The next chapter covers structural patterns — how you wrap, adapt, and simplify existing objects. These are lighter, but they appear in real codebases constantly: Decorator wraps an object with new behavior, Adapter makes incompatible interfaces work together, Facade hides a complex subsystem behind a simple one. You already use Facade every time you import a library.


Chapter 5: Design Patterns — Structural

Structural patterns deal with the composition of classes and objects — how you build larger structures from smaller ones. They tend to be lighter than behavioral patterns, and you'll encounter them in code reviews and library design more often than in whiteboard interviews. But they're important enough to name and recognise, because once you do, you'll see them everywhere.


Part 1: Decorator — Add Behavior Without Changing the Class

You have a Logger class that writes messages to the console. You want to add timestamps to log messages. Then you want to add log levels (INFO, WARN, ERROR). Then you want to add caller context.

The naive approach: add all of this to the Logger class. It grows. When you need timestamp-only but not caller context, you're stuck — you can't selectively remove behavior.

The inheritance approach: TimestampLogger extends Logger, TimestampLevelLogger extends TimestampLogger. This creates a rigid hierarchy. To get the timestamp+level combination, you need a specific subclass. For N features, you potentially need 2^N subclasses. This is called a combinatorial explosion.

The Decorator pattern: Wrap an object in another object that adds behavior, while still matching the same interface. Decorators can be stacked.

javascript
class Logger {
  log(message) {
    console.log(message);
  }
}

// A decorator wraps a Logger and adds timestamp behavior
class TimestampDecorator {
  #logger;

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

  log(message) {
    this.#logger.log(`[${new Date().toISOString()}] ${message}`);
  }
}

class LevelDecorator {
  #logger;
  #level;

  constructor(logger, level = "INFO") {
    this.#logger = logger;
    this.#level = level;
  }

  log(message) {
    this.#logger.log(`[${this.#level}] ${message}`);
  }
}

class CallerDecorator {
  #logger;

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

  log(message, caller = "unknown") {
    this.#logger.log(`[${caller}] ${message}`);
  }
}

// Stack decorators:
const logger = new CallerDecorator(
  new LevelDecorator(
    new TimestampDecorator(
      new Logger()
    ), "WARN"
  )
);

logger.log("Payment failed", "PaymentService");
// Output: [PaymentService] [WARN] [2024-01-15T10:30:00Z] Payment failed

Each decorator calls the inner logger's log() method, adding its own behavior before or after. The interface is identical at every layer — everything has a log(message) method. You can mix and match any combination of decorators without creating subclasses.

When you've already seen this: Express middleware is a Decorator chain. Each middleware wraps the next. app.use(cors()), app.use(express.json()) — each adds behavior to the request handler. JavaScript's own Array.prototype.map(fn) conceptually decorates the array with a new view.

When to use: when you need to add responsibilities to objects dynamically and transparently, when extension by subclassing would produce an explosion of subclasses, when you want to compose behaviors from small independent pieces.


Part 2: Adapter — Make Incompatible Interfaces Work Together

You built your SaaS using Stripe. Every place in your codebase that processes a payment calls:

javascript
stripe.charges.create({ amount, currency, source: cardToken });

A year later, you need to support Razorpay. Razorpay's API is completely different:

javascript
razorpay.orders.create({ amount: amount * 100, currency }); // amount in paise!

Every callsite in your codebase would need to be updated. That's a large, risky refactor.

The Adapter pattern: Create a wrapper class that translates Razorpay's interface to look like Stripe's:

javascript
// The interface your codebase already uses (Stripe's shape)
class StripePaymentGateway {
  async createCharge({ amount, currency, source }) {
    return stripe.charges.create({ amount, currency, source });
  }
}

// Razorpay speaks differently — the Adapter translates
class RazorpayAdapter {
  async createCharge({ amount, currency, source }) {
    // Translate: Stripe uses amount in the smallest currency unit (paise),
    // Razorpay also uses smallest unit but expects different field names
    const order = await razorpay.orders.create({
      amount: amount, // both use smallest unit
      currency: currency,
      receipt: source,
    });
    return { id: order.id, amount: order.amount }; // return Stripe-shaped response
  }
}

// Your existing code doesn't change AT ALL
async function processPayment(gateway, paymentData) {
  return gateway.createCharge(paymentData); // same call, regardless of gateway
}

// In production with Stripe:
processPayment(new StripePaymentGateway(), { amount: 1000, currency: "INR", source: token });

// Switch to Razorpay — only this line changes
processPayment(new RazorpayAdapter(), { amount: 1000, currency: "INR", source: token });

The Adapter makes a foreign interface look like the interface your code expects. It's the electrical plug adapter of software — your laptop's plug doesn't change, the wall socket doesn't change, the adapter in between makes them compatible.

When to use: when you want to use an existing class but its interface doesn't match what you need, when you're integrating with a third-party library that uses a different API shape, when migrating between services (Stripe → Razorpay, MySQL → Postgres, Redis v3 → v4).


Part 3: Facade — Simplify a Complex Subsystem

You're building the checkout flow for your SaaS. Checkout involves: verifying inventory, creating an order, charging the payment, sending a confirmation email, updating the user's subscription tier, and logging the transaction.

These are six separate services, each with their own API. A new engineer joins and needs to implement checkout — they'd have to know all six services, their initialization order, their error handling requirements, and the orchestration logic.

The Facade pattern: Create a single class that provides a simple interface to a complex subsystem. Hide the complexity behind one entry point.

javascript
class CheckoutFacade {
  #inventoryService;
  #orderService;
  #paymentService;
  #emailService;
  #subscriptionService;
  #auditLog;

  constructor(deps) {
    this.#inventoryService   = deps.inventoryService;
    this.#orderService       = deps.orderService;
    this.#paymentService     = deps.paymentService;
    this.#emailService       = deps.emailService;
    this.#subscriptionService = deps.subscriptionService;
    this.#auditLog           = deps.auditLog;
  }

  async checkout(userId, planId, paymentMethod) {
    // The complex orchestration is hidden here
    await this.#inventoryService.reserve(planId);
    
    const order = await this.#orderService.create(userId, planId);
    
    try {
      const payment = await this.#paymentService.charge(
        paymentMethod, order.total
      );
      await this.#subscriptionService.activate(userId, planId);
      await this.#emailService.sendConfirmation(userId, order);
      await this.#auditLog.record("checkout_completed", { userId, orderId: order.id });
      return { success: true, orderId: order.id };
    } catch (error) {
      await this.#inventoryService.release(planId);
      await this.#orderService.cancel(order.id);
      throw error;
    }
  }
}

// The caller only needs to know one thing:
const checkout = new CheckoutFacade(deps);
await checkout.checkout(userId, "pro_monthly", paymentMethod);

Every library you use is a Facade. mongoose.connect() hides the complexity of TCP connection establishment, authentication, connection pooling, and heartbeat management. axios.get() hides HTTP request construction, response parsing, and error mapping. The Facade pattern is the most commonly used pattern in the entire industry — you just never called it by name.

When to use: when a subsystem is complex and most callers only need a simple use case, when you want to decouple callers from the internal workings of a subsystem, when you want a clean public API over a messy implementation.


Vocabulary Map

TermWhat it actually is
Structural PatternA pattern about how classes and objects are composed — wrapping, adapting, simplifying
DecoratorA wrapper that adds behavior to an object while matching its interface — stackable
Combinatorial explosionThe problem where N optional features require 2^N subclasses — the problem Decorator solves
AdapterA wrapper that translates one interface to another — the plug adapter of software
FacadeA simplified front door to a complex subsystem — hides internal complexity from callers
SubsystemA group of related classes that accomplish a complex function together
WrapperA class that holds a reference to another object and delegates calls to it — what Decorator and Adapter both are

Practice Problems

Problem 1 — Decorator chain

Design a caching decorator for an HTTP service. The base HttpService always makes a real network call. The CacheDecorator wraps it and returns cached responses for requests made within the last 5 minutes. The RetryDecorator wraps it and retries failed requests up to 3 times with exponential backoff. A caller wants caching + retry. Write the class structure. What order should the decorators be stacked, and why does order matter?

Problem 2 — Adapter for migration

Your SaaS stores files on Cloudflare R2. The R2 client API is: r2.put(key, buffer), r2.get(key), r2.delete(key). You want to migrate to AWS S3, which has: s3.upload({ Bucket, Key, Body }), s3.getObject({ Bucket, Key }), s3.deleteObject({ Bucket, Key }). Write the Adapter that makes S3 look like R2 to your existing code. What's the one thing an Adapter can't always hide, and what's the pragmatic solution?

Problem 3 — Facade boundaries

A teammate argues: "Facades are just God objects with a nicer name. They still do too many things." Draw the line between a legitimate Facade and a God Object. What structural properties differentiate them? Can a Facade violate SRP? Under what conditions?


What's Coming Next

You now have the vocabulary and the mental models: OOP mechanics, SOLID principles, and six design patterns — three creational, three behavioral, two structural. In Chapter 6, we step back from the patterns themselves and learn how to use them in an interview. An LLD interview is not about recalling patterns — it's about a structured method for decomposing a problem into entities, relationships, and behaviors. The method is transferable to any problem. We'll learn it, then in Chapter 7, we'll apply it to six classic problems with complete solutions.


Chapter 6: The LLD Interview Method

An LLD interview typically starts with: "Design a parking lot system" or "Design BookMyShow." You have 45 minutes. Most candidates start sketching classes immediately — and get lost within 10 minutes because they didn't understand the problem before designing the solution.

The method in this chapter takes 10-15 minutes to execute before writing a single class. Engineers who follow it consistently produce cleaner, more complete designs than engineers who jump to code. It has five phases.


Phase 1: Requirements — What Are We Actually Building?

The single most common failure mode in LLD interviews is solving a different problem than the interviewer intended. "Design a parking lot" is underspecified. Are we tracking individual spots? Multiple floors? Multiple vehicle types? Time-based pricing? Entry/exit barriers?

Spend 3-5 minutes here. Ask clarifying questions. For every system, there are two types of requirements:

Functional requirements — what the system does:

  • What are the core use cases? (Book a ticket, cancel a booking, check availability)
  • What types of entities exist? (Users, shows, seats, theatres)
  • What are the state transitions? (Seat goes from available → held → booked → released)

Non-functional requirements — constraints on how it does it:

  • Scale: hundreds of transactions per second, or 2?
  • Concurrency: what happens when two users try to book the same seat simultaneously?
  • Persistence: are we designing in-memory for this exercise, or production-grade?

In a 45-minute interview, your interviewer will tell you which to focus on. In a real architecture, both matter.

Write down 4-6 core use cases before doing anything else. For BookMyShow:

  1. User searches for movies by city/date
  2. User selects a show and views available seats
  3. User selects seats and initiates booking
  4. System holds seats temporarily, user pays, system confirms booking
  5. User cancels booking, seats are released
  6. Admin adds new shows/theatres

These use cases drive everything that follows. Every class you create should serve at least one use case.


Phase 2: Entities — What Are the Nouns?

Go through your use cases and underline every noun — every real-world thing in the domain. These are your candidate entities:

From BookMyShow: Movie, Theatre, Show, Screen, Seat, Booking, User, Payment, City

Not every noun becomes a class. Apply two filters:

Filter 1 — Does it have both data and behavior? A noun that's just a value (like "price" or "date") is a property, not an entity. A noun that has its own state and methods is an entity.

Filter 2 — Does it need an identity? A specific Seat (Row C, Seat 12) needs an ID. A "price type" might just be a string enum.

After filtering, you typically get 5-8 core entities for a moderately complex system. More than 10 and you might be over-engineering.

Sketch the entities and their primary properties:

code
Movie        { id, title, duration, language, genre }
Theatre      { id, name, city, address, screens[] }
Screen       { id, theatreId, name, totalSeats, seatLayout }
Show         { id, movieId, screenId, startTime, endTime, status }
Seat         { id, screenId, row, number, type, status }
Booking      { id, userId, showId, seats[], totalAmount, status, createdAt }
User         { id, name, email, bookings[] }
Payment      { id, bookingId, amount, gateway, status }

Phase 3: Relationships — How Do Entities Connect?

Define the relationships between entities. There are four types:

One-to-one: A Payment belongs to exactly one Booking. A Booking has exactly one Payment.

One-to-many: A Theatre has many Screens. A Screen has many Seats. A Show is on one Screen.

Many-to-many: A Booking has many Seats. A Seat can be part of many Bookings (over time, not concurrently).

Inheritance/Is-A: RegularSeat, PremiumSeat, and VIPSeat might all be types of Seat with different pricing. Or they might just be a type property with a pricing enum — use judgement.

Draw this as a simple diagram:

code
User ──1──── Booking ──M──── Seat
              |
             1|
              Payment

Theatre ──1──── Screen ──1──── Show
                  |              |
                  └──── Seat ────┘

This diagram tells you which class holds references to which other classes.


Phase 4: Behavior — What Are the Verbs?

For each use case, trace the sequence of operations across your entities. This is where you discover methods.

Use case: User books seats

  1. Show.getAvailableSeats() — query which seats have status "available"
  2. Show.holdSeats(seatIds, userId) — temporarily lock seats for 10 minutes
  3. Payment.process() — charge the user
  4. Booking.confirm() — mark booking as confirmed, emit event
  5. Seat.markAsBooked(bookingId) — update seat status

This reveals:

  • Show needs a holdSeats() method and timeout logic
  • Seat needs a status state machine: available → held → booked → released
  • Booking needs to go through states: pending → confirmed → cancelled
  • There's a concurrency problem: if two users call holdSeats() simultaneously for the same seat, you have a race condition — flag this for the interviewer

State transitions are critical to identify. For every entity with a status/state field, draw the transition diagram:

code
Seat:     available → held → booked
                ↓          ↓
           (timeout)   (cancellation)
                ↓          ↓
           available ← available

Booking:  pending → confirmed
              ↓          ↓
          (timeout)  (cancellation)
              ↓          ↓
          cancelled  cancelled

Phase 5: Code — The Skeleton

Only now do you write code. And in a 45-minute interview, you won't write full implementations. You write a skeleton that demonstrates: entity structure, key relationships, critical methods, design patterns applied.

Focus on the 20% of code that demonstrates 80% of your understanding:

  • The constructors and key properties of the main entities
  • The most interesting method (the one with the most logic)
  • The place where a design pattern is applied
  • The concurrency-sensitive operation (if any)

For BookMyShow, the most interesting code is the seat booking flow:

javascript
class Show {
  #id;
  #seats;
  #heldSeats = new Map(); // seatId → { userId, expiresAt }

  holdSeats(seatIds, userId) {
    // Check availability
    for (const seatId of seatIds) {
      const seat = this.#seats.get(seatId);
      if (!seat || seat.getStatus() !== "available") {
        throw new Error(`Seat ${seatId} is not available`);
      }
      const held = this.#heldSeats.get(seatId);
      if (held && held.expiresAt > Date.now()) {
        throw new Error(`Seat ${seatId} is currently held`);
      }
    }

    // Hold the seats
    const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes
    for (const seatId of seatIds) {
      this.#heldSeats.set(seatId, { userId, expiresAt });
    }

    // Schedule release
    setTimeout(() => this.#releaseExpiredHolds(), 10 * 60 * 1000);

    return expiresAt;
  }

  #releaseExpiredHolds() {
    const now = Date.now();
    for (const [seatId, hold] of this.#heldSeats) {
      if (hold.expiresAt <= now) {
        this.#heldSeats.delete(seatId);
      }
    }
  }

  confirmSeats(seatIds, userId) {
    for (const seatId of seatIds) {
      const hold = this.#heldSeats.get(seatId);
      if (!hold || hold.userId !== userId) {
        throw new Error(`Seat ${seatId} not held by this user`);
      }
      if (hold.expiresAt <= Date.now()) {
        throw new Error(`Hold for seat ${seatId} has expired`);
      }
      this.#heldSeats.delete(seatId);
      this.#seats.get(seatId).markAsBooked();
    }
  }
}

Always tell the interviewer:

  • What design pattern you're applying (Observer for notifications, Strategy for pricing, Factory for payment gateways)
  • What you're deliberately leaving out and why ("I'd use a distributed lock for the seat hold in production, but for this exercise I'm using in-memory state")
  • What the hardest problem is (concurrency, consistency, scale)

The Method Summary

code
Phase 1 — Requirements (5 min)
  → List 4-6 core use cases
  → Note any scale/concurrency concerns

Phase 2 — Entities (5 min)
  → Underline nouns in use cases
  → Filter: does it have behavior? does it need an identity?
  → Sketch entity properties

Phase 3 — Relationships (3 min)
  → One-to-many, many-to-many, inheritance
  → Draw the entity-relationship sketch

Phase 4 — Behavior (5 min)
  → Trace each use case through entity methods
  → Draw state transition diagrams for status fields
  → Identify where patterns apply (Factory, Observer, Strategy)

Phase 5 — Code (20 min)
  → Write class skeletons
  → Implement the most interesting method fully
  → Apply patterns where identified
  → Narrate what you're doing and why

Chapter 7: Classic Problems — Full Solutions


Problem 1: Parking Lot

Requirements

Functional: vehicles enter/exit, spots are assigned by type, fees are calculated. Entities: ParkingLot, ParkingFloor, ParkingSpot, Vehicle, Ticket, Payment.

Vehicle types: motorcycle, car, truck (each needs different spot sizes). Spot types: motorcycle, compact, large.

Entities

javascript
// Enums
const VehicleType = Object.freeze({ MOTORCYCLE: "motorcycle", CAR: "car", TRUCK: "truck" });
const SpotType    = Object.freeze({ MOTORCYCLE: "motorcycle", COMPACT: "compact", LARGE: "large" });
const SpotStatus  = Object.freeze({ AVAILABLE: "available", OCCUPIED: "occupied" });

class Vehicle {
  #licensePlate;
  #type;

  constructor(licensePlate, type) {
    this.#licensePlate = licensePlate;
    this.#type = type;
  }

  getType()         { return this.#type; }
  getLicensePlate() { return this.#licensePlate; }
}

class ParkingSpot {
  #id;
  #type;
  #status = SpotStatus.AVAILABLE;
  #currentVehicle = null;

  constructor(id, type) {
    this.#id = id;
    this.#type = type;
  }

  isAvailable() { return this.#status === SpotStatus.AVAILABLE; }
  getType()     { return this.#type; }
  getId()       { return this.#id; }

  assignVehicle(vehicle) {
    if (!this.isAvailable()) throw new Error("Spot already occupied");
    this.#currentVehicle = vehicle;
    this.#status = SpotStatus.OCCUPIED;
  }

  removeVehicle() {
    this.#currentVehicle = null;
    this.#status = SpotStatus.AVAILABLE;
  }
}

class Ticket {
  #id;
  #vehicle;
  #spot;
  #entryTime;
  #exitTime = null;

  constructor(vehicle, spot) {
    this.#id = generateId();
    this.#vehicle = vehicle;
    this.#spot = spot;
    this.#entryTime = Date.now();
  }

  checkout() {
    this.#exitTime = Date.now();
    this.#spot.removeVehicle();
  }

  getDurationMinutes() {
    const end = this.#exitTime || Date.now();
    return Math.ceil((end - this.#entryTime) / 60000);
  }

  getSpot()    { return this.#spot; }
  getVehicle() { return this.#vehicle; }
}

Strategy Pattern for Pricing

javascript
class PricingStrategy {
  calculate(durationMinutes) { throw new Error("Required"); }
}

class HourlyPricing extends PricingStrategy {
  #ratePerHour;
  constructor(ratePerHour) { super(); this.#ratePerHour = ratePerHour; }
  calculate(durationMinutes) {
    return Math.ceil(durationMinutes / 60) * this.#ratePerHour;
  }
}

class DailyCapPricing extends PricingStrategy {
  #hourlyRate;
  #dailyCap;
  constructor(hourlyRate, dailyCap) {
    super();
    this.#hourlyRate = hourlyRate;
    this.#dailyCap = dailyCap;
  }
  calculate(durationMinutes) {
    const hours = Math.ceil(durationMinutes / 60);
    const days = Math.ceil(hours / 24);
    const hourlyTotal = hours * this.#hourlyRate;
    return Math.min(hourlyTotal, days * this.#dailyCap);
  }
}

Factory for Spot Assignment

javascript
class SpotAssigner {
  // Maps vehicle type to compatible spot types (in priority order)
  static #spotPreferences = {
    [VehicleType.MOTORCYCLE]: [SpotType.MOTORCYCLE, SpotType.COMPACT],
    [VehicleType.CAR]:        [SpotType.COMPACT, SpotType.LARGE],
    [VehicleType.TRUCK]:      [SpotType.LARGE],
  };

  static assign(vehicle, floors) {
    const preferredTypes = this.#spotPreferences[vehicle.getType()];
    
    for (const spotType of preferredTypes) {
      for (const floor of floors) {
        const spot = floor.getAvailableSpot(spotType);
        if (spot) return spot;
      }
    }
    return null; // no spot available
  }
}

class ParkingLot {
  #floors;
  #pricingStrategies; // Map of SpotType → PricingStrategy
  #activeTickets = new Map(); // licensePlate → Ticket

  constructor(floors, pricingStrategies) {
    this.#floors = floors;
    this.#pricingStrategies = pricingStrategies;
  }

  enter(vehicle) {
    if (this.#activeTickets.has(vehicle.getLicensePlate())) {
      throw new Error("Vehicle already parked");
    }
    const spot = SpotAssigner.assign(vehicle, this.#floors);
    if (!spot) throw new Error("No spot available");
    
    spot.assignVehicle(vehicle);
    const ticket = new Ticket(vehicle, spot);
    this.#activeTickets.set(vehicle.getLicensePlate(), ticket);
    return ticket;
  }

  exit(licensePlate) {
    const ticket = this.#activeTickets.get(licensePlate);
    if (!ticket) throw new Error("No active ticket");
    
    ticket.checkout();
    const strategy = this.#pricingStrategies.get(ticket.getSpot().getType());
    const fee = strategy.calculate(ticket.getDurationMinutes());
    this.#activeTickets.delete(licensePlate);
    return { ticket, fee };
  }
}

Problem 2: BookMyShow

Entities and Relationships

javascript
class Movie {
  constructor(id, title, durationMinutes, language, genre) {
    this.id = id;
    this.title = title;
    this.durationMinutes = durationMinutes;
    this.language = language;
    this.genre = genre;
  }
}

class Screen {
  #id;
  #name;
  #seats = new Map(); // seatId → Seat

  constructor(id, name) { this.#id = id; this.#name = name; }

  addSeat(seat)    { this.#seats.set(seat.getId(), seat); }
  getSeat(seatId)  { return this.#seats.get(seatId); }
  getAllSeats()     { return [...this.#seats.values()]; }
  getId()          { return this.#id; }
}

class Seat {
  #id;
  #row;
  #number;
  #type; // "regular", "premium", "vip"
  #status = "available"; // available | held | booked

  constructor(id, row, number, type) {
    this.#id = id; this.#row = row;
    this.#number = number; this.#type = type;
  }

  getId()       { return this.#id; }
  getType()     { return this.#type; }
  getStatus()   { return this.#status; }
  isAvailable() { return this.#status === "available"; }
  hold()        { this.#status = "held"; }
  book()        { this.#status = "booked"; }
  release()     { this.#status = "available"; }

  getLabel()    { return `${this.#row}${this.#number}`; }
}

// SeatPricingStrategy — different prices for different seat types
class SeatPricingStrategy {
  #prices;
  constructor(prices) { this.#prices = prices; } // { regular: 200, premium: 400, vip: 800 }
  getPrice(seatType) { return this.#prices[seatType] || 0; }
  calculateTotal(seats) { return seats.reduce((sum, s) => sum + this.getPrice(s.getType()), 0); }
}

class Show {
  #id;
  #movie;
  #screen;
  #startTime;
  #pricingStrategy;
  #heldSeats = new Map(); // seatId → { userId, expiresAt }
  #HOLD_DURATION = 10 * 60 * 1000; // 10 min

  constructor(id, movie, screen, startTime, pricingStrategy) {
    this.#id = id; this.#movie = movie;
    this.#screen = screen; this.#startTime = startTime;
    this.#pricingStrategy = pricingStrategy;
  }

  getAvailableSeats() {
    return this.#screen.getAllSeats().filter(seat => {
      if (!seat.isAvailable()) return false;
      const hold = this.#heldSeats.get(seat.getId());
      return !hold || hold.expiresAt <= Date.now();
    });
  }

  holdSeats(seatIds, userId) {
    // Validate all seats are available
    for (const seatId of seatIds) {
      const seat = this.#screen.getSeat(seatId);
      if (!seat?.isAvailable()) throw new Error(`Seat ${seatId} unavailable`);
      const hold = this.#heldSeats.get(seatId);
      if (hold && hold.expiresAt > Date.now()) {
        throw new Error(`Seat ${seatId} is held`);
      }
    }

    const expiresAt = Date.now() + this.#HOLD_DURATION;
    for (const seatId of seatIds) {
      this.#heldSeats.set(seatId, { userId, expiresAt });
    }
    return expiresAt;
  }

  calculateTotal(seatIds) {
    const seats = seatIds.map(id => this.#screen.getSeat(id));
    return this.#pricingStrategy.calculateTotal(seats);
  }

  confirmSeats(seatIds, userId) {
    for (const seatId of seatIds) {
      const hold = this.#heldSeats.get(seatId);
      if (!hold || hold.userId !== userId || hold.expiresAt <= Date.now()) {
        throw new Error(`Hold invalid for seat ${seatId}`);
      }
      this.#heldSeats.delete(seatId);
      this.#screen.getSeat(seatId).book();
    }
  }

  getId() { return this.#id; }
  getMovie() { return this.#movie; }
  getStartTime() { return this.#startTime; }
}

class Booking {
  #id;
  #userId;
  #show;
  #seatIds;
  #totalAmount;
  #status = "pending"; // pending | confirmed | cancelled
  #createdAt;

  constructor(userId, show, seatIds, totalAmount) {
    this.#id = generateId();
    this.#userId = userId;
    this.#show = show;
    this.#seatIds = seatIds;
    this.#totalAmount = totalAmount;
    this.#createdAt = Date.now();
  }

  confirm() {
    if (this.#status !== "pending") throw new Error("Can only confirm pending booking");
    this.#show.confirmSeats(this.#seatIds, this.#userId);
    this.#status = "confirmed";
  }

  cancel() {
    if (this.#status === "cancelled") throw new Error("Already cancelled");
    this.#seatIds.forEach(id => this.#show.getScreen().getSeat(id).release());
    this.#status = "cancelled";
  }

  getId()          { return this.#id; }
  getStatus()      { return this.#status; }
  getTotalAmount() { return this.#totalAmount; }
}

// BookingService — orchestrates the flow
class BookingService {
  #shows = new Map();

  createBooking(userId, showId, seatIds) {
    const show = this.#shows.get(showId);
    if (!show) throw new Error("Show not found");

    show.holdSeats(seatIds, userId);
    const total = show.calculateTotal(seatIds);
    return new Booking(userId, show, seatIds, total);
  }

  confirmBooking(booking, payment) {
    payment.process();
    booking.confirm();
    return booking;
  }
}

Problem 3: URL Shortener (LLD)

Core Concept

A short URL maps to a long URL. The key operations: shorten a URL, resolve a short code, optionally track analytics.

Entities

javascript
class ShortURL {
  #code;
  #originalUrl;
  #createdAt;
  #expiresAt;
  #clickCount = 0;
  #createdBy; // userId

  constructor(code, originalUrl, createdBy, ttlDays = null) {
    this.#code = code;
    this.#originalUrl = originalUrl;
    this.#createdBy = createdBy;
    this.#createdAt = Date.now();
    this.#expiresAt = ttlDays
      ? Date.now() + (ttlDays * 24 * 60 * 60 * 1000)
      : null;
  }

  isExpired() {
    return this.#expiresAt !== null && Date.now() > this.#expiresAt;
  }

  recordClick() { this.#clickCount++; }

  getCode()        { return this.#code; }
  getOriginalUrl() { return this.#originalUrl; }
  getClickCount()  { return this.#clickCount; }
}

Strategy for Code Generation

javascript
class CodeGenerationStrategy {
  generate(url) { throw new Error("Required"); }
}

class RandomCodeStrategy extends CodeGenerationStrategy {
  #length;
  constructor(length = 7) { super(); this.#length = length; }
  generate() {
    const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    return Array.from({ length: this.#length }, () =>
      chars[Math.floor(Math.random() * chars.length)]
    ).join("");
  }
}

class HashCodeStrategy extends CodeGenerationStrategy {
  #length;
  constructor(length = 7) { super(); this.#length = length; }
  generate(url) {
    // MD5 or SHA of URL, truncated — collision possible
    const hash = crypto.createHash("md5").update(url).digest("hex");
    return hash.substring(0, this.#length);
  }
}

class URLShortenerService {
  #urlMap = new Map();  // code → ShortURL
  #codeStrategy;
  #MAX_RETRIES = 5;

  constructor(codeStrategy = new RandomCodeStrategy()) {
    this.#codeStrategy = codeStrategy;
  }

  shorten(originalUrl, userId, customCode = null, ttlDays = null) {
    let code = customCode;

    if (!code) {
      let attempts = 0;
      do {
        code = this.#codeStrategy.generate(originalUrl);
        attempts++;
        if (attempts > this.#MAX_RETRIES) throw new Error("Could not generate unique code");
      } while (this.#urlMap.has(code));
    } else if (this.#urlMap.has(code)) {
      throw new Error("Custom code already in use");
    }

    const shortUrl = new ShortURL(code, originalUrl, userId, ttlDays);
    this.#urlMap.set(code, shortUrl);
    return shortUrl;
  }

  resolve(code) {
    const shortUrl = this.#urlMap.get(code);
    if (!shortUrl) throw new Error("URL not found");
    if (shortUrl.isExpired()) {
      this.#urlMap.delete(code);
      throw new Error("URL has expired");
    }
    shortUrl.recordClick();
    return shortUrl.getOriginalUrl();
  }
}

Problem 4: Splitwise

Core Logic

Expenses are split among group members. The system tracks who owes whom. When an expense is added, balances update. The simplify debts feature finds the minimum number of transactions to settle all debts.

javascript
class User {
  #id;
  #name;
  #email;

  constructor(id, name, email) {
    this.#id = id; this.#name = name; this.#email = email;
  }

  getId()   { return this.#id; }
  getName() { return this.#name; }
}

// SplitStrategy — how an expense is divided
class SplitStrategy {
  calculateSplits(amount, participants) {
    throw new Error("Required");
  }
}

class EqualSplit extends SplitStrategy {
  calculateSplits(amount, participants) {
    const share = amount / participants.length;
    return participants.map(userId => ({ userId, share: Math.round(share * 100) / 100 }));
  }
}

class ExactSplit extends SplitStrategy {
  #exactAmounts; // Map of userId → amount
  constructor(exactAmounts) { super(); this.#exactAmounts = exactAmounts; }
  calculateSplits(amount, participants) {
    const total = [...this.#exactAmounts.values()].reduce((a, b) => a + b, 0);
    if (Math.abs(total - amount) > 0.01) throw new Error("Exact amounts must sum to total");
    return participants.map(userId => ({ userId, share: this.#exactAmounts.get(userId) }));
  }
}

class PercentageSplit extends SplitStrategy {
  #percentages; // Map of userId → percentage
  constructor(percentages) { super(); this.#percentages = percentages; }
  calculateSplits(amount, participants) {
    const totalPct = [...this.#percentages.values()].reduce((a, b) => a + b, 0);
    if (Math.abs(totalPct - 100) > 0.01) throw new Error("Percentages must sum to 100");
    return participants.map(userId => ({
      userId,
      share: (this.#percentages.get(userId) / 100) * amount
    }));
  }
}

class Expense {
  #id;
  #description;
  #amount;
  #paidBy; // userId
  #splits; // [{ userId, share }]
  #createdAt;

  constructor(id, description, amount, paidBy, splits) {
    this.#id = id; this.#description = description;
    this.#amount = amount; this.#paidBy = paidBy;
    this.#splits = splits; this.#createdAt = Date.now();
  }

  getSplits()    { return this.#splits; }
  getPaidBy()    { return this.#paidBy; }
  getAmount()    { return this.#amount; }
  getId()        { return this.#id; }
}

class Group {
  #id;
  #name;
  #members = new Set();
  #expenses = [];
  #balances = new Map(); // "userId1:userId2" → amount (positive = userId1 owes userId2)

  constructor(id, name) { this.#id = id; this.#name = name; }

  addMember(user) { this.#members.add(user.getId()); }

  addExpense(description, amount, paidBy, participantIds, splitStrategy) {
    const splits = splitStrategy.calculateSplits(amount, participantIds);

    const expense = new Expense(generateId(), description, amount, paidBy, splits);
    this.#expenses.push(expense);

    // Update balances: each participant owes paidBy their share
    for (const { userId, share } of splits) {
      if (userId === paidBy) continue; // payer doesn't owe themselves
      this.#updateBalance(userId, paidBy, share);
    }

    return expense;
  }

  #updateBalance(debtor, creditor, amount) {
    const key = `${debtor}:${creditor}`;
    const reverseKey = `${creditor}:${debtor}`;

    if (this.#balances.has(reverseKey)) {
      // creditor already owes debtor — reduce the reverse debt
      const existing = this.#balances.get(reverseKey);
      if (existing > amount) {
        this.#balances.set(reverseKey, existing - amount);
      } else if (existing < amount) {
        this.#balances.delete(reverseKey);
        this.#balances.set(key, amount - existing);
      } else {
        this.#balances.delete(reverseKey);
      }
    } else {
      this.#balances.set(key, (this.#balances.get(key) || 0) + amount);
    }
  }

  getBalances() {
    const result = [];
    for (const [key, amount] of this.#balances) {
      if (amount > 0.01) {
        const [debtor, creditor] = key.split(":");
        result.push({ debtor, creditor, amount: Math.round(amount * 100) / 100 });
      }
    }
    return result;
  }

  // Greedy algorithm to minimize the number of settlement transactions
  simplifyDebts() {
    // Net balance per person: positive = owed to them, negative = they owe
    const net = new Map();
    for (const memberId of this.#members) net.set(memberId, 0);

    for (const [key, amount] of this.#balances) {
      const [debtor, creditor] = key.split(":");
      net.set(debtor,   (net.get(debtor)   || 0) - amount);
      net.set(creditor, (net.get(creditor) || 0) + amount);
    }

    const creditors = []; // owed money
    const debtors   = []; // owe money

    for (const [userId, balance] of net) {
      if (balance > 0.01)  creditors.push({ userId, amount: balance });
      if (balance < -0.01) debtors.push({ userId, amount: -balance });
    }

    const transactions = [];
    let i = 0, j = 0;
    while (i < creditors.length && j < debtors.length) {
      const settleAmount = Math.min(creditors[i].amount, debtors[j].amount);
      transactions.push({
        from: debtors[j].userId,
        to: creditors[i].userId,
        amount: Math.round(settleAmount * 100) / 100
      });
      creditors[i].amount -= settleAmount;
      debtors[j].amount   -= settleAmount;
      if (creditors[i].amount < 0.01) i++;
      if (debtors[j].amount < 0.01)   j++;
    }

    return transactions;
  }
}

Problem 5: Snake and Ladder

javascript
class Board {
  #size;
  #snakes = new Map();  // headPosition → tailPosition
  #ladders = new Map(); // bottomPosition → topPosition

  constructor(size = 100) {
    this.#size = size;
  }

  addSnake(head, tail) {
    if (head <= tail) throw new Error("Snake head must be above tail");
    this.#snakes.set(head, tail);
  }

  addLadder(bottom, top) {
    if (bottom >= top) throw new Error("Ladder bottom must be below top");
    this.#ladders.set(bottom, top);
  }

  getFinalPosition(position) {
    if (this.#snakes.has(position)) return this.#snakes.get(position);
    if (this.#ladders.has(position)) return this.#ladders.get(position);
    return position;
  }

  getSize() { return this.#size; }
}

class Dice {
  #faces;
  constructor(faces = 6) { this.#faces = faces; }
  roll() { return Math.floor(Math.random() * this.#faces) + 1; }
}

class Player {
  #id;
  #name;
  #position = 0;

  constructor(id, name) { this.#id = id; this.#name = name; }

  move(steps, board) {
    const newPos = this.#position + steps;
    if (newPos > board.getSize()) return; // can't overshoot the end
    this.#position = board.getFinalPosition(newPos);
  }

  hasWon(board)   { return this.#position === board.getSize(); }
  getPosition()   { return this.#position; }
  getName()       { return this.#name; }
}

class Game {
  #board;
  #dice;
  #players;
  #currentPlayerIndex = 0;
  #status = "active"; // active | finished

  constructor(board, dice, players) {
    this.#board = board;
    this.#dice = dice;
    this.#players = players;
  }

  takeTurn() {
    if (this.#status !== "active") throw new Error("Game is over");

    const player = this.#players[this.#currentPlayerIndex];
    const roll = this.#dice.roll();

    player.move(roll, this.#board);

    if (player.hasWon(this.#board)) {
      this.#status = "finished";
      return { player, roll, won: true };
    }

    this.#currentPlayerIndex = (this.#currentPlayerIndex + 1) % this.#players.length;
    return { player, roll, position: player.getPosition(), won: false };
  }

  getStatus() { return this.#status; }
}

Problem 6: Ride Sharing (Uber LLD)

Requirements

Riders request rides. Drivers accept. Pricing varies by type. Ride goes through states: requested → accepted → in-progress → completed.

javascript
// Strategy for pricing
class RidePricingStrategy {
  calculateFare(distanceKm, durationMinutes) {
    throw new Error("Required");
  }
}

class StandardPricing extends RidePricingStrategy {
  calculateFare(distance, duration) {
    return 30 + (distance * 12) + (duration * 1.5);
  }
}

class PremiumPricing extends RidePricingStrategy {
  calculateFare(distance, duration) {
    return 80 + (distance * 20) + (duration * 2);
  }
}

class SurgePricing extends RidePricingStrategy {
  #base;
  #multiplier;
  constructor(basePricing, multiplier) {
    super();
    this.#base = basePricing;
    this.#multiplier = multiplier;
  }
  calculateFare(distance, duration) {
    return this.#base.calculateFare(distance, duration) * this.#multiplier;
  }
}

class Location {
  constructor(latitude, longitude, address) {
    this.latitude = latitude;
    this.longitude = longitude;
    this.address = address;
  }

  distanceTo(other) {
    // Haversine formula simplified
    const dx = (this.longitude - other.longitude) * 111 * Math.cos(this.latitude * Math.PI / 180);
    const dy = (this.latitude - other.latitude) * 111;
    return Math.sqrt(dx * dx + dy * dy);
  }
}

class Driver {
  #id;
  #name;
  #vehicleType; // "standard" | "premium"
  #isAvailable = true;
  #currentLocation;

  constructor(id, name, vehicleType, location) {
    this.#id = id; this.#name = name;
    this.#vehicleType = vehicleType;
    this.#currentLocation = location;
  }

  accept(ride) {
    if (!this.#isAvailable) throw new Error("Driver not available");
    this.#isAvailable = false;
    ride.assignDriver(this);
  }

  complete() { this.#isAvailable = true; }

  getId()          { return this.#id; }
  getName()        { return this.#name; }
  getVehicleType() { return this.#vehicleType; }
  isAvailable()    { return this.#isAvailable; }
  getLocation()    { return this.#currentLocation; }
}

class Ride {
  #id;
  #rider;
  #driver = null;
  #pickup;
  #dropoff;
  #vehicleType;
  #pricingStrategy;
  #status = "requested"; // requested | accepted | in_progress | completed | cancelled
  #startTime = null;
  #endTime = null;
  #fare = null;

  constructor(id, rider, pickup, dropoff, vehicleType, pricingStrategy) {
    this.#id = id; this.#rider = rider;
    this.#pickup = pickup; this.#dropoff = dropoff;
    this.#vehicleType = vehicleType;
    this.#pricingStrategy = pricingStrategy;
  }

  assignDriver(driver) {
    this.#driver = driver;
    this.#status = "accepted";
  }

  startRide() {
    if (this.#status !== "accepted") throw new Error("Ride must be accepted first");
    this.#status = "in_progress";
    this.#startTime = Date.now();
  }

  completeRide() {
    if (this.#status !== "in_progress") throw new Error("Ride must be in progress");
    this.#endTime = Date.now();
    this.#status = "completed";

    const distance = this.#pickup.distanceTo(this.#dropoff);
    const duration = (this.#endTime - this.#startTime) / 60000; // minutes
    this.#fare = this.#pricingStrategy.calculateFare(distance, duration);

    if (this.#driver) this.#driver.complete();
    return this.#fare;
  }

  cancel() {
    if (["completed", "in_progress"].includes(this.#status)) {
      throw new Error("Cannot cancel a completed or in-progress ride");
    }
    this.#status = "cancelled";
    if (this.#driver) this.#driver.complete();
  }

  getId()     { return this.#id; }
  getStatus() { return this.#status; }
  getFare()   { return this.#fare; }
  getVehicleType() { return this.#vehicleType; }
}

// Observer: notify rider and driver of state changes
class RideService extends EventEmitter {
  #rides = new Map();
  #drivers = new Map();
  #pricingStrategies;

  constructor(pricingStrategies) {
    super();
    this.#pricingStrategies = pricingStrategies;
  }

  registerDriver(driver) {
    this.#drivers.set(driver.getId(), driver);
  }

  requestRide(rider, pickup, dropoff, vehicleType) {
    const isSurge = this.#checkSurge(pickup);
    const baseStrategy = this.#pricingStrategies.get(vehicleType);
    const strategy = isSurge ? new SurgePricing(baseStrategy, 1.8) : baseStrategy;

    const ride = new Ride(generateId(), rider, pickup, dropoff, vehicleType, strategy);
    this.#rides.set(ride.getId(), ride);
    this.emit("ride:requested", ride);
    return ride;
  }

  assignDriver(rideId, driverId) {
    const ride = this.#rides.get(rideId);
    const driver = this.#drivers.get(driverId);
    if (!ride || !driver) throw new Error("Not found");
    driver.accept(ride);
    this.emit("ride:accepted", { ride, driver });
  }

  startRide(rideId) {
    const ride = this.#rides.get(rideId);
    ride.startRide();
    this.emit("ride:started", ride);
  }

  completeRide(rideId) {
    const ride = this.#rides.get(rideId);
    const fare = ride.completeRide();
    this.emit("ride:completed", { ride, fare });
    return fare;
  }

  // Find nearest available driver of the right vehicle type
  findNearestDriver(pickup, vehicleType) {
    return [...this.#drivers.values()]
      .filter(d => d.isAvailable() && d.getVehicleType() === vehicleType)
      .sort((a, b) =>
        a.getLocation().distanceTo(pickup) - b.getLocation().distanceTo(pickup)
      )[0] || null;
  }

  #checkSurge(location) {
    // Real implementation: check demand/supply ratio in the area
    const hour = new Date().getHours();
    return hour >= 17 && hour <= 20; // evening peak
  }
}

Final Vocabulary Map — Full Curriculum

TermWhat it actually is
OOPA programming paradigm that bundles data with the functions that operate on that data, enforcing discipline through encapsulation, inheritance, and polymorphism
Design PatternA named, reusable solution to a commonly recurring design problem — not code to copy, but a structure to apply
LLDLow Level Design: the practice of modeling a system's class structure, entity relationships, and behavior before writing production code
Dependency InjectionPassing an object's dependencies in from outside rather than creating them internally — the mechanism for DIP
State MachineA model where an entity can be in one of N states and transitions between them are controlled and validated
Concurrency controlMechanisms for handling simultaneous access to shared state (locks, holds with TTL, optimistic locking)
Polymorphism (runtime)Multiple classes that all respond to the same method call, each doing it differently — the engine that makes Strategy and Observer work
Composition over InheritanceHold a reference to an object instead of extending it — more flexible, less fragile, easier to test
Event-driven designComponents communicate by emitting and subscribing to events, not by calling each other directly
God ObjectA class that knows too much and does too much — the OOP antipattern
AntipatternA commonly used approach that seems reasonable but causes consistent problems in practice

This curriculum is a foundation. Every pattern here will appear in the systems you build. When you find yourself with an if/else chain that creates objects — remember Factory. When you have state that needs guarding — remember encapsulation. When something changes and five other things need to react — remember Observer. The patterns are names for things you'll invent yourself when the problem is painful enough. This curriculum means you won't have to wait that long.