Back to blog
OOPClean CodeArchitecture

SOLID Principles Explained Simply (with Real Code Fixes)

A practical guide to SOLID — what each principle means in plain English, how code breaks when you violate it, and exactly how to fix it. Every principle has a before/after example.

April 22, 2025
12 min read

SOLID is five rules for writing object-oriented code that doesn't fall apart when you try to change it. Most developers can recite the names but struggle to spot violations in real code. This post fixes that. For every principle, I'll show you broken code, explain what's wrong, and give you the exact fix.

I'll use TypeScript for examples because it's clear and most developers can read it. The lessons apply to any object-oriented language.

S — Single Responsibility Principle (SRP)

A class should have one reason to change. In simple words: each class should do one thing. If you can describe a class as 'it does X AND Y', you've already broken SRP.

Here's a class that violates SRP. It saves users to a database, sends them emails, AND formats reports. That's three jobs.

// BAD: One class doing too many things
class User {
  constructor(public name: string, public email: string) {}

  saveToDatabase() {
    // SQL queries to save user
    console.log(`Saving ${this.name} to database`);
  }

  sendWelcomeEmail() {
    // Email sending logic
    console.log(`Sending email to ${this.email}`);
  }

  generateReport() {
    // Report generation logic
    console.log(`Report for ${this.name}`);
  }
}

Why is this bad? If your email service changes, you edit User. If your database changes, you edit User. If the report format changes, you edit User. The same class breaks for three completely different reasons.

The fix: split each responsibility into its own class.

// GOOD: Each class has one job
class User {
  constructor(public name: string, public email: string) {}
}

class UserRepository {
  save(user: User) {
    console.log(`Saving ${user.name} to database`);
  }
}

class EmailService {
  sendWelcome(user: User) {
    console.log(`Sending email to ${user.email}`);
  }
}

class ReportGenerator {
  generate(user: User) {
    console.log(`Report for ${user.name}`);
  }
}

Now if email logic changes, only EmailService changes. The database changes? Only UserRepository. Each class has exactly one reason to change.

O — Open/Closed Principle (OCP)

Code should be open for extension but closed for modification. In simple words: you should be able to add new features without editing old code.

Here's a payment processor that violates OCP. Every time we add a new payment method, we have to modify the same function with another if-else.

// BAD: Every new payment method requires editing this function
class PaymentProcessor {
  process(type: string, amount: number) {
    if (type === 'stripe') {
      console.log(`Charging $${amount} via Stripe`);
    } else if (type === 'paypal') {
      console.log(`Charging $${amount} via PayPal`);
    } else if (type === 'crypto') {
      console.log(`Charging $${amount} in crypto`);
    }
    // Adding a new method? Edit this function again.
  }
}

The problem: this function grows forever. Every change risks breaking the others. You're editing tested, working code just to add something new.

The fix: define a common interface, then add new payment methods as new classes — without touching the old ones.

// GOOD: Add new payment methods without modifying existing code
interface PaymentMethod {
  pay(amount: number): void;
}

class StripePayment implements PaymentMethod {
  pay(amount: number) { console.log(`Charging $${amount} via Stripe`); }
}

class PayPalPayment implements PaymentMethod {
  pay(amount: number) { console.log(`Charging $${amount} via PayPal`); }
}

class CryptoPayment implements PaymentMethod {
  pay(amount: number) { console.log(`Charging $${amount} in crypto`); }
}

class PaymentProcessor {
  process(method: PaymentMethod, amount: number) {
    method.pay(amount);
  }
}

// Adding ApplePay? Just create a new class. Don't touch anything else.
class ApplePayPayment implements PaymentMethod {
  pay(amount: number) { console.log(`Charging $${amount} via Apple Pay`); }
}

Now PaymentProcessor never changes. Old code stays untouched. New features just plug in.

L — Liskov Substitution Principle (LSP)

If class B is a subclass of A, you should be able to replace A with B anywhere — and the code should still work correctly. In simple words: subclasses must behave like their parent. If a child class breaks expectations the parent set, you've violated LSP.

The classic example: a Square inheriting from Rectangle. It seems logical — a square IS a rectangle, right? But it breaks LSP.

// BAD: Square breaks Rectangle's expected behavior
class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  getArea() { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(w: number) {
    this.width = w;
    this.height = w; // Square forces both equal
  }
  setHeight(h: number) {
    this.width = h;
    this.height = h;
  }
}

// This function expects Rectangle behavior
function resizeRectangle(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  console.log(rect.getArea()); // Expects 50
}

resizeRectangle(new Rectangle(0, 0)); // Output: 50 ✓
resizeRectangle(new Square(0, 0));    // Output: 100 ✗ — broken!

The function expected width × height = 5 × 10 = 50. With Square, setting width to 5 then height to 10 gives 100. The Square subclass silently broke code that worked perfectly with Rectangle. THAT is an LSP violation — the subclass doesn't behave like its parent.

The fix: don't force inheritance where it doesn't fit. A Square is mathematically a Rectangle, but in code it has different behavior, so it should be a separate type. Use a common interface instead.

// GOOD: Both shapes implement the same interface — neither is a subtype of the other
interface Shape {
  getArea(): number;
}

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

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

function printArea(shape: Shape) {
  console.log(shape.getArea());
}

printArea(new Rectangle(5, 10)); // 50 ✓
printArea(new Square(5));         // 25 ✓
// Both work correctly because neither pretends to be the other

The general rule for fixing LSP violations: if a subclass needs to disable, override, or fundamentally change parent behavior — it shouldn't inherit. Use composition or a shared interface instead.

I — Interface Segregation Principle (ISP)

Don't force classes to implement methods they don't use. In simple words: many small interfaces are better than one giant interface.

Here's a fat interface that violates ISP. Every Worker must implement work, eat, AND sleep. That makes sense for a Human worker, but what about a Robot?

// BAD: One fat interface forces every class to implement everything
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

class Human implements Worker {
  work() { console.log('Working'); }
  eat() { console.log('Eating'); }
  sleep() { console.log('Sleeping'); }
}

class Robot implements Worker {
  work() { console.log('Working'); }
  eat() { throw new Error('Robots do not eat!'); }
  sleep() { throw new Error('Robots do not sleep!'); }
}

Robot is forced to implement eat() and sleep() with fake methods that throw errors. Any code calling worker.eat() expecting it to work will crash. The interface lied about what Robot can actually do.

The fix: split the fat interface into smaller, focused ones. Classes implement only what they actually do.

// GOOD: Small focused interfaces — implement only what applies
interface Workable { work(): void; }
interface Eatable { eat(): void; }
interface Sleepable { sleep(): void; }

class Human implements Workable, Eatable, Sleepable {
  work() { console.log('Working'); }
  eat() { console.log('Eating'); }
  sleep() { console.log('Sleeping'); }
}

class Robot implements Workable {
  work() { console.log('Working'); }
  // No fake methods — Robot only declares what it actually does
}

Now Robot is honest about its capabilities. No fake methods, no surprise crashes. Each class implements only the interfaces it can truly fulfill.

D — Dependency Inversion Principle (DIP)

Depend on abstractions, not concrete implementations. In simple words: high-level code shouldn't directly reference low-level details. Both should rely on a contract (interface) in between.

Here's a class that violates DIP. NotificationService is hard-wired to use EmailSender. Want to switch to SMS? You have to rewrite NotificationService.

// BAD: High-level class directly creates and uses a concrete class
class EmailSender {
  send(message: string) { console.log(`Email: ${message}`); }
}

class NotificationService {
  private emailSender = new EmailSender(); // Hard dependency

  notify(message: string) {
    this.emailSender.send(message);
  }
}

// Want to send SMS instead? You have to edit NotificationService.
// Want to test it without sending real emails? Impossible without mocking the constructor.

The problem: NotificationService is tied to EmailSender forever. Testing is hard (you can't swap in a fake sender). Switching providers means rewriting code.

The fix: depend on an interface, and inject the concrete implementation from outside. This is called dependency injection.

// GOOD: Both classes depend on an abstraction, not each other
interface MessageSender {
  send(message: string): void;
}

class EmailSender implements MessageSender {
  send(message: string) { console.log(`Email: ${message}`); }
}

class SMSSender implements MessageSender {
  send(message: string) { console.log(`SMS: ${message}`); }
}

class NotificationService {
  // Inject the dependency through constructor
  constructor(private sender: MessageSender) {}

  notify(message: string) {
    this.sender.send(message);
  }
}

// Now you can swap implementations freely
const emailNotifier = new NotificationService(new EmailSender());
const smsNotifier = new NotificationService(new SMSSender());

// For tests, inject a fake sender
const fakeSender: MessageSender = { send: jest.fn() };
const testNotifier = new NotificationService(fakeSender);

Now NotificationService doesn't know or care about email vs SMS. It works with anything that implements MessageSender. Testing becomes trivial. Switching providers is a one-line change.

How to Spot SOLID Violations in Real Code

After years of writing OOP code, here are the signals I look for: 1) A class file longer than 200 lines (likely SRP violation). 2) A long if-else chain checking types (likely OCP violation). 3) A subclass that overrides parent methods to throw errors or do nothing (LSP violation). 4) An interface where most implementations leave half the methods empty (ISP violation). 5) A class that uses 'new' to create its dependencies (DIP violation).

When you spot one, don't panic. SOLID isn't a set of rules to follow blindly — it's a set of warnings that your code is becoming hard to change. Apply the principles when changes feel painful, not before.

The Bottom Line

SOLID isn't about writing clever code. It's about writing code that doesn't break when requirements change. Every principle reduces coupling and isolates change. Master these five rules and your codebases will age gracefully — not collapse under their own weight.

Don't try to apply all five perfectly from day one. Start by spotting violations in code you already have. Refactor when changes hurt. SOLID is a tool, not a religion.

Thanks for reading!

Got thoughts or questions? I'd love to hear from you.

Get in touch