Don't Build Your Code Like a House of Cards: Understanding SOLID Principles with JavaScript Examples
As software engineers, we often face the frustrating scenario where a minor change in code leads to an avalanche of bugs and system failures. It's like building a house of cards—one wrong move, and everything collapses. This fragility occurs when our code lacks strong foundational principles, which makes it harder to maintain, scale, and debug.
That’s where SOLID principles come in. These five rules—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—are the foundation for writing flexible, maintainable, and scalable code. Let’s break them down one by one, with examples in both Java and JavaScript to make things crystal clear.
Single Responsibility Principle (SRP)
A class should have only one reason to change. In other words, each class should handle one responsibility or functionality.
Why It Matters:
When a class handles multiple responsibilities, changes to one part of the class could inadvertently affect another. This makes the code harder to maintain and test.
// Bad Example: One function does too much
class User {
createUser() {
// Logic to create user
}
sendWelcomeEmail() {
// Logic to send a welcome email
}
}
// Good Example: Separate responsibilities
class UserCreator {
createUser() {
// Logic to create user
}
}
class WelcomeEmailSender {
sendWelcomeEmail() {
// Logic to send a welcome email
}
}
Open/Closed Principle (OCP)
Software entities (classes, modules, functions) should be open for extension but closed for modification.
Why It Matters:
When your code is open for extension, you can add new features without changing existing code, which helps avoid introducing bugs into stable parts of your system.
// Bad Example: Violates OCP by hardcoding conditions
class PaymentProcessor {
processPayment(type) {
if (type === "credit") {
// Process credit payment
} else if (type === "paypal") {
// Process PayPal payment
}
}
}
// Good Example: Follows OCP by allowing extension
class PaymentProcessor {
process(payment) {
payment.process();
}
}
class CreditPayment {
process() {
// Process credit payment
}
}
class PayPalPayment {
process() {
// Process PayPal payment
}
}
Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.
Why It Matters:
Violating LSP can lead to fragile systems where using a subclass instead of a superclass causes unexpected behaviors. Your subclasses should behave like their parent class.
// Bad Example: Violates LSP because a Car class can't behave like a Bike
class Vehicle {
start() {
// Vehicle start logic
}
}
class Bike extends Vehicle {
start() {
// Bike start logic
}
}
class Car extends Vehicle {
start() {
throw new Error("Cars need keys to start!");
}
}
// Good Example: Follows LSP by separating responsibilities
class Vehicle {
start() {
throw new Error("This method should be overridden");
}
}
class Bike extends Vehicle {
start() {
// Bike start logic
}
}
class Car extends Vehicle {
start() {
// Car start logic
}
}
Interface Segregation Principle (ISP)
Clients should not be forced to implement interfaces they don’t use. In other words, it’s better to have small, specific interfaces than large, general ones.
Why It Matters:
Violating ISP forces your classes to implement methods they don’t need, leading to bloated classes and harder maintenance.
// Bad Example: A class implementing an interface with unnecessary methods
class Worker {
work() {
// Logic for work
}
eat() {
// Robots don't need to eat!
}
}
// Good Example: Separate interfaces
class Workable {
work() {
// Logic for work
}
}
class Eatable {
eat() {
// Logic for eating
}
}
class Robot extends Workable {
work() {
// Robot works
}
}
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
Why It Matters:
This principle allows for flexible code where the implementation details can change without impacting the higher-level modules, making it easier to maintain and swap out dependencies.
// Bad Example: Direct dependency on low-level class
class LightBulb {
turnOn() {
console.log("Light is on");
}
}
class Switch {
constructor() {
this.bulb = new LightBulb();
}
operate() {
this.bulb.turnOn();
}
}
// Good Example: Inverting dependency with abstraction
class Switchable {
turnOn() {
throw new Error("This method should be overridden");
}
}
class LightBulb extends Switchable {
turnOn() {
console.log("Light is on");
}
}
class Switch {
constructor(device) {
this.device = device;
}
operate() {
this.device.turnOn();
}
}
Build a Strong Foundation, Not a Fragile House of Cards
The SOLID principles form the bedrock of maintainable and scalable software development. When you follow these principles, your code is easier to read, debug, and extend without introducing new bugs or breaking existing functionality
Just as a house with weak foundations will crumble, so will your codebase if you ignore these guiding principles. Make your code SOLID, and it will stand strong, even as the complexity of your system grows.