SOLID Principles Every Software Engineer Should Actually Understand
Why these five design rules will save you from maintenance hell and make your codebase something you’re actually proud of
SOLID principles, really worth it….? We’ve all been there as engineers. You’re staring at a codebase that’s become a tangled mess. Every time you try to add a feature, something breaks in a completely different part of the system. Fixing one bug creates three more. And you’re left wondering: “How did this happen?”
The answer is usually the same: the code wasn’t designed with change in mind.
That’s where SOLID principles come in. They’re not fancy design patterns or cutting-edge frameworks. They’re five fundamental principles that, if you follow them, make your systems stable, maintainable, and actually pleasant to work with.
Let’s break them down, one by one.
S: Single Responsibility Principle
Every class should have one reason to change.
Here’s the idea: each object should do one thing and do it well. All its methods should be tightly focused on that single responsibility.
Think about it this way. Imagine you have a `User` class that handles:
- User authentication
- Sending email notifications
- Generating PDF reports
- Database operations
What happens when you need to change how emails are sent? You have to touch the `User` class. What about when the PDF library updates? Yep, `User` class again. What if you want to swap databases? Same thing.
This is a nightmare. Every change, no matter how small, risks breaking something else.
A better approach:
class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}
isValid() {
return this.email && this.email.includes(’@’);
}
}
class UserAuthenticator {
authenticate(user, password) {
// Handle login logic
}
}
class EmailService {
sendWelcomeEmail(user) {
// Send email
}
}
class UserRepository {
save(user) {
// Database operations
}
}
Now each class has exactly one reason to change. Email logic changes? Touch `EmailService`. Authentication changes? Touch `UserAuthenticator`. The `User` class itself stays stable.
But here’s the thing: don’t over-engineer this. If your application isn’t changing in ways that cause different responsibilities to evolve independently, you don’t need to separate them yet. Start simple. Refactor when the pain becomes real.
This is important. I’ve seen developers create twenty tiny classes for what could be three, all in the name of SRP. That’s not helpful. The principle is about managing change, not about making everything as small as possible.
O: Open-Closed Principle
Open for extension, closed for modification.
This one sounds abstract, but the metaphor is perfect: open-chest surgery is not needed when putting on a coat.
In other words, you should be able to add new functionality without changing existing code. Why? Because every time you modify working code, you risk breaking it. And let’s face it, nobody wants to be the person who broke production on a Friday afternoon because they added “just one more feature.”
Let’s say you’re building a payment system:
// Bad approach
class PaymentProcessor {
processPayment(amount, type) {
if (type === ‘credit_card’) {
// Credit card logic
} else if (type === ‘paypal’) {
// PayPal logic
} else if (type === ‘crypto’) {
// Crypto logic
}
}
}
Every time you add a new payment method, you have to modify `PaymentProcessor`. That’s risky. And it violates the open-closed principle.
What’s worse? You’re probably running this code in production. Every modification means retesting everything. That credit card logic that’s been working for two years? Now you have to verify it still works after you added crypto support. Exhausting.
Better approach:
class PaymentProcessor {
constructor(paymentMethod) {
this.paymentMethod = paymentMethod;
}
processPayment(amount) {
return this.paymentMethod.pay(amount);
}
}
class CreditCardPayment {
pay(amount) {
// Credit card logic
}
}
class PayPalPayment {
pay(amount) {
// PayPal logic
}
}
class CryptoPayment {
pay(amount) {
// Crypto logic
}
}
Now you can add new payment methods without touching `PaymentProcessor`. Just create a new class that implements the `pay` method. The system is open for extension, but closed for modification.
The existing payment methods keep working. No retesting needed. No risk of breaking production. You just plug in the new payment type and go. That’s the power of this principle.
L: Liskov Substitution Principle
Subtypes must be substitutable for their base types.
This is my favorite analogy: if it looks like a duck and quacks like a duck but needs batteries, you probably have the wrong abstraction.
In practice, this means if you have a base class and a derived class, you should be able to use the derived class anywhere the base class is expected without breaking things. Sounds reasonable, right? But it’s surprisingly easy to violate.
Here’s a classic violation:
class Bird {
fly() {
console.log(’Flying!’);
}
}
class Penguin extends Bird {
fly() {
throw new Error(’Penguins cannot fly!’);
}
}
This breaks LSP. If some code expects a `Bird` and calls `fly()`, it’ll crash if you pass in a `Penguin`. The subtype isn’t substitutable for the base type.
I’ve seen this pattern so many times in real codebases. Someone creates a nice abstraction, then realizes “wait, this special case doesn’t quite fit,” so they add a method that throws an error. Now every caller has to know about this special case and handle it differently. The abstraction becomes useless.
Better approach:
class Bird {
move() {
console.log(’Moving!’);
}
}
class FlyingBird extends Bird {
fly() {
console.log(’Flying!’);
}
}
class Penguin extends Bird {
swim() {
console.log(’Swimming!’);
}
}
Now “Penguin” doesn’t promise something it can’t deliver. The abstraction actually matches reality.
The key lesson? Don’t force inheritance where it doesn’t fit. Think carefully about what behaviors your base class promises, and make sure all subclasses can actually fulfill those promises. If they can’t, your abstraction is wrong. It’s that simple.
I: Interface Segregation Principle
Clients shouldn’t be forced to depend on methods they don’t use.
Remember: an interface with only one method is totally fine. In fact, it’s often ideal.
The opposite of this is called a “fat interface.” It’s when you have one massive interface that tries to do everything, and clients are forced to implement methods they don’t actually need. This creates friction everywhere.
Here’s an example:
// Bad: Fat interface
class Worker {
work() { }
eat() { }
sleep() { }
attendMeeting() { }
}
class Robot extends Worker {
work() {
console.log('Working...');
}
eat() {
throw new Error('Robots do not eat');
}
sleep() {
throw new Error('Robots do not sleep');
}
attendMeeting() {
throw new Error(’Robots do not attend meetings’);
}
}
This is absurd, right? The `Robot` is forced to implement methods that make no sense for it. And look at all those error throws. That’s a code smell. Any time you’re implementing a method just to throw an error, you’ve violated this principle.
Better approach:
class Workable {
work() { }
}
class Eatable {
eat() { }
}
class Sleepable {
sleep() { }
}
class Human extends Workable, Eatable, Sleepable {
// Implements all three
}
class Robot extends Workable {
// Only implements work
}
Now each class only depends on the interfaces it actually needs. Tailor your interfaces to individual clients’ needs. Keep them small, focused, and specific.
Well… I’ll be honest. When I first learned this principle, I thought “isn’t this just common sense?” But then I started reviewing code and realized how often it’s violated. We love creating one big interface because it feels organized. “Everything a Worker needs is in one place!” But that convenience comes at a cost. Don’t fall into that trap.
D: Dependency Inversion Principle
It depend on abstractions, not concretions.
The metaphor here is perfect: would you solder a lamp directly to the electrical wiring in a wall? Of course not. You’d use a plug and socket. That way you can swap out the lamp without rewiring your house.
The same principle applies to code. High-level modules shouldn’t depend on low-level implementation details. Both should depend on abstractions. This is probably the most impactful of all five principles when you actually apply it.
Here’s what this looks like in practice:
// Bad: Direct dependency on implementation
class UserService {
constructor() {
this.database = new MySQLDatabase();
}
getUser(id) {
return this.database.query(`SELECT * FROM users WHERE id = ${id}`);
}
}
This is tightly coupled to MySQL. What if you want to switch to PostgreSQL? Or add a caching layer? You have to modify `UserService`. And probably every test that uses it. And probably a dozen other places in your codebase.
I’ve been on teams where switching databases took months because of this kind of coupling. It’s painful.
Better approach:
// Good: Depend on abstraction
class UserService {
constructor(database) {
this.database = database;
}
getUser(id) {
return this.database.findById(’users’, id);
}
}
class MySQLDatabase {
findById(table, id) {
// MySQL-specific implementation
}
}
class PostgreSQLDatabase {
findById(table, id) {
// PostgreSQL-specific implementation
}
}
// Usage
const db = new MySQLDatabase();
const userService = new UserService(db);
Now `UserService` doesn’t care what database you’re using. It just depends on something stable: the abstraction that any database provides a `findById` method.
This is dependency injection in action. And it makes your code incredibly flexible and testable. Want to test `UserService`? Just pass in a fake database object. No need to spin up a real database for every test. Beautiful 🎯
Putting It All Together
SOLID principles aren’t about making your code more complex. They’re about making it more stable and easier to change.
Here’s the thing: you don’t need to apply all five principles everywhere, all the time. That’s over-engineering. But when you feel pain in your codebase—when changes become risky, when bugs keep popping up, when adding features feels like playing Jenga—that’s when SOLID principles show their value.
I’ve worked on codebases that followed these principles religiously, and codebases that ignored them completely. The difference is night and day. The SOLID codebases? Features take hours, not weeks. Bugs are isolated and easy to fix. New team members can contribute confidently. The non-SOLID codebases? Every change is scary. Nobody wants to touch certain files. Technical debt compounds until eventually someone suggests a full rewrite.
Don’t be that team.
Key takeaways
Single Responsibility: One class, one reason to change. But don’t over-engineer it. Start simple, refactor when the pain appears.
Open-Closed: Add features without modifying existing code. Your production code will thank you.
Liskov Substitution: Subtypes should be drop-in replacements. If you’re throwing errors in overridden methods, your abstraction is wrong.
Interface Segregation: Keep interfaces small and focused. One method per interface is totally fine.
Dependency Inversion: Depend on stable abstractions, not concrete implementations. This makes testing easy and changes safe.
Start applying these when the pain appears. Your future self (and your teammates) will thank you ☺️
I’ve developed numerous projects on my GitHub over the years. Feel free to browse them for inspiration or to contribute! And I’ve got more content coming your way on my LinkedIn.




