Toolmingo
Guides10 min read

SOLID Principles Explained with Code Examples

Learn the five SOLID principles of object-oriented design — Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — with practical JavaScript and TypeScript examples.

SOLID is an acronym for five object-oriented design principles that make software easier to maintain, extend, and understand. Coined by Robert C. Martin ("Uncle Bob"), these principles help you avoid code that becomes brittle and hard to change.


Quick Reference

Letter Principle One-line summary
S Single Responsibility A class/function should have one reason to change
O Open-Closed Open for extension, closed for modification
L Liskov Substitution Subtypes must be substitutable for their base types
I Interface Segregation Don't force clients to depend on methods they don't use
D Dependency Inversion Depend on abstractions, not concretions

S — Single Responsibility Principle (SRP)

A module should have one, and only one, reason to change.

Violation

class UserService {
  constructor(private db: Database) {}

  getUser(id: string) {
    return this.db.query(`SELECT * FROM users WHERE id = ?`, [id]);
  }

  sendWelcomeEmail(user: User) {
    // Email logic mixed into UserService
    const smtp = new SMTPClient({ host: 'smtp.example.com' });
    smtp.send({
      to: user.email,
      subject: 'Welcome!',
      body: `Hi ${user.name}, welcome aboard.`,
    });
  }

  formatUserForExport(user: User): string {
    // CSV formatting mixed in too
    return `${user.id},${user.name},${user.email}`;
  }
}

UserService has three reasons to change: database queries, email logic, and export formatting.

Fixed

class UserRepository {
  constructor(private db: Database) {}

  findById(id: string): Promise<User> {
    return this.db.query(`SELECT * FROM users WHERE id = ?`, [id]);
  }
}

class EmailService {
  sendWelcome(user: User): void {
    const smtp = new SMTPClient({ host: 'smtp.example.com' });
    smtp.send({ to: user.email, subject: 'Welcome!', body: `Hi ${user.name}` });
  }
}

class UserExporter {
  toCsv(user: User): string {
    return `${user.id},${user.name},${user.email}`;
  }
}

Each class now has one reason to change. Changing how emails are sent doesn't touch the database code.

SRP in functions

SRP applies to functions too. A 200-line function that validates input, transforms data, saves to a database, and sends a notification violates SRP. Break it into small, focused functions:

// Before: one function does everything
async function processOrder(data: unknown) {
  // validate, transform, save, notify — all here
}

// After: each function does one thing
async function processOrder(rawData: unknown) {
  const data = validateOrder(rawData);
  const order = transformOrder(data);
  const saved = await orderRepository.save(order);
  await notificationService.orderConfirmed(saved);
  return saved;
}

O — Open-Closed Principle (OCP)

Software entities should be open for extension, but closed for modification.

Add new behaviour without changing existing code. This protects existing, tested code from regressions.

Violation

class DiscountCalculator {
  calculate(price: number, customerType: string): number {
    if (customerType === 'regular') {
      return price;
    } else if (customerType === 'member') {
      return price * 0.9;
    } else if (customerType === 'vip') {
      return price * 0.8;
    }
    // Every new customer type requires editing this class
    return price;
  }
}

Adding a new discount type requires modifying DiscountCalculator.

Fixed

interface DiscountStrategy {
  apply(price: number): number;
}

class RegularDiscount implements DiscountStrategy {
  apply(price: number) { return price; }
}

class MemberDiscount implements DiscountStrategy {
  apply(price: number) { return price * 0.9; }
}

class VipDiscount implements DiscountStrategy {
  apply(price: number) { return price * 0.8; }
}

// New type: add a new class, never touch existing ones
class BlackFridayDiscount implements DiscountStrategy {
  apply(price: number) { return price * 0.5; }
}

class DiscountCalculator {
  calculate(price: number, strategy: DiscountStrategy): number {
    return strategy.apply(price);
  }
}

Now you can add as many discount strategies as you want without touching DiscountCalculator.

OCP with higher-order functions

In functional JS/TS, OCP often means accepting functions instead of switch statements:

type Transform = (data: string) => string;

function pipeline(data: string, ...transforms: Transform[]): string {
  return transforms.reduce((acc, fn) => fn(acc), data);
}

// Extend by adding new transforms — never modify pipeline()
const trim = (s: string) => s.trim();
const uppercase = (s: string) => s.toUpperCase();
const removeSpaces = (s: string) => s.replace(/\s+/g, '_');

const result = pipeline('  hello world  ', trim, uppercase, removeSpaces);
// → 'HELLO_WORLD'

L — Liskov Substitution Principle (LSP)

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering program correctness.

In plain terms: a subclass must honour the contract of its parent. You should be able to use any subclass wherever the base class is expected.

Violation

class Rectangle {
  constructor(protected width: number, protected height: number) {}

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

class Square extends Rectangle {
  // Square "is-a" Rectangle — classic OOP teaching, but it violates LSP
  setWidth(w: number)  { this.width = w; this.height = w; }
  setHeight(h: number) { this.width = h; this.height = h; }
}

function resizeAndPrint(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  // Expected: 50. With Square, get: 100 (height call overwrote width)
  console.log(rect.area());
}

resizeAndPrint(new Rectangle(2, 3)); // 50 ✓
resizeAndPrint(new Square(2, 2));    // 100 ✗  — substitution breaks behaviour

Fixed

Don't force an inheritance relationship that doesn't hold. Use composition or a shared interface:

interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height; }
}

class Square implements Shape {
  constructor(private side: number) {}
  area() { return this.side * this.side; }
}

function printArea(shape: Shape) {
  console.log(shape.area()); // works correctly for any Shape
}

LSP in practice

Common LSP violations:

Violation pattern Fix
Subclass throws on method parent supports Favour composition over inheritance
Subclass narrows input types (stricter preconditions) Keep or widen accepted inputs in subclasses
Subclass weakens output guarantees (looser postconditions) Subclasses must return at least as strong a guarantee
Subclass ignores inherited method Redesign the hierarchy

I — Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not use.

Large, fat interfaces force implementors to provide methods they don't need, leading to empty or throw stubs.

Violation

interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

class HumanWorker implements Worker {
  work()  { console.log('working'); }
  eat()   { console.log('eating'); }
  sleep() { console.log('sleeping'); }
}

class RobotWorker implements Worker {
  work()  { console.log('working'); }
  eat()   { throw new Error('Robots do not eat'); }  // forced stub
  sleep() { throw new Error('Robots do not sleep'); } // forced stub
}

Fixed

Split the fat interface into smaller, focused ones:

interface Workable {
  work(): void;
}

interface Feedable {
  eat(): void;
}

interface Restable {
  sleep(): void;
}

class HumanWorker implements Workable, Feedable, Restable {
  work()  { console.log('working'); }
  eat()   { console.log('eating'); }
  sleep() { console.log('sleeping'); }
}

class RobotWorker implements Workable {
  work() { console.log('working'); }
  // Only implements what it actually supports
}

ISP in TypeScript

TypeScript's structural typing makes ISP natural:

// Instead of one big type, use intersections where needed
type Serialisable = { serialize(): string };
type Loggable    = { log(): void };
type Cacheable   = { cacheKey(): string };

// A service only asks for what it needs
function saveToCache(item: Cacheable & Serialisable) {
  cache.set(item.cacheKey(), item.serialize());
}

// Callers don't need to implement Loggable unless the function asks for it

D — Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Concretely: inject dependencies rather than creating them inside a class. This makes code testable and swappable.

Violation

class OrderService {
  // Hard-coded dependency on a concrete class
  private emailer = new SendGridEmailer();
  private db      = new PostgresDatabase();

  async placeOrder(order: Order) {
    await this.db.save(order);
    await this.emailer.send(order.userEmail, 'Order confirmed');
  }
}
  • Can't swap PostgresDatabase for an in-memory DB in tests.
  • Can't switch to a different email provider without modifying OrderService.

Fixed

// Define abstractions
interface Database {
  save(record: unknown): Promise<void>;
}

interface Emailer {
  send(to: string, subject: string): Promise<void>;
}

// Concrete implementations
class PostgresDatabase implements Database {
  async save(record: unknown) { /* postgres logic */ }
}

class SendGridEmailer implements Emailer {
  async send(to: string, subject: string) { /* sendgrid logic */ }
}

// High-level module depends on abstractions, not concretions
class OrderService {
  constructor(
    private db: Database,
    private emailer: Emailer,
  ) {}

  async placeOrder(order: Order) {
    await this.db.save(order);
    await this.emailer.send(order.userEmail, 'Order confirmed');
  }
}

// Production
const service = new OrderService(
  new PostgresDatabase(),
  new SendGridEmailer(),
);

// Tests — swap with lightweight fakes
const fakeDb: Database = { save: async () => {} };
const fakeEmailer: Emailer = { send: async () => {} };
const testService = new OrderService(fakeDb, fakeEmailer);

DIP with a DI container (Node.js)

For larger apps, use a DI container like tsyringe or inversify:

import { injectable, inject, container } from 'tsyringe';

@injectable()
class OrderService {
  constructor(
    @inject('Database') private db: Database,
    @inject('Emailer')  private emailer: Emailer,
  ) {}
}

container.register('Database', { useClass: PostgresDatabase });
container.register('Emailer',  { useClass: SendGridEmailer });

const service = container.resolve(OrderService);

Applying SOLID Together

A realistic refactor that applies all five principles:

// Before: one bloated class
class ReportGenerator {
  generatePdfReport(userId: string) {
    // fetch user from DB directly
    // format report
    // save to disk
    // send email
    // all here, all coupled
  }
}

// After: SOLID-compliant design

// S: each class has one responsibility
// I: small, focused interfaces
interface UserRepo   { findById(id: string): Promise<User> }
interface Formatter  { format(user: User): Buffer }
interface FileStore  { save(name: string, data: Buffer): Promise<string> }
interface Notifier   { notify(to: string, link: string): Promise<void> }

// O: extend by adding new Formatter implementations
class PdfFormatter  implements Formatter { format(u: User) { /* ... */ return Buffer.from(''); } }
class ExcelFormatter implements Formatter { format(u: User) { /* ... */ return Buffer.from(''); } }

// D: ReportService depends on abstractions, injected from outside
class ReportService {
  constructor(
    private users: UserRepo,
    private formatter: Formatter,
    private store: FileStore,
    private notifier: Notifier,
  ) {}

  async generate(userId: string) {
    const user = await this.users.findById(userId);  // L: any UserRepo subtype works
    const data = this.formatter.format(user);
    const link = await this.store.save(`report-${userId}.pdf`, data);
    await this.notifier.notify(user.email, link);
  }
}

Common Mistakes

Mistake Why it's a problem Fix
Applying SRP by making 1 class = 1 method Over-engineering, cohesion suffers Group by reason to change, not by size
Using inheritance for code reuse only Breaks LSP if the "is-a" relationship is false Prefer composition
Fat interfaces with 10+ methods ISP violation, painful to implement Split into role-based interfaces
Skipping DIP in small projects Works until tests become impossible Always inject external dependencies
Creating abstractions for everything Premature abstraction, extra complexity Abstract when you have ≥2 implementations or need testability
Confusing OCP with never editing code All code is editable; OCP protects stable APIs Draw the line at public contracts

SOLID vs Functional Programming

SOLID was designed with OOP in mind, but the ideas translate:

SOLID Principle Functional equivalent
SRP Small, pure functions with one purpose
OCP Higher-order functions, pipelines, composition
LSP Functions must honour type contracts (no surprising null returns)
ISP Accept only the data a function needs (destructure parameters)
DIP Pass side effects (DB, email) as function arguments, not globals

Frequently Asked Questions

Do I need to follow all five principles all the time?
No. Apply them where they reduce real pain: code that's hard to test, modules that change frequently, or classes that keep growing. Over-applying SOLID in a 50-line script creates unnecessary complexity.

Is SOLID only for object-oriented languages?
The acronym comes from OOP, but the underlying ideas (small modules, dependency injection, respecting contracts) apply in any paradigm.

What's the most important SOLID principle?
Dependency Inversion (DIP) and Single Responsibility (SRP) give the biggest practical return because they directly enable testability and understandability.

Can SOLID principles conflict with each other?
Yes. Strict ISP (tiny interfaces) can make DIP verbose (many injection points). Strict OCP can create deep inheritance hierarchies that violate LSP. Use judgement — principles are guides, not laws.

How do SOLID principles relate to design patterns?
Design patterns (Strategy, Observer, Factory…) are common implementations of SOLID principles. Strategy implements OCP. Factory supports DIP. Observer enables SRP by decoupling event senders from receivers.

Should I refactor existing code to be SOLID?
Refactor when you're already changing the code. Don't refactor working code just to make it SOLID — the risk of introducing bugs outweighs the theoretical benefit.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools