Skip to content
Innovialabs

SOLID Principles Explained with Practical Examples

Practice4 min

In short

SOLID is five rules for keeping code easy to change: one reason to change per class, extension without editing, safe substitution, small interfaces and dependence on abstractions instead of concrete classes.

What SOLID gives you in practice

SOLID is five design principles for object-oriented code. Their shared goal is simple: a change in one place should not break or force edits in other places. Below, each principle comes with a "before" and "after" example in TypeScript and the concrete benefit.

S: Single Responsibility

A class should have one reason to change.

// Before: report logic, formatting and email in one class
class Report {
  build(data: Order[]) { /* calculate totals */ }
  toPdf() { /* render PDF */ }
  sendByEmail(to: string) { /* SMTP */ }
}
// After: each class changes for its own reason
class ReportBuilder { build(data: Order[]): ReportData { /* ... */ } }
class PdfRenderer { render(r: ReportData): Buffer { /* ... */ } }
class Mailer { send(to: string, file: Buffer) { /* ... */ } }

Benefit: changing the PDF layout no longer risks breaking totals, and each part can be tested in isolation.

O: Open/Closed

Code should be open for extension, closed for modification: new behaviour is added with new code, not by editing working code.

// Before: every new method means editing this function
function deliveryCost(type: string, weight: number) {
  if (type === "courier") return 20 + weight * 2;
  if (type === "pickup") return 0;
  throw new Error("unknown type");
}
// After: a new delivery type is a new class
interface Delivery { cost(weight: number): number }
class Courier implements Delivery { cost(w: number) { return 20 + w * 2; } }
class Pickup implements Delivery { cost() { return 0; } }

Benefit: adding a delivery type does not touch tested code, so regressions are less likely.

L: Liskov Substitution

A subclass must be usable anywhere its parent is expected, without surprises.

// Before: ReadOnlyFile breaks the File contract
class File { write(text: string) { /* ... */ } }
class ReadOnlyFile extends File {
  write() { throw new Error("read-only"); }
}
// After: separate capabilities, no broken promises
interface Readable { read(): string }
interface Writable { write(text: string): void }
class ReadOnlyFile implements Readable { read() { return "..."; } }

Benefit: code that accepts a parent type does not need instanceof checks or try/catch for "special" subclasses.

I: Interface Segregation

Clients should not depend on methods they do not use.

// Before: one fat interface
interface Worker { code(): void; test(): void; manageBudget(): void }
// After: small, focused interfaces
interface Coder { code(): void }
interface Tester { test(): void }
interface BudgetOwner { manageBudget(): void }

Benefit: classes implement only what makes sense for them, and changes to one interface affect fewer files.

D: Dependency Inversion

High-level logic should depend on abstractions, not on concrete implementations.

// Before: the service is hard-wired to one database
class OrderService {
  private db = new PostgresClient();
  save(o: Order) { this.db.insert("orders", o); }
}
// After: the dependency is passed in through an interface
interface OrderRepository { save(o: Order): Promise<void> }
class OrderService {
  constructor(private repo: OrderRepository) {}
  place(o: Order) { return this.repo.save(o); }
}

Benefit: in tests you pass an in-memory repository; switching storage does not touch business logic.

Quick reference

PrincipleWarning signTypical fix
SRPA class changes for several unrelated reasonsSplit by responsibility
OCPA growing if/switch on typePolymorphism or a strategy map
LSPSubclass throws "not supported"Rethink the hierarchy
ISPEmpty or stub method implementationsSplit the interface
DIPnew of infrastructure inside business logicInject through an interface

Common mistakes

  • Applying SOLID too early: three interfaces for a function that has one implementation and no plans to change only add noise.
  • Treating it as law: these are guidelines for code that changes often, not a checklist for every file.
  • Splitting too finely: dozens of one-line classes are as hard to read as one giant class.

A practical rule: refactor towards SOLID when a change hurts, the second or third time you edit the same place.

FAQ

The ideas do. In functional code, single responsibility means small focused functions, and dependency inversion means passing dependencies as arguments instead of importing them directly.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.