SOLID Principles: 5 Rules for Writing Readable and Maintainable Code
You look at code you wrote six months ago. You don't understand it. You try to change something, something else breaks. You want to write tests, but everything is so tangled together that you don't know where to start.
If this sounds familiar, you're not alone. And SOLID principles exist precisely to solve this problem.
What Is SOLID?
SOLID is an acronym for five design principles defined by Robert C. Martin (Uncle Bob). Written for object-oriented programming, but its essence is independent of language and paradigm:
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
Each one solves a different problem. Applied together, code becomes less brittle, more testable, and easier to change.
S — Single Responsibility Principle
"A class should have only one reason to change."
In other words: a class should do only one thing and do it well.
// WRONG: One class doing too many things class User { constructor(public name: string, public email: string) {} // User data — fine getProfile() { return { name: this.name, email: this.email }; } // Database operation — what is this doing here? save() { db.query("INSERT INTO users ..."); } // Sending email — should never be here sendWelcomeEmail() { emailClient.send(this.email, "Welcome!"); } // PDF generation — seriously? generateReport() { return pdfGenerator.create(this.getProfile()); } }
There are four different reasons to change this class: if user data changes, if the database schema changes, if the email template changes, if the report format changes. Every change carries the risk of breaking something else.
// CORRECT: Each class has a single responsibility class User { constructor(public name: string, public email: string) {} getProfile() { return { name: this.name, email: this.email }; } } class UserRepository { save(user: User) { db.query("INSERT INTO users ...", user); } findById(id: string) { return db.query("SELECT * FROM users WHERE id = ?", [id]); } } class EmailService { sendWelcome(user: User) { emailClient.send(user.email, "Welcome!"); } } class UserReportService { generate(user: User) { return pdfGenerator.create(user.getProfile()); } }
Now each class has only one reason to change. If the email template changes, only EmailService changes. If the database query changes, only UserRepository changes.
O — Open/Closed Principle
"Software entities should be open for extension, closed for modification."
When adding new features, you should add new code rather than changing existing code.
// WRONG: This function must change every time a new payment method is added function processPayment(order: Order, method: string) { if (method === "credit_card") { // credit card logic } else if (method === "paypal") { // PayPal logic } else if (method === "crypto") { // crypto logic — this chain keeps growing } }
// CORRECT: New method = new class, existing code doesn't change interface PaymentMethod { process(order: Order): Promise<PaymentResult>; } class CreditCardPayment implements PaymentMethod { async process(order: Order) { /* credit card logic */ } } class PayPalPayment implements PaymentMethod { async process(order: Order) { /* PayPal logic */ } } class CryptoPayment implements PaymentMethod { async process(order: Order) { /* crypto logic */ } } // This function never changes async function processPayment(order: Order, method: PaymentMethod) { return method.process(order); }
New payment method added? You write a new class. You don't touch any existing code. You don't break any existing tests.
L — Liskov Substitution Principle
"Subtypes must be substitutable for their base types."
A function expects an Animal and you give it a Dog — everything should work as expected. Dog should extend Animal without breaking its behaviors.
// WRONG: Subclass breaks the parent class expectation class Rectangle { constructor(public width: number, public height: number) {} setWidth(w: number) { this.width = w; } setHeight(h: number) { this.height = h; } area() { return this.width * this.height; } } class Square extends Rectangle { setWidth(w: number) { this.width = w; this.height = w; } setHeight(h: number) { this.width = h; this.height = h; } } function testRectangle(rect: Rectangle) { rect.setWidth(5); rect.setHeight(10); // Rectangle: expects 50, Square: returns 100 console.log(rect.area()); }
Square can't substitute for Rectangle because its behavior is different. LSP violation.
// CORRECT: Shared interface, separate implementations 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()); // Always works correctly }
I — Interface Segregation Principle
"Clients should not be forced to depend on interfaces they don't use."
Split large interfaces into smaller, focused ones.
// WRONG: Every service forced to implement this massive interface interface Worker { work(): void; eat(): void; sleep(): void; attendMeeting(): void; writeReport(): void; } // Robot doesn't eat or sleep — but must implement them class Robot implements Worker { work() { /* work */ } eat() { throw new Error("Robots don't eat"); } // Nonsense sleep() { throw new Error("Robots don't sleep"); } // Nonsense attendMeeting() { /* attend */ } writeReport() { /* report */ } }
// CORRECT: Small, focused interfaces interface Workable { work(): void; } interface Eatable { eat(): void; } interface Sleepable { sleep(): void; } interface Reportable { writeReport(): void; } class Human implements Workable, Eatable, Sleepable, Reportable { work() { /* work */ } eat() { /* eat */ } sleep() { /* sleep */ } writeReport() { /* report */ } } class Robot implements Workable, Reportable { work() { /* work */ } writeReport() { /* report */ } // No eating or sleeping — because it's not needed }
D — Dependency Inversion Principle
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
// WRONG: High-level code directly dependent on low-level implementation class OrderService { private db = new MySQLDatabase(); // Directly coupled to MySQL createOrder(order: Order) { this.db.save(order); // Must change if MySQL changes } }
// CORRECT: Depend on abstraction interface Database { save(data: any): Promise<void>; findById(id: string): Promise<any>; } class MySQLDatabase implements Database { async save(data: any) { /* MySQL implementation */ } async findById(id: string) { /* MySQL implementation */ } } class MongoDatabase implements Database { async save(data: any) { /* MongoDB implementation */ } async findById(id: string) { /* MongoDB implementation */ } } class OrderService { constructor(private db: Database) {} // Depends on interface, not implementation async createOrder(order: Order) { await this.db.save(order); } } // Use with MySQL const service = new OrderService(new MySQLDatabase()); // Switch to MongoDB — OrderService never changed const service2 = new OrderService(new MongoDatabase()); // Use in-memory in tests const testService = new OrderService(new InMemoryDatabase());
The biggest benefit of DIP is testability. An in-memory implementation can be injected instead of a real database.
SOLID Is a Goal, Not a Dogma
SOLID principles are guides, not rules. Trying to apply every principle to every class creates unnecessary complexity.
For small scripts and one-off tools, SOLID can be overkill. For code that grows, is maintained, and is developed by a team, SOLID pays for itself.
Writing code that someone reading it — including yourself six months from now — can say "this makes sense" is the essence of all the principles.