The Idiot’s Guide to SOLID Principles (Explained Simply, with Code)
If you’ve ever opened a codebase, tried to change one tiny thing, and watched five unrelated features explode, you’ve met the enemy that SOLID was invented to fight. SOLID is a set of five object-oriented design principles, coined by Robert C. Martin (“Uncle Bob”), that keep your code flexible, testable, and easy to change without fear.
This is the no-nonsense version. For each principle you get the idea in plain English, a “before” example that breaks the rule, an “after” that fixes it, and a rule of thumb you can actually remember. Examples are in Java, with quick TypeScript notes where the syntax differs. If you’ve already read The Idiot’s Guide to 15 Core Design Patterns, this is the perfect companion — SOLID tells you why those patterns exist.
What SOLID stands for
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
1. Single Responsibility Principle (SRP)
The idea
A class should have one reason to change. Put another way: one class, one job. When a class does the work and formats the report and saves to the database, any change to any of those concerns risks breaking the others.
The problem
class Invoice {
void calculateTotal() { /* business logic */ }
String formatAsHtml() { /* presentation logic */ }
void saveToDatabase() { /* persistence logic */ }
}
This Invoice has three reasons to change: a pricing rule, a layout tweak, or a database switch. Three teams could collide in one file.
The fix
class Invoice {
BigDecimal calculateTotal() { /* business logic only */ }
}
class InvoiceHtmlFormatter {
String format(Invoice invoice) { /* presentation only */ }
}
class InvoiceRepository {
void save(Invoice invoice) { /* persistence only */ }
}
Rule of thumb: if you describe a class and have to say “and” a lot, it’s doing too much.
2. Open/Closed Principle (OCP)
The idea
Software entities should be open for extension, but closed for modification. You should be able to add new behaviour without editing existing, tested code. The tell-tale smell that you’re breaking OCP is a growing if/else or switch that you have to crack open every time a new case appears.
The problem
class AreaCalculator {
double area(Object shape) {
if (shape instanceof Rectangle r) return r.width * r.height;
if (shape instanceof Circle c) return Math.PI * c.radius * c.radius;
// Add a Triangle? You must edit this method again.
throw new IllegalArgumentException("Unknown shape");
}
}
The fix
interface Shape {
double area();
}
class Rectangle implements Shape {
double width, height;
public double area() { return width * height; }
}
class Circle implements Shape {
double radius;
public double area() { return Math.PI * radius * radius; }
}
// Adding a Triangle now means writing a NEW class, not editing old ones.
Rule of thumb: new requirements should mean new code, not surgery on working code.
3. Liskov Substitution Principle (LSP)
The idea
Subtypes must be substitutable for their base types without breaking the program. If code expects a Bird, any subclass you hand it should behave like a Bird — no nasty surprises. The classic violation: a subclass that throws an exception or quietly does nothing for a method it inherited.
The problem
class Bird {
void fly() { /* ... */ }
}
class Ostrich extends Bird {
void fly() { throw new UnsupportedOperationException("Can't fly!"); }
}
// Any loop that calls bird.fly() now blows up on an Ostrich.
The fix
Model the hierarchy by capability, not by loose real-world resemblance.
interface Bird { void eat(); }
interface FlyingBird extends Bird { void fly(); }
class Sparrow implements FlyingBird {
public void eat() { /* ... */ }
public void fly() { /* ... */ }
}
class Ostrich implements Bird {
public void eat() { /* ... */ } // No fly() to break.
}
Rule of thumb: if a subclass has to weaken or refuse a promise its parent made, it shouldn’t be a subclass.
4. Interface Segregation Principle (ISP)
The idea
No client should be forced to depend on methods it doesn’t use. Many small, focused interfaces beat one fat one. If implementing an interface forces you to write empty stub methods, the interface is too big.
The problem
interface Worker {
void work();
void eat();
}
class Robot implements Worker {
public void work() { /* ... */ }
public void eat() { /* Robots don't eat... awkward empty method */ }
}
The fix
interface Workable { void work(); }
interface Feedable { void eat(); }
class Human implements Workable, Feedable {
public void work() { /* ... */ }
public void eat() { /* ... */ }
}
class Robot implements Workable {
public void work() { /* ... */ } // Only what it actually needs.
}
Rule of thumb: empty method bodies are a code smell pointing straight at a bloated interface.
5. Dependency Inversion Principle (DIP)
The idea
High-level modules shouldn’t depend on low-level modules — both should depend on abstractions. Your business logic shouldn’t be welded to a specific database, email provider, or payment gateway. Depend on an interface, and inject the concrete thing from outside.
The problem
class MySqlDatabase {
void save(String data) { /* ... */ }
}
class UserService {
private MySqlDatabase db = new MySqlDatabase(); // hard-wired
void register(String user) { db.save(user); }
}
// Want Postgres? An in-memory DB for tests? You have to edit UserService.
The fix
interface Database {
void save(String data);
}
class MySqlDatabase implements Database {
public void save(String data) { /* ... */ }
}
class UserService {
private final Database db;
UserService(Database db) { this.db = db; } // injected
void register(String user) { db.save(user); }
}
// Now you can pass MySql, Postgres, or a fake DB in tests — no edits.
In TypeScript the shape is identical — swap interface + constructor injection — and frameworks like Spring (Java) or NestJS (TS) do this wiring for you automatically. That’s all a “dependency injection container” really is.
Rule of thumb: if you see new SomethingConcrete() inside your business logic, ask whether it should be injected instead.
The 30-second cheat sheet
- SRP — One class, one job.
- OCP — Add new code, don’t edit old code.
- LSP — Subclasses must keep their parent’s promises.
- ISP — Small focused interfaces, no empty stubs.
- DIP — Depend on abstractions, inject the details.
A word of warning
SOLID is a set of guidelines, not commandments. Applied with judgement, they make code that bends instead of breaking. Applied dogmatically, they produce a galaxy of one-method interfaces and indirection nobody can follow. Reach for a principle when you feel the pain it solves — rigidity, fragility, code that’s terrifying to touch — not just because a checklist told you to.
Once SOLID clicks, design patterns stop looking like magic incantations and start looking like obvious applications of these five ideas. If you haven’t yet, read the companion piece: The Idiot’s Guide to 15 Core Design Patterns.
Enjoyed this? Get new posts in your inbox.
Occasional dev notes and stories. No spam, unsubscribe anytime.
