๐๏ธ SOLID Design Principles
The foundation of maintainable and scalable object-oriented design
Overviewโ
The SOLID principles are a set of design principles introduced by Robert C. Martin (Uncle Bob) that help create more maintainable, understandable, and flexible software. These principles form the foundation of good object-oriented programming and clean architecture.
๐ The Five Principlesโ
| Principle | Description | Benefit |
|---|---|---|
| S - Single Responsibility | A class should have only one reason to change | ๐ฏ Focused classes |
| O - Open/Closed | Open for extension, closed for modification | ๐ง Easy to extend |
| L - Liskov Substitution | Objects should be replaceable with instances of subtypes | ๐ Proper inheritance |
| I - Interface Segregation | Don't force clients to depend on unused interfaces | ๐๏ธ Lean interfaces |
| D - Dependency Inversion | Depend on abstractions, not concretions | ๐๏ธ Flexible architecture |
๐ฏ Single Responsibility Principleโ
A class should have only one reason to change
What it meansโ
Each class should have only one responsibility and only one reason to change. A class should do one thing and do it well.
โ Violation Exampleโ
// BAD: UserManager does too many things
class UserManager {
constructor() {
this.users = [];
}
// User management
addUser(user) { /* ... */ }
removeUser(userId) { /* ... */ }
// Email functionality
sendWelcomeEmail(user) { /* ... */ }
sendPasswordResetEmail(user) { /* ... */ }
// Database operations
saveToDatabase(user) { /* ... */ }
loadFromDatabase(userId) { /* ... */ }
// Report generation
generateUserReport() { /* ... */ }
}
โ Better Approachโ
// GOOD: Separate responsibilities
class UserManager {
constructor() {
this.users = [];
}
addUser(user) { /* ... */ }
removeUser(userId) { /* ... */ }
getUser(userId) { /* ... */ }
}
class EmailService {
sendWelcomeEmail(user) { /* ... */ }
sendPasswordResetEmail(user) { /* ... */ }
}
class UserRepository {
save(user) { /* ... */ }
findById(userId) { /* ... */ }
}
class ReportGenerator {
generateUserReport(users) { /* ... */ }
}
๐ Benefitsโ
- Easier to understand and maintain
- Reduced coupling
- Better testability
- Clear separation of concerns
๐ง Open/Closed Principleโ
Software entities should be open for extension but closed for modification
What it meansโ
You should be able to extend a class's behavior without modifying its existing code.
โ Violation Exampleโ
// BAD: Must modify existing code to add new shapes
class AreaCalculator {
calculateArea(shapes) {
let totalArea = 0;
for (const shape of shapes) {
if (shape.type === 'rectangle') {
totalArea += shape.width * shape.height;
} else if (shape.type === 'circle') {
totalArea += Math.PI * shape.radius * shape.radius;
}
// To add triangle, we must modify this class!
}
return totalArea;
}
}
โ Better Approachโ
// GOOD: Open for extension, closed for modification
class Shape {
calculateArea() {
throw new Error('calculateArea must be implemented');
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
calculateArea() {
return this.width * this.height;
}
}
class Circle extends Shape {
constructor(radius) {
super();
this.radius = radius;
}
calculateArea() {
return Math.PI * this.radius * this.radius;
}
}
// New shape - no need to modify existing code!
class Triangle extends Shape {
constructor(base, height) {
super();
this.base = base;
this.height = height;
}
calculateArea() {
return 0.5 * this.base * this.height;
}
}
class AreaCalculator {
calculateArea(shapes) {
return shapes.reduce((total, shape) => total + shape.calculateArea(), 0);
}
}
๐ Benefitsโ
- Reduces risk when adding new functionality
- Encourages proper use of inheritance and polymorphism
- Makes code more maintainable
๐ Liskov Substitution Principleโ
Objects of a superclass should be replaceable with objects of its subclasses without altering program correctness
What it meansโ
If class B is a subtype of class A, then objects of type A should be replaceable with objects of type B without changing the desirable properties of the program.
โ Violation Exampleโ
// BAD: Penguin violates LSP - it can't fly!
class Bird {
fly() {
console.log('Flying high!');
}
}
class Duck extends Bird {
fly() {
console.log('Duck flying!');
}
}
class Penguin extends Bird {
fly() {
throw new Error('Penguins cannot fly!'); // Violates LSP!
}
}
// This will break with Penguin
function makeBirdFly(bird) {
bird.fly(); // Crashes if bird is a Penguin
}
โ Better Approachโ
// GOOD: Proper hierarchy that respects LSP
class Bird {
eat() {
console.log('Bird is eating');
}
}
class FlyingBird extends Bird {
fly() {
console.log('Flying high!');
}
}
class Duck extends FlyingBird {
fly() {
console.log('Duck flying!');
}
}
class Penguin extends Bird {
swim() {
console.log('Penguin swimming!');
}
}
// Now this works correctly
function makeFlyingBirdFly(bird) {
if (bird instanceof FlyingBird) {
bird.fly();
}
}
๐ Benefitsโ
- Ensures proper inheritance hierarchies
- Maintains behavioral consistency
- Enables safe polymorphism
Interface Segregation Principleโ
No client should be forced to depend on methods it does not use
What it meansโ
Create specific interfaces rather than large, general-purpose ones. Clients should not be forced to depend on interfaces they don't use.
โ Violation Exampleโ
// BAD: Fat interface forces all implementations to have unused methods
class WorkerInterface {
work() { throw new Error('Must implement'); }
eat() { throw new Error('Must implement'); }
sleep() { throw new Error('Must implement'); }
}
class Human extends WorkerInterface {
work() { console.log('Human working'); }
eat() { console.log('Human eating'); }
sleep() { console.log('Human sleeping'); }
}
class Robot extends WorkerInterface {
work() { console.log('Robot working'); }
eat() { throw new Error('Robots do not eat!'); } // Forced to implement!
sleep() { throw new Error('Robots do not sleep!'); } // Forced to implement!
}
โ Better Approachโ
// GOOD: Segregated interfaces
class Workable {
work() { throw new Error('Must implement work'); }
}
class Feedable {
eat() { throw new Error('Must implement eat'); }
}
class Sleepable {
sleep() { throw new Error('Must implement sleep'); }
}
class Human {
work() { console.log('Human working'); }
eat() { console.log('Human eating'); }
sleep() { console.log('Human sleeping'); }
}
class Robot {
work() { console.log('Robot working'); }
// Robot doesn't need to implement eat() or sleep()!
}
// Mix and match as needed
class WorkerManager {
makeWork(workable) {
if (workable.work) {
workable.work();
}
}
provideMeal(feedable) {
if (feedable.eat) {
feedable.eat();
}
}
}
๐ Benefitsโ
- Reduces unnecessary dependencies
- Makes interfaces more focused and cohesive
- Increases flexibility and maintainability
Dependency Inversion Principleโ
Depend on abstractions, not concretions
What it meansโ
- 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.
โ Violation Exampleโ
// BAD: High-level class depends directly on low-level classes
class EmailService {
sendEmail(message) {
console.log(`Sending email: ${message}`);
}
}
class SMSService {
sendSMS(message) {
console.log(`Sending SMS: ${message}`);
}
}
class NotificationManager {
constructor() {
this.emailService = new EmailService(); // Direct dependency!
this.smsService = new SMSService(); // Direct dependency!
}
sendNotification(message, type) {
if (type === 'email') {
this.emailService.sendEmail(message);
} else if (type === 'sms') {
this.smsService.sendSMS(message);
}
}
}
โ Better Approachโ
// GOOD: Depend on abstractions
class NotificationService {
send(message) {
throw new Error('Must implement send method');
}
}
class EmailService extends NotificationService {
send(message) {
console.log(`Sending email: ${message}`);
}
}
class SMSService extends NotificationService {
send(message) {
console.log(`Sending SMS: ${message}`);
}
}
class PushNotificationService extends NotificationService {
send(message) {
console.log(`Sending push notification: ${message}`);
}
}
class NotificationManager {
constructor(notificationServices = []) {
this.services = notificationServices; // Dependency injection!
}
addService(service) {
this.services.push(service);
}
sendToAll(message) {
this.services.forEach(service => service.send(message));
}
}
// Usage with dependency injection
const notificationManager = new NotificationManager([
new EmailService(),
new SMSService(),
new PushNotificationService()
]);
๐ Benefitsโ
- Increases flexibility and maintainability
- Makes code more testable (easy to mock dependencies)
- Reduces coupling between modules
- Enables easier changes and extensions
๐ฏ Applying SOLID Principles Togetherโ
// Example: E-commerce system applying all SOLID principles
// S - Single Responsibility: Each class has one responsibility
class Product {
constructor(id, name, price) {
this.id = id;
this.name = name;
this.price = price;
}
}
class ShoppingCart {
constructor() {
this.items = [];
}
addItem(product, quantity) {
this.items.push({ product, quantity });
}
removeItem(productId) {
this.items = this.items.filter(item => item.product.id !== productId);
}
getItems() {
return this.items;
}
}
// O - Open/Closed: Open for extension, closed for modification
class DiscountStrategy {
calculate(amount) {
throw new Error('Must implement calculate method');
}
}
class NoDiscount extends DiscountStrategy {
calculate(amount) {
return amount;
}
}
class PercentageDiscount extends DiscountStrategy {
constructor(percentage) {
super();
this.percentage = percentage;
}
calculate(amount) {
return amount * (1 - this.percentage / 100);
}
}
class FixedAmountDiscount extends DiscountStrategy {
constructor(discountAmount) {
super();
this.discountAmount = discountAmount;
}
calculate(amount) {
return Math.max(0, amount - this.discountAmount);
}
}
// L - Liskov Substitution: All discount strategies are interchangeable
// I - Interface Segregation: PaymentProcessor only does payments
class PaymentProcessor {
process(amount) {
throw new Error('Must implement process method');
}
}
class CreditCardProcessor extends PaymentProcessor {
process(amount) {
console.log(`Processing $${amount} via credit card`);
return true;
}
}
class PayPalProcessor extends PaymentProcessor {
process(amount) {
console.log(`Processing $${amount} via PayPal`);
return true;
}
}
// D - Dependency Inversion: OrderService depends on abstractions
class OrderService {
constructor(paymentProcessor, discountStrategy = new NoDiscount()) {
this.paymentProcessor = paymentProcessor;
this.discountStrategy = discountStrategy;
}
processOrder(cart) {
const total = cart.getItems().reduce((sum, item) =>
sum + (item.product.price * item.quantity), 0
);
const discountedTotal = this.discountStrategy.calculate(total);
return this.paymentProcessor.process(discountedTotal);
}
}
// Usage
const cart = new ShoppingCart();
cart.addItem(new Product(1, 'Laptop', 1000), 1);
cart.addItem(new Product(2, 'Mouse', 50), 2);
const orderService = new OrderService(
new CreditCardProcessor(),
new PercentageDiscount(10)
);
orderService.processOrder(cart);
๐ Benefits of Following SOLIDโ
| Benefit | Description |
|---|---|
| ๐งน Maintainability | Code is easier to understand and modify |
| ๐งช Testability | Easier to write unit tests with proper isolation |
| ๐ง Flexibility | Easy to extend and modify without breaking existing code |
| ๐ Reusability | Components can be reused in different contexts |
| ๐ฅ Team Collaboration | Clear responsibilities make team development smoother |
| ๐ Bug Reduction | Better structure leads to fewer bugs |
๐ Further Readingโ
- SOLID Principles on Wikipedia
- Clean Architecture by Robert C. Martin
- Martin Fowler's Design Principles
๐ฏ Next Stepsโ
- Practice: Apply these principles in your current projects
- Refactor: Review existing code and identify SOLID violations
- Learn: Study design patterns that implement these principles
- Share: Teach these concepts to your team members
Remember: SOLID principles are guidelines, not rigid rules. Use them wisely to create better software architecture! ๐