Skip to main content

๐Ÿ›๏ธ 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โ€‹

PrincipleDescriptionBenefit
S - Single ResponsibilityA class should have only one reason to change๐ŸŽฏ Focused classes
O - Open/ClosedOpen for extension, closed for modification๐Ÿ”ง Easy to extend
L - Liskov SubstitutionObjects should be replaceable with instances of subtypes๐Ÿ”„ Proper inheritance
I - Interface SegregationDon't force clients to depend on unused interfaces๐ŸŽ›๏ธ Lean interfaces
D - Dependency InversionDepend 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โ€‹

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. 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โ€‹

BenefitDescription
๐Ÿงน MaintainabilityCode is easier to understand and modify
๐Ÿงช TestabilityEasier to write unit tests with proper isolation
๐Ÿ”ง FlexibilityEasy to extend and modify without breaking existing code
๐Ÿ”„ ReusabilityComponents can be reused in different contexts
๐Ÿ‘ฅ Team CollaborationClear responsibilities make team development smoother
๐Ÿ› Bug ReductionBetter structure leads to fewer bugs

๐Ÿ“š Further Readingโ€‹

๐ŸŽฏ Next Stepsโ€‹

  1. Practice: Apply these principles in your current projects
  2. Refactor: Review existing code and identify SOLID violations
  3. Learn: Study design patterns that implement these principles
  4. Share: Teach these concepts to your team members

Remember: SOLID principles are guidelines, not rigid rules. Use them wisely to create better software architecture! ๐Ÿš€