SOLID Principles in JavaScript

advanced SOLID SRP OCP LSP ISP DIP design-principles

SOLID is a set of five design principles that help us write code that’s easier to maintain, extend, and test. They were originally coined for OOP in languages like Java, but they apply just as well to JavaScript — we just use idiomatic JS patterns like callbacks, modules, and mixins instead of formal interfaces.

Let’s go through each one with a bad example, then the fixed version.

S — Single Responsibility Principle

One class (or module) should have only one reason to change.

If a class does too many things, changing one part risks breaking the others.

Bad — one class does everything:

class UserService {
  createUser(data) { /* save to DB */ }
  sendWelcomeEmail(user) { /* send email */ }
  generateReport(user) { /* create PDF */ }
}

This class has three reasons to change: DB logic, email logic, and report logic.

Good — split by responsibility:

class UserRepository {
  create(data) { /* save to DB */ }
}

class EmailService {
  sendWelcome(user) { /* send email */ }
}

class ReportGenerator {
  generate(user) { /* create PDF */ }
}

Now each class has one job. If the email provider changes, only EmailService changes.

O — Open/Closed Principle

Open for extension, closed for modification.

We should be able to add new behavior without modifying existing code. In JS, we do this with strategy patterns, plugins, or callbacks.

Bad — modifying existing code for every new shape:

class AreaCalculator {
  calculate(shape) {
    if (shape.type === "circle") return Math.PI * shape.radius ** 2;
    if (shape.type === "rectangle") return shape.width * shape.height;
    // Every new shape = modify this function
  }
}

Good — each shape knows how to calculate its own area:

class Circle {
  constructor(radius) { this.radius = radius; }
  area() { return Math.PI * this.radius ** 2; }
}

class Rectangle {
  constructor(w, h) { this.width = w; this.height = h; }
  area() { return this.width * this.height; }
}

// Adding a Triangle doesn't touch Circle or Rectangle
function totalArea(shapes) {
  return shapes.reduce((sum, s) => sum + s.area(), 0);
}

New shapes just implement area(). The totalArea function never changes.

L — Liskov Substitution Principle

Subclasses must be substitutable for their parent class without breaking anything.

If we have code that works with a parent class, swapping in a child class should not cause surprises.

Bad — the subclass breaks the parent’s contract:

class Bird {
  fly() { return "flying"; }
}

class Penguin extends Bird {
  fly() { throw new Error("Can't fly!"); } // breaks the contract!
}

function makeBirdFly(bird) {
  return bird.fly(); // crashes if we pass a Penguin
}

Good — restructure so the contract holds:

class Bird {
  move() { return "moving"; }
}

class FlyingBird extends Bird {
  fly() { return "flying"; }
}

class Penguin extends Bird {
  swim() { return "swimming"; }
}

// Now we only call fly() on FlyingBird, not all Birds

The rule is simple: if a subclass can’t do everything the parent promises, it shouldn’t extend that parent.

I — Interface Segregation Principle

Don’t force classes to implement methods they don’t need.

JavaScript doesn’t have formal interfaces, but the idea still applies. Instead of one giant mixin or base class with everything, we use small focused mixins.

Bad — one fat interface:

class Animal {
  fly() { throw new Error("Not implemented"); }
  swim() { throw new Error("Not implemented"); }
  walk() { throw new Error("Not implemented"); }
}

class Dog extends Animal {
  walk() { return "walking"; }
  // Forced to inherit fly() and swim() which make no sense
}

Good — small, role-based mixins:

const Walkable = (Base) => class extends Base {
  walk() { return `${this.name} is walking`; }
};

const Swimmable = (Base) => class extends Base {
  swim() { return `${this.name} is swimming`; }
};

class Dog extends Walkable(Swimmable(class {
  constructor(name) { this.name = name; }
})) {}

const dog = new Dog("Buddy");
dog.walk(); // "Buddy is walking"
dog.swim(); // "Buddy is swimming"
// No fly() — Dog doesn't need it

Each mixin adds only what’s relevant. Classes pick only the abilities they need.

D — Dependency Inversion Principle

Depend on abstractions, not concretions.

High-level modules shouldn’t depend directly on low-level modules. Both should depend on abstractions (callbacks, interfaces, injection).

Bad — tightly coupled to a specific database:

class UserService {
  constructor() {
    this.db = new PostgresDB(); // hardcoded dependency
  }
  getUser(id) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

If we want to switch to MongoDB or use a mock in tests, we have to change UserService.

Good — inject the dependency:

class UserService {
  constructor(db) {
    this.db = db; // accepts any object with a query() method
  }
  getUser(id) {
    return this.db.findById("users", id);
  }
}

// Production
const service = new UserService(new PostgresDB());

// Tests
const service = new UserService(new MockDB());

Now UserService doesn’t care what database it’s using. It just needs something with a findById() method. In JS, we don’t need a formal interface — we rely on duck typing (“if it has the right methods, it works”).

SOLID at a Glance

PrincipleOne-LinerJS Pattern
S — Single ResponsibilityOne class = one jobSmall modules, separate concerns
O — Open/ClosedExtend, don’t modifyStrategy pattern, plugins, callbacks
L — Liskov SubstitutionSubclasses keep promisesDon’t override to break contracts
I — Interface SegregationSmall, focused interfacesRole-based mixins
D — Dependency InversionDepend on abstractionsConstructor injection, callbacks

In simple language, SOLID principles are guidelines, not strict rules. We don’t need to apply all five everywhere — that’s overengineering. But knowing them helps us recognize when a class is doing too much, when inheritance is the wrong tool, or when our code is too tightly coupled. Use them as a compass, not a rulebook.