cv.cm

Coding · Design patterns

SOLID principles explained with examples

7 min read

Short answer

SOLID is an acronym for five object-oriented design principles: Single responsibility, Open/closed, Liskov substitution, Interface segregation and Dependency inversion. They were collected by Robert C. Martin, and the acronym was coined by Michael Feathers. Together they aim to make code easier to change: each class has one reason to change, new behavior is added without editing old code, subtypes are safe replacements, interfaces stay small, and high-level code depends on abstractions.

What SOLID means

LetterPrincipleShort rule
SSingle responsibility principle (SRP)A class should have only one reason to change.
OOpen/closed principle (OCP)Software should be open for extension but closed for modification.
LLiskov substitution principle (LSP)Subtypes must be usable wherever their base type is expected.
IInterface segregation principle (ISP)Clients should not be forced to depend on methods they do not use.
DDependency inversion principle (DIP)Depend on abstractions, not on concrete details.

The ideas are older than the acronym. The open/closed principle comes from Bertrand Meyer's book Object-Oriented Software Construction (1988), and the substitution principle from Barbara Liskov's 1987 keynote Data Abstraction and Hierarchy. Robert C. Martin described all five in his writing around 2000, and Michael Feathers later arranged them into the word SOLID.

S: Single responsibility principle

A class should have one reason to change, meaning it should serve one actor or one concern. When a class mixes business rules, formatting and storage, a change to any of them risks breaking the others.

Before: one class calculates, formats and saves a report.

class Report {
  constructor(private sales: number[]) {}
  total(): number { return this.sales.reduce((a, b) => a + b, 0); }
  toHtml(): string { return `<p>Total: ${this.total()}</p>`; }
  saveToFile(path: string): void { /* write this.toHtml() to disk */ }
}

After: each concern lives in its own small unit, so a new output format does not touch the calculation.

class SalesReport {
  constructor(private sales: number[]) {}
  total(): number { return this.sales.reduce((a, b) => a + b, 0); }
}
class HtmlFormatter {
  format(r: SalesReport): string { return `<p>Total: ${r.total()}</p>`; }
}
class ReportWriter {
  save(content: string, path: string): void { /* write to disk */ }
}
const html = new HtmlFormatter().format(new SalesReport([10, 20, 5]));
console.log(html); // <p>Total: 35</p>

O: Open/closed principle

You should be able to add new behavior by adding new code, not by editing code that already works and is tested. The usual tool is an interface plus polymorphism.

Before: every new shape means editing the area function.

type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number {
  if (s.kind === "circle") return Math.PI * s.r ** 2;
  if (s.kind === "square") return s.side ** 2;
  throw new Error("unknown shape");
}

After: a new shape is a new class; existing code stays unchanged.

interface Shape { area(): number; }
class Circle implements Shape {
  constructor(private r: number) {}
  area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
  constructor(private side: number) {}
  area(): number { return this.side ** 2; }
}
const totalArea = (shapes: Shape[]) => shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea([new Square(2), new Square(3)])); // 13

L: Liskov substitution principle

If code works with a base type, it must keep working when given any subtype. A subtype must not strengthen preconditions, weaken guarantees, or throw where the base type does not.

Before: the classic square/rectangle problem. Square extends Rectangle but breaks the expectation that width and height are independent.

class Rectangle {
  constructor(public width: number, public height: number) {}
  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }
  area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
  setWidth(w: number): void { this.width = w; this.height = w; }
  setHeight(h: number): void { this.width = h; this.height = h; }
}
function stretch(r: Rectangle): number {
  r.setWidth(5);
  r.setHeight(4);
  return r.area(); // caller expects 20
}
console.log(stretch(new Rectangle(1, 1))); // 20
console.log(stretch(new Square(1, 1)));    // 16, surprise

After: do not model the relation as inheritance. Both types implement a shared read-only interface, and nobody expects to resize a square's sides independently.

interface Shape { area(): number; }
class Rectangle implements Shape {
  constructor(readonly width: number, readonly height: number) {}
  area(): number { return this.width * this.height; }
}
class Square implements Shape {
  constructor(readonly side: number) {}
  area(): number { return this.side ** 2; }
}
console.log([new Rectangle(5, 4), new Square(4)].map((s) => s.area())); // [ 20, 16 ]

I: Interface segregation principle

Large interfaces force classes to implement methods they cannot support, usually by throwing errors. Split them into small, focused interfaces, and let each class implement only what it really does.

Before: a simple printer must pretend it can scan and fax.

interface Machine {
  print(doc: string): void;
  scan(): string;
  fax(doc: string): void;
}
class BasicPrinter implements Machine {
  print(doc: string): void { console.log("print", doc); }
  scan(): string { throw new Error("not supported"); }
  fax(doc: string): void { throw new Error("not supported"); }
}

After: small role interfaces that can be combined.

interface Printer { print(doc: string): void; }
interface Scanner { scan(): string; }
class BasicPrinter implements Printer {
  print(doc: string): void { console.log("print", doc); }
}
class OfficeMachine implements Printer, Scanner {
  print(doc: string): void { console.log("print", doc); }
  scan(): string { return "scanned page"; }
}
function printAll(p: Printer, docs: string[]): void { docs.forEach((d) => p.print(d)); }
printAll(new BasicPrinter(), ["a.pdf"]); // print a.pdf

D: Dependency inversion principle

High-level policy should not depend on low-level details such as a specific database or email service. Both should depend on an abstraction, and the high-level module usually owns that abstraction. In practice, you pass dependencies in (dependency injection) instead of creating them inside.

Before: the service creates a concrete database client, so it cannot be tested or switched.

class MySqlUserStore {
  find(id: string): string { return "user " + id; }
}
class UserService {
  private store = new MySqlUserStore(); // hard-wired detail
  greet(id: string): string { return "Hello, " + this.store.find(id); }
}

After: the service depends on an interface; any implementation can be injected, including a fake in tests.

interface UserStore { find(id: string): string; }
class UserService {
  constructor(private store: UserStore) {}
  greet(id: string): string { return "Hello, " + this.store.find(id); }
}
class InMemoryUserStore implements UserStore {
  constructor(private users: Record<string, string>) {}
  find(id: string): string { return this.users[id] ?? "unknown"; }
}
const service = new UserService(new InMemoryUserStore({ u1: "Ada" }));
console.log(service.greet("u1")); // Hello, Ada

When to apply SOLID, and when not to

SOLID principles are guidelines for code that changes often, not laws. Applied blindly, they produce many tiny classes and interfaces with a single implementation, which makes code harder to follow.

  • Apply them when you feel the pain they address: a class that breaks every time anything changes, a test that needs a real database, or a switch statement copied into many places.
  • Do not add an interface for every class in advance. Extract one when a second implementation or a test double appears.
  • In small scripts and prototypes, simple and direct code is usually better.
  • Many design patterns are concrete applications of SOLID: Strategy and Decorator support open/closed, Adapter and Facade help isolate details, and Abstract Factory supports dependency inversion.

FAQ

Who created the SOLID principles?

Robert C. Martin collected and described the five principles, building on earlier work by Bertrand Meyer (open/closed) and Barbara Liskov (substitution). Michael Feathers coined the acronym SOLID.

Does SOLID apply to functional programming?

The ideas translate: small functions with one purpose, composing behavior instead of editing it, and passing dependencies as parameters. The class-based wording is specific to object-oriented code.

What is the difference between dependency inversion and dependency injection?

Dependency inversion is the principle that high-level code should depend on abstractions. Dependency injection is a technique that supplies those dependencies from outside, for example through a constructor.

Which SOLID principle is most important?

There is no official ranking. Single responsibility and dependency inversion usually give the quickest benefit, because they make code easier to understand and to test.

Is it bad to break a SOLID principle?

Not always. Every principle has a cost in extra code. Break one deliberately when the simpler design is clearer and the change it protects against is unlikely.

All coding tutorials