SOLID Principles
The SOLID principles are five foundational guidelines for object-oriented design, introduced by Robert C. Martin (Uncle Bob). They help developers write code that is easier to understand, maintain, extend, and refactor. When followed consistently, SOLID principles reduce technical debt and make software systems resilient to change.
| Letter | Principle | Core Idea |
|---|---|---|
| S | Single Responsibility | A class should have only one reason to change |
| O | Open/Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be substitutable for their base types |
| I | Interface Segregation | Prefer many small interfaces over one large interface |
| D | Dependency Inversion | Depend on abstractions, not concrete implementations |
S — Single Responsibility Principle (SRP)
“A class should have one, and only one, reason to change.” — Robert C. Martin
The Single Responsibility Principle states that every class should encapsulate exactly one responsibility. If a class handles multiple concerns, changes to one concern risk breaking the other. SRP leads to smaller, focused classes that are easier to test and maintain.
The Problem: A Class Doing Too Much
Consider a User class that manages user data, validates input, and sends emails. Any change to the email system, validation rules, or data format forces changes to the same class.
# BAD: User class has three responsibilitiesclass User: def __init__(self, name, email): self.name = name self.email = email
def get_user_info(self): return f"{self.name} ({self.email})"
def validate_email(self): # Validation logic -- reason to change #2 import re pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$' return bool(re.match(pattern, self.email))
def send_welcome_email(self): # Email sending logic -- reason to change #3 if self.validate_email(): print(f"Sending welcome email to {self.email}") # SMTP setup, template rendering, etc.// BAD: User class has three responsibilitiesclass User { constructor(name, email) { this.name = name; this.email = email; }
getUserInfo() { return `${this.name} (${this.email})`; }
validateEmail() { // Validation logic -- reason to change #2 const pattern = /^[\w\.-]+@[\w\.-]+\.\w+$/; return pattern.test(this.email); }
sendWelcomeEmail() { // Email sending logic -- reason to change #3 if (this.validateEmail()) { console.log(`Sending welcome email to ${this.email}`); // SMTP setup, template rendering, etc. } }}// BAD: User class has three responsibilitiespublic class User { private String name; private String email;
public User(String name, String email) { this.name = name; this.email = email; }
public String getUserInfo() { return name + " (" + email + ")"; }
// Validation logic -- reason to change #2 public boolean validateEmail() { return email.matches("^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$"); }
// Email sending logic -- reason to change #3 public void sendWelcomeEmail() { if (validateEmail()) { System.out.println("Sending welcome email to " + email); // SMTP setup, template rendering, etc. } }}// BAD: User class has three responsibilities#include <iostream>#include <string>#include <regex>using namespace std;
class User { string name; string email;
public: User(string name, string email) : name(name), email(email) {}
string getUserInfo() { return name + " (" + email + ")"; }
// Validation logic -- reason to change #2 bool validateEmail() { regex pattern(R"(^[\w\.-]+@[\w\.-]+\.\w+$)"); return regex_match(email, pattern); }
// Email sending logic -- reason to change #3 void sendWelcomeEmail() { if (validateEmail()) { cout << "Sending welcome email to " << email << endl; // SMTP setup, template rendering, etc. } }};The Solution: Separate Responsibilities
Split each responsibility into its own class. Now changes to email logic, validation rules, or user data are isolated from one another.
# GOOD: Each class has a single responsibility
class User: """Responsibility: manage user data""" def __init__(self, name, email): self.name = name self.email = email
def get_user_info(self): return f"{self.name} ({self.email})"
class EmailValidator: """Responsibility: validate email addresses""" @staticmethod def is_valid(email): import re pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$' return bool(re.match(pattern, email))
class EmailService: """Responsibility: send emails""" def __init__(self, validator): self.validator = validator
def send_welcome_email(self, user): if self.validator.is_valid(user.email): print(f"Sending welcome email to {user.email}")
# Usageuser = User("Alice", "alice@example.com")validator = EmailValidator()email_service = EmailService(validator)email_service.send_welcome_email(user)// GOOD: Each class has a single responsibility
class User { // Responsibility: manage user data constructor(name, email) { this.name = name; this.email = email; }
getUserInfo() { return `${this.name} (${this.email})`; }}
class EmailValidator { // Responsibility: validate email addresses static isValid(email) { const pattern = /^[\w\.-]+@[\w\.-]+\.\w+$/; return pattern.test(email); }}
class EmailService { // Responsibility: send emails constructor(validator) { this.validator = validator; }
sendWelcomeEmail(user) { if (this.validator.isValid(user.email)) { console.log(`Sending welcome email to ${user.email}`); } }}
// Usageconst user = new User("Alice", "alice@example.com");const emailService = new EmailService(EmailValidator);emailService.sendWelcomeEmail(user);// GOOD: Each class has a single responsibility
// Responsibility: manage user datapublic class User { private String name; private String email;
public User(String name, String email) { this.name = name; this.email = email; }
public String getName() { return name; } public String getEmail() { return email; }
public String getUserInfo() { return name + " (" + email + ")"; }}
// Responsibility: validate email addressespublic class EmailValidator { public static boolean isValid(String email) { return email.matches("^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$"); }}
// Responsibility: send emailspublic class EmailService { private EmailValidator validator;
public EmailService(EmailValidator validator) { this.validator = validator; }
public void sendWelcomeEmail(User user) { if (EmailValidator.isValid(user.getEmail())) { System.out.println("Sending welcome email to " + user.getEmail()); } }}// GOOD: Each class has a single responsibility#include <iostream>#include <string>#include <regex>using namespace std;
// Responsibility: manage user dataclass User { string name; string email;
public: User(string name, string email) : name(name), email(email) {}
string getName() const { return name; } string getEmail() const { return email; } string getUserInfo() const { return name + " (" + email + ")"; }};
// Responsibility: validate email addressesclass EmailValidator {public: static bool isValid(const string& email) { regex pattern(R"(^[\w\.-]+@[\w\.-]+\.\w+$)"); return regex_match(email, pattern); }};
// Responsibility: send emailsclass EmailService {public: void sendWelcomeEmail(const User& user) { if (EmailValidator::isValid(user.getEmail())) { cout << "Sending welcome email to " << user.getEmail() << endl; } }};O — Open/Closed Principle (OCP)
“Software entities should be open for extension, but closed for modification.” — Bertrand Meyer
The Open/Closed Principle means you should be able to add new behavior to a system without changing existing, tested code. This is typically achieved through abstraction — using interfaces, abstract classes, or polymorphism so that new functionality is added via new classes rather than by editing old ones.
The Problem: Modifying Existing Code for Every New Feature
Imagine a discount calculator that uses if/else chains. Every time a new discount type is introduced, you must modify the existing function, risking bugs in previously working logic.
# BAD: Must modify this function for every new discount typeclass DiscountCalculator: def calculate(self, customer_type, amount): if customer_type == "regular": return amount * 0.95 # 5% discount elif customer_type == "premium": return amount * 0.90 # 10% discount elif customer_type == "vip": return amount * 0.80 # 20% discount # Adding a new type? Must edit this class! else: return amount// BAD: Must modify this function for every new discount typeclass DiscountCalculator { calculate(customerType, amount) { if (customerType === "regular") { return amount * 0.95; // 5% discount } else if (customerType === "premium") { return amount * 0.90; // 10% discount } else if (customerType === "vip") { return amount * 0.80; // 20% discount } // Adding a new type? Must edit this class! return amount; }}// BAD: Must modify this method for every new discount typepublic class DiscountCalculator { public double calculate(String customerType, double amount) { if (customerType.equals("regular")) { return amount * 0.95; // 5% discount } else if (customerType.equals("premium")) { return amount * 0.90; // 10% discount } else if (customerType.equals("vip")) { return amount * 0.80; // 20% discount } // Adding a new type? Must edit this class! return amount; }}// BAD: Must modify this function for every new discount type#include <string>using namespace std;
class DiscountCalculator {public: double calculate(const string& customerType, double amount) { if (customerType == "regular") { return amount * 0.95; // 5% discount } else if (customerType == "premium") { return amount * 0.90; // 10% discount } else if (customerType == "vip") { return amount * 0.80; // 20% discount } // Adding a new type? Must edit this class! return amount; }};The Solution: Extend with New Classes, Not Modifications
Define a DiscountStrategy abstraction and implement each discount type as its own class. New types are added by creating new classes — existing code is never touched.
# GOOD: Open for extension, closed for modificationfrom abc import ABC, abstractmethod
class DiscountStrategy(ABC): @abstractmethod def calculate(self, amount): pass
class RegularDiscount(DiscountStrategy): def calculate(self, amount): return amount * 0.95 # 5% discount
class PremiumDiscount(DiscountStrategy): def calculate(self, amount): return amount * 0.90 # 10% discount
class VIPDiscount(DiscountStrategy): def calculate(self, amount): return amount * 0.80 # 20% discount
# Adding a new discount? Just create a new class!class EmployeeDiscount(DiscountStrategy): def calculate(self, amount): return amount * 0.70 # 30% discount
class DiscountCalculator: def __init__(self, strategy: DiscountStrategy): self.strategy = strategy
def calculate(self, amount): return self.strategy.calculate(amount)
# Usagecalc = DiscountCalculator(VIPDiscount())print(calc.calculate(100)) # 80.0// GOOD: Open for extension, closed for modification
class DiscountStrategy { calculate(amount) { throw new Error("Subclasses must implement calculate()"); }}
class RegularDiscount extends DiscountStrategy { calculate(amount) { return amount * 0.95; // 5% discount }}
class PremiumDiscount extends DiscountStrategy { calculate(amount) { return amount * 0.90; // 10% discount }}
class VIPDiscount extends DiscountStrategy { calculate(amount) { return amount * 0.80; // 20% discount }}
// Adding a new discount? Just create a new class!class EmployeeDiscount extends DiscountStrategy { calculate(amount) { return amount * 0.70; // 30% discount }}
class DiscountCalculator { constructor(strategy) { this.strategy = strategy; }
calculate(amount) { return this.strategy.calculate(amount); }}
// Usageconst calc = new DiscountCalculator(new VIPDiscount());console.log(calc.calculate(100)); // 80// GOOD: Open for extension, closed for modification
public interface DiscountStrategy { double calculate(double amount);}
public class RegularDiscount implements DiscountStrategy { public double calculate(double amount) { return amount * 0.95; // 5% discount }}
public class PremiumDiscount implements DiscountStrategy { public double calculate(double amount) { return amount * 0.90; // 10% discount }}
public class VIPDiscount implements DiscountStrategy { public double calculate(double amount) { return amount * 0.80; // 20% discount }}
// Adding a new discount? Just create a new class!public class EmployeeDiscount implements DiscountStrategy { public double calculate(double amount) { return amount * 0.70; // 30% discount }}
public class DiscountCalculator { private DiscountStrategy strategy;
public DiscountCalculator(DiscountStrategy strategy) { this.strategy = strategy; }
public double calculate(double amount) { return strategy.calculate(amount); }}
// Usage// DiscountCalculator calc = new DiscountCalculator(new VIPDiscount());// System.out.println(calc.calculate(100)); // 80.0// GOOD: Open for extension, closed for modification#include <iostream>#include <memory>using namespace std;
class DiscountStrategy {public: virtual double calculate(double amount) const = 0; virtual ~DiscountStrategy() = default;};
class RegularDiscount : public DiscountStrategy {public: double calculate(double amount) const override { return amount * 0.95; // 5% discount }};
class PremiumDiscount : public DiscountStrategy {public: double calculate(double amount) const override { return amount * 0.90; // 10% discount }};
class VIPDiscount : public DiscountStrategy {public: double calculate(double amount) const override { return amount * 0.80; // 20% discount }};
// Adding a new discount? Just create a new class!class EmployeeDiscount : public DiscountStrategy {public: double calculate(double amount) const override { return amount * 0.70; // 30% discount }};
class DiscountCalculator { unique_ptr<DiscountStrategy> strategy;
public: DiscountCalculator(unique_ptr<DiscountStrategy> strategy) : strategy(move(strategy)) {}
double calculate(double amount) const { return strategy->calculate(amount); }};
// Usage// auto calc = DiscountCalculator(make_unique<VIPDiscount>());// cout << calc.calculate(100) << endl; // 80L — Liskov Substitution Principle (LSP)
“Objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.” — Barbara Liskov
The Liskov Substitution Principle means that if your code works with a base class, it must also work correctly with any derived class. A subclass should extend behavior, not break the contract established by its parent.
The Problem: Subclass Breaks Parent’s Contract
The classic example is Rectangle and Square. A square is a rectangle mathematically, but in code, making Square inherit from Rectangle creates unexpected behavior when width and height are set independently.
# BAD: Square breaks the Rectangle contractclass Rectangle: def __init__(self, width, height): self._width = width self._height = height
def set_width(self, width): self._width = width
def set_height(self, height): self._height = height
def area(self): return self._width * self._height
class Square(Rectangle): def __init__(self, size): super().__init__(size, size)
def set_width(self, width): # Forces both dimensions to change -- breaks expectations! self._width = width self._height = width
def set_height(self, height): self._width = height self._height = height
# This function expects Rectangle behaviordef resize_and_check(rect): rect.set_width(5) rect.set_height(10) assert rect.area() == 50, f"Expected 50, got {rect.area()}"
resize_and_check(Rectangle(2, 3)) # Passesresize_and_check(Square(2)) # FAILS! area() returns 100, not 50// BAD: Square breaks the Rectangle contractclass Rectangle { constructor(width, height) { this._width = width; this._height = height; }
setWidth(width) { this._width = width; } setHeight(height) { this._height = height; } area() { return this._width * this._height; }}
class Square extends Rectangle { constructor(size) { super(size, size); }
// Forces both dimensions to change -- breaks expectations! setWidth(width) { this._width = width; this._height = width; }
setHeight(height) { this._width = height; this._height = height; }}
// This function expects Rectangle behaviorfunction resizeAndCheck(rect) { rect.setWidth(5); rect.setHeight(10); console.assert(rect.area() === 50, `Expected 50, got ${rect.area()}`);}
resizeAndCheck(new Rectangle(2, 3)); // PassesresizeAndCheck(new Square(2)); // FAILS! area() returns 100// BAD: Square breaks the Rectangle contractpublic class Rectangle { protected int width; protected int height;
public Rectangle(int width, int height) { this.width = width; this.height = height; }
public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int area() { return width * height; }}
public class Square extends Rectangle { public Square(int size) { super(size, size); }
// Forces both dimensions to change -- breaks expectations! @Override public void setWidth(int width) { this.width = width; this.height = width; }
@Override public void setHeight(int height) { this.width = height; this.height = height; }}
// resizeAndCheck(new Rectangle(2, 3)); // Passes// resizeAndCheck(new Square(2)); // FAILS! area() returns 100// BAD: Square breaks the Rectangle contract#include <iostream>#include <cassert>using namespace std;
class Rectangle {protected: int width; int height;
public: Rectangle(int w, int h) : width(w), height(h) {}
virtual void setWidth(int w) { width = w; } virtual void setHeight(int h) { height = h; } int area() const { return width * height; }};
class Square : public Rectangle {public: Square(int size) : Rectangle(size, size) {}
// Forces both dimensions to change -- breaks expectations! void setWidth(int w) override { width = w; height = w; }
void setHeight(int h) override { width = h; height = h; }};
void resizeAndCheck(Rectangle& rect) { rect.setWidth(5); rect.setHeight(10); assert(rect.area() == 50); // Fails for Square!}The Solution: Use a Common Abstraction
Instead of forcing Square to inherit from Rectangle, define a shared Shape interface. Each shape implements its own area logic independently, so substitution never breaks the contract.
# GOOD: Both shapes implement a common interface correctlyfrom abc import ABC, abstractmethod
class Shape(ABC): @abstractmethod def area(self): pass
class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height
def area(self): return self.width * self.height
class Square(Shape): def __init__(self, side): self.side = side
def area(self): return self.side * self.side
# Both work correctly with any code that expects a Shapedef print_area(shape: Shape): print(f"Area: {shape.area()}")
print_area(Rectangle(5, 10)) # Area: 50print_area(Square(7)) # Area: 49// GOOD: Both shapes implement a common interface correctly
class Shape { area() { throw new Error("Subclasses must implement area()"); }}
class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; }
area() { return this.width * this.height; }}
class Square extends Shape { constructor(side) { super(); this.side = side; }
area() { return this.side * this.side; }}
// Both work correctly with any code that expects a Shapefunction printArea(shape) { console.log(`Area: ${shape.area()}`);}
printArea(new Rectangle(5, 10)); // Area: 50printArea(new Square(7)); // Area: 49// GOOD: Both shapes implement a common interface correctly
public interface Shape { double area();}
public class Rectangle implements Shape { private final double width; private final double height;
public Rectangle(double width, double height) { this.width = width; this.height = height; }
public double area() { return width * height; }}
public class Square implements Shape { private final double side;
public Square(double side) { this.side = side; }
public double area() { return side * side; }}
// Both work correctly with any code that expects a Shape// public static void printArea(Shape shape) {// System.out.println("Area: " + shape.area());// }// GOOD: Both shapes implement a common interface correctly#include <iostream>#include <memory>using namespace std;
class Shape {public: virtual double area() const = 0; virtual ~Shape() = default;};
class Rectangle : public Shape { double width, height;
public: Rectangle(double w, double h) : width(w), height(h) {}
double area() const override { return width * height; }};
class Square : public Shape { double side;
public: Square(double s) : side(s) {}
double area() const override { return side * side; }};
// Both work correctly with any code that expects a Shapevoid printArea(const Shape& shape) { cout << "Area: " << shape.area() << endl;}
// printArea(Rectangle(5, 10)); // Area: 50// printArea(Square(7)); // Area: 49I — Interface Segregation Principle (ISP)
“No client should be forced to depend on methods it does not use.” — Robert C. Martin
The Interface Segregation Principle states that large, monolithic interfaces should be split into smaller, more specific ones. Classes should only need to implement the methods that are relevant to them. This prevents “fat interfaces” that force implementing classes to provide stub or empty methods for functionality they do not support.
The Problem: A Fat Interface
A Worker interface that requires all workers to implement work(), eat(), and sleep() forces a Robot class to provide meaningless implementations for eat() and sleep().
# BAD: Fat interface forces irrelevant implementationsfrom abc import ABC, abstractmethod
class Worker(ABC): @abstractmethod def work(self): pass
@abstractmethod def eat(self): pass
@abstractmethod def sleep(self): pass
class Human(Worker): def work(self): print("Human working")
def eat(self): print("Human eating")
def sleep(self): print("Human sleeping")
class Robot(Worker): def work(self): print("Robot working")
def eat(self): pass # Robots don't eat! Forced to implement.
def sleep(self): pass # Robots don't sleep! Forced to implement.// BAD: Fat interface forces irrelevant implementations
class Worker { work() { throw new Error("Must implement"); } eat() { throw new Error("Must implement"); } sleep() { throw new Error("Must implement"); }}
class Human extends Worker { work() { console.log("Human working"); } eat() { console.log("Human eating"); } sleep() { console.log("Human sleeping"); }}
class Robot extends Worker { work() { console.log("Robot working"); } eat() { /* Robots don't eat! Forced to implement. */ } sleep() { /* Robots don't sleep! Forced to implement. */ }}// BAD: Fat interface forces irrelevant implementationspublic interface Worker { void work(); void eat(); void sleep();}
public class Human implements Worker { public void work() { System.out.println("Human working"); } public void eat() { System.out.println("Human eating"); } public void sleep() { System.out.println("Human sleeping"); }}
public class Robot implements Worker { public void work() { System.out.println("Robot working"); } public void eat() { /* Robots don't eat! Forced to implement. */ } public void sleep() { /* Robots don't sleep! Forced to implement. */ }}// BAD: Fat interface forces irrelevant implementations#include <iostream>using namespace std;
class Worker {public: virtual void work() = 0; virtual void eat() = 0; virtual void sleep() = 0; virtual ~Worker() = default;};
class Human : public Worker {public: void work() override { cout << "Human working" << endl; } void eat() override { cout << "Human eating" << endl; } void sleep() override { cout << "Human sleeping" << endl; }};
class Robot : public Worker {public: void work() override { cout << "Robot working" << endl; } void eat() override { /* Robots don't eat! Forced to implement. */ } void sleep() override { /* Robots don't sleep! Forced to implement. */ }};The Solution: Split Into Focused Interfaces
Break the fat interface into smaller role-based interfaces. Each class only implements the interfaces that apply to it.
# GOOD: Small, focused interfacesfrom abc import ABC, abstractmethod
class Workable(ABC): @abstractmethod def work(self): pass
class Feedable(ABC): @abstractmethod def eat(self): pass
class Sleepable(ABC): @abstractmethod def sleep(self): pass
class Human(Workable, Feedable, Sleepable): def work(self): print("Human working")
def eat(self): print("Human eating")
def sleep(self): print("Human sleeping")
class Robot(Workable): def work(self): print("Robot working") # No need to implement eat() or sleep()!
# Functions depend only on the interface they needdef assign_task(worker: Workable): worker.work()
def schedule_lunch(eater: Feedable): eater.eat()
assign_task(Human()) # Worksassign_task(Robot()) # Worksschedule_lunch(Human()) # Works# schedule_lunch(Robot()) # Type error -- correct!// GOOD: Small, focused interfaces via mixins / composition
// Define capabilities as mixinsconst Workable = (Base) => class extends Base { work() { throw new Error("Must implement work()"); }};
const Feedable = (Base) => class extends Base { eat() { throw new Error("Must implement eat()"); }};
const Sleepable = (Base) => class extends Base { sleep() { throw new Error("Must implement sleep()"); }};
class Human extends Sleepable(Feedable(Workable(class {}))) { work() { console.log("Human working"); } eat() { console.log("Human eating"); } sleep() { console.log("Human sleeping"); }}
class Robot extends Workable(class {}) { work() { console.log("Robot working"); } // No need to implement eat() or sleep()!}
// Functions depend only on the capability they needfunction assignTask(worker) { worker.work();}
assignTask(new Human()); // WorksassignTask(new Robot()); // Works// GOOD: Small, focused interfaces
public interface Workable { void work();}
public interface Feedable { void eat();}
public interface Sleepable { void sleep();}
public class Human implements Workable, Feedable, Sleepable { public void work() { System.out.println("Human working"); } public void eat() { System.out.println("Human eating"); } public void sleep() { System.out.println("Human sleeping"); }}
public class Robot implements Workable { public void work() { System.out.println("Robot working"); } // No need to implement eat() or sleep()!}
// Methods depend only on the interface they need// public static void assignTask(Workable worker) { worker.work(); }// public static void scheduleLunch(Feedable eater) { eater.eat(); }// GOOD: Small, focused interfaces#include <iostream>using namespace std;
class Workable {public: virtual void work() = 0; virtual ~Workable() = default;};
class Feedable {public: virtual void eat() = 0; virtual ~Feedable() = default;};
class Sleepable {public: virtual void sleep() = 0; virtual ~Sleepable() = default;};
class Human : public Workable, public Feedable, public Sleepable {public: void work() override { cout << "Human working" << endl; } void eat() override { cout << "Human eating" << endl; } void sleep() override { cout << "Human sleeping" << endl; }};
class Robot : public Workable {public: void work() override { cout << "Robot working" << endl; } // No need to implement eat() or sleep()!};
// Functions depend only on the interface they needvoid assignTask(Workable& worker) { worker.work(); }void scheduleLunch(Feedable& eater) { eater.eat(); }D — Dependency Inversion Principle (DIP)
“High-level modules should not depend on low-level modules. Both should depend on abstractions.” — Robert C. Martin
The Dependency Inversion Principle states that instead of high-level business logic depending directly on low-level implementation details (databases, file systems, APIs), both should depend on abstract interfaces. This decouples modules from specific implementations and makes the system easier to test and swap components.
The Problem: High-Level Module Depends on Low-Level Details
A NotificationService that directly instantiates and depends on a concrete EmailSender is tightly coupled. Switching to SMS or push notifications requires rewriting the service.
# BAD: High-level module depends directly on low-level moduleclass EmailSender: def send(self, recipient, message): print(f"Email to {recipient}: {message}")
class NotificationService: def __init__(self): # Tightly coupled to EmailSender! self.sender = EmailSender()
def notify(self, recipient, message): self.sender.send(recipient, message)
# What if we want SMS? Must rewrite NotificationService!// BAD: High-level module depends directly on low-level moduleclass EmailSender { send(recipient, message) { console.log(`Email to ${recipient}: ${message}`); }}
class NotificationService { constructor() { // Tightly coupled to EmailSender! this.sender = new EmailSender(); }
notify(recipient, message) { this.sender.send(recipient, message); }}
// What if we want SMS? Must rewrite NotificationService!// BAD: High-level module depends directly on low-level modulepublic class EmailSender { public void send(String recipient, String message) { System.out.println("Email to " + recipient + ": " + message); }}
public class NotificationService { // Tightly coupled to EmailSender! private EmailSender sender = new EmailSender();
public void notify(String recipient, String message) { sender.send(recipient, message); }}
// What if we want SMS? Must rewrite NotificationService!// BAD: High-level module depends directly on low-level module#include <iostream>#include <string>using namespace std;
class EmailSender {public: void send(const string& recipient, const string& message) { cout << "Email to " << recipient << ": " << message << endl; }};
class NotificationService { // Tightly coupled to EmailSender! EmailSender sender;
public: void notify(const string& recipient, const string& message) { sender.send(recipient, message); }};
// What if we want SMS? Must rewrite NotificationService!The Solution: Depend on Abstractions
Define a MessageSender interface. The NotificationService depends on the abstraction, and concrete senders are injected at runtime. Swapping implementations requires zero changes to the service.
# GOOD: Both high-level and low-level depend on abstractionsfrom abc import ABC, abstractmethod
class MessageSender(ABC): @abstractmethod def send(self, recipient, message): pass
class EmailSender(MessageSender): def send(self, recipient, message): print(f"Email to {recipient}: {message}")
class SMSSender(MessageSender): def send(self, recipient, message): print(f"SMS to {recipient}: {message}")
class PushNotificationSender(MessageSender): def send(self, recipient, message): print(f"Push to {recipient}: {message}")
class NotificationService: def __init__(self, sender: MessageSender): # Depends on abstraction, not concrete class self.sender = sender
def notify(self, recipient, message): self.sender.send(recipient, message)
# Easy to swap implementationsservice = NotificationService(EmailSender())service.notify("alice@example.com", "Welcome!")
service = NotificationService(SMSSender())service.notify("+1234567890", "Your code is 1234")// GOOD: Both high-level and low-level depend on abstractions
class MessageSender { send(recipient, message) { throw new Error("Subclasses must implement send()"); }}
class EmailSender extends MessageSender { send(recipient, message) { console.log(`Email to ${recipient}: ${message}`); }}
class SMSSender extends MessageSender { send(recipient, message) { console.log(`SMS to ${recipient}: ${message}`); }}
class PushNotificationSender extends MessageSender { send(recipient, message) { console.log(`Push to ${recipient}: ${message}`); }}
class NotificationService { constructor(sender) { // Depends on abstraction, not concrete class this.sender = sender; }
notify(recipient, message) { this.sender.send(recipient, message); }}
// Easy to swap implementationslet service = new NotificationService(new EmailSender());service.notify("alice@example.com", "Welcome!");
service = new NotificationService(new SMSSender());service.notify("+1234567890", "Your code is 1234");// GOOD: Both high-level and low-level depend on abstractions
public interface MessageSender { void send(String recipient, String message);}
public class EmailSender implements MessageSender { public void send(String recipient, String message) { System.out.println("Email to " + recipient + ": " + message); }}
public class SMSSender implements MessageSender { public void send(String recipient, String message) { System.out.println("SMS to " + recipient + ": " + message); }}
public class PushNotificationSender implements MessageSender { public void send(String recipient, String message) { System.out.println("Push to " + recipient + ": " + message); }}
public class NotificationService { // Depends on abstraction, not concrete class private MessageSender sender;
public NotificationService(MessageSender sender) { this.sender = sender; }
public void notify(String recipient, String message) { sender.send(recipient, message); }}
// Easy to swap implementations:// new NotificationService(new EmailSender())// new NotificationService(new SMSSender())// GOOD: Both high-level and low-level depend on abstractions#include <iostream>#include <string>#include <memory>using namespace std;
class MessageSender {public: virtual void send(const string& recipient, const string& message) = 0; virtual ~MessageSender() = default;};
class EmailSender : public MessageSender {public: void send(const string& recipient, const string& message) override { cout << "Email to " << recipient << ": " << message << endl; }};
class SMSSender : public MessageSender {public: void send(const string& recipient, const string& message) override { cout << "SMS to " << recipient << ": " << message << endl; }};
class PushNotificationSender : public MessageSender {public: void send(const string& recipient, const string& message) override { cout << "Push to " << recipient << ": " << message << endl; }};
class NotificationService { // Depends on abstraction, not concrete class unique_ptr<MessageSender> sender;
public: NotificationService(unique_ptr<MessageSender> sender) : sender(move(sender)) {}
void notify(const string& recipient, const string& message) { sender->send(recipient, message); }};
// Easy to swap implementations:// auto svc = NotificationService(make_unique<EmailSender>());// svc.notify("alice@example.com", "Welcome!");SOLID Principles Summary
| Principle | Acronym | Key Question | Violation Smell |
|---|---|---|---|
| Single Responsibility | SRP | Does this class have more than one reason to change? | God classes, classes with “and” in their description |
| Open/Closed | OCP | Can I add new behavior without modifying existing code? | Long if/else or switch chains for types |
| Liskov Substitution | LSP | Can I use a subclass anywhere the parent is expected? | Overridden methods that throw NotImplementedError or break contracts |
| Interface Segregation | ISP | Is every method in this interface used by every implementor? | Empty method stubs, “fat” interfaces |
| Dependency Inversion | DIP | Does my high-level logic depend on concrete low-level details? | new inside business logic, hard-coded dependencies |
SOLID in Practice: Real-World Scenarios
Scenario 1: E-Commerce Order Processing
An order processing system must calculate totals, apply discounts, charge payment, and send confirmations. Without SOLID, all this logic lives in one giant OrderProcessor class.
Applying SOLID:
- SRP — Separate into
OrderCalculator,DiscountEngine,PaymentGateway,OrderNotifier - OCP — New payment methods (PayPal, crypto) are added as new
PaymentMethodimplementations - LSP — All
PaymentMethodsubclasses process payments without breaking the checkout flow - ISP —
PaymentMethodinterface only requirescharge()andrefund(), not unrelated methods likesendReceipt() - DIP —
OrderProcessordepends onPaymentMethodinterface, notStripePaymentdirectly
Scenario 2: Logging Framework
A logging framework must support multiple outputs (console, file, remote server) and formats (plain text, JSON, XML).
Applying SOLID:
- SRP —
Loggerorchestrates;Formatterhandles formatting;Transporthandles output - OCP — New formats or transports are added as new classes without modifying the
Logger - LSP — Any
Transport(console, file, HTTP) can be used interchangeably - ISP —
Transportonly needswrite(entry), not formatting methods - DIP —
Loggerdepends onFormatterandTransportabstractions, injected at creation
Scenario 3: Authentication System
An authentication system must support multiple strategies (password, OAuth, SSO, biometrics).
Applying SOLID:
- SRP —
AuthControllerhandles routing;AuthStrategyhandles verification;SessionManagerhandles sessions - OCP — New auth strategies (e.g., WebAuthn) are added by implementing
AuthStrategy - LSP — All strategies return consistent
AuthResultobjects - ISP —
AuthStrategyonly requiresauthenticate(credentials), not session management - DIP — The controller depends on the
AuthStrategyinterface, not any specific provider
Common Violations and How to Fix Them
1. The God Class (Violates SRP)
Symptom: A single class with hundreds or thousands of lines that handles UI, business logic, data access, and more.
Fix: Identify distinct responsibilities and extract each into its own class. Use the “describe in one sentence” test — if you need “and” or “or”, the class has too many responsibilities.
2. The Switch/If-Else Chain (Violates OCP)
Symptom: A function with a growing switch or if/else block that checks types or categories.
Fix: Replace the conditional with polymorphism. Define an interface for the varying behavior and create one implementation per case.
3. The Broken Override (Violates LSP)
Symptom: A subclass overrides a parent method to throw an exception, return null, or do nothing.
Fix: Reconsider the inheritance hierarchy. If the subclass cannot honor the parent’s contract, it likely should not extend that parent. Use composition or a different interface instead.
4. The Kitchen-Sink Interface (Violates ISP)
Symptom: An interface with 10+ methods, and most implementing classes leave several of them empty or throw UnsupportedOperationException.
Fix: Split the interface into several smaller, cohesive interfaces. Group methods by the clients that use them.
5. The Hard-Coded Dependency (Violates DIP)
Symptom: Business logic classes instantiate their own dependencies using new or direct constructors, making testing and swapping impossible.
Fix: Accept dependencies through constructors (constructor injection) or setters. Depend on interfaces, not concrete classes. Use a dependency injection container in larger applications.
Key Takeaways
-
SOLID is about managing change. Every principle addresses a different way that changes in requirements can cascade through your codebase. Following them limits the blast radius of any single change.
-
Start with SRP. If each class has a single, well-defined purpose, the other principles become easier to apply naturally.
-
OCP and DIP work together. Abstractions (DIP) are the mechanism that enables extension without modification (OCP). You cannot follow OCP well without DIP.
-
LSP is about contracts, not types. A subclass that technically compiles but behaves unexpectedly is more dangerous than a compile error. Think about behavioral compatibility, not just structural compatibility.
-
ISP prevents “pollution.” Fat interfaces force unnecessary coupling. Small, focused interfaces keep classes lean and reduce the impact of changes.
-
Apply SOLID pragmatically. Over-engineering a simple script with five layers of abstraction is just as harmful as ignoring the principles entirely. Use your judgment — apply SOLID where the code will benefit from flexibility and maintainability.
-
SOLID principles are complementary. They reinforce each other. A codebase that follows all five tends to be modular, testable, and resilient to changing requirements.
Practice Exercises
Exercise 1: Refactor a Report Generator
You have a ReportGenerator class that fetches data from a database, formats it as HTML, and emails it to the manager. Refactor it to follow SRP and DIP.
Hints
- Identify the three responsibilities: data retrieval, formatting, and delivery.
- Create a
DataFetcherinterface for the data source (could be a database, API, or file). - Create a
ReportFormatterinterface with implementations likeHTMLFormatter,PDFFormatter, andCSVFormatter. - Create a
ReportDeliveryinterface with implementations likeEmailDeliveryandFileDelivery. - The
ReportGeneratorshould accept all three as constructor dependencies.
Exercise 2: Fix an LSP Violation
Given the following class hierarchy, identify the LSP violation and fix it:
class Bird: def fly(self): return "Flying high!"
def make_sound(self): return "Tweet!"
class Penguin(Bird): def fly(self): raise Exception("Penguins can't fly!") # LSP violation!
def make_sound(self): return "Squawk!"Hints
- Not all birds can fly, so
fly()should not be part of the baseBirdclass. - Create a
Birdbase class withmake_sound(). - Create a
FlyingBirdsubclass (or aFlyableinterface) that addsfly(). PenguinextendsBirdbut notFlyingBird.EagleextendsFlyingBird(or implements bothBirdandFlyable).
Exercise 3: Apply OCP to a Shape Area Calculator
You have a function that calculates the total area of shapes using type-checking:
def total_area(shapes): total = 0 for shape in shapes: if isinstance(shape, dict) and shape["type"] == "circle": total += 3.14159 * shape["radius"] ** 2 elif isinstance(shape, dict) and shape["type"] == "rectangle": total += shape["width"] * shape["height"] # Must add more elif for each new shape... return totalRefactor this to follow OCP so that new shapes can be added without modifying the total_area function.
Hints
- Define an abstract
Shapeclass with anarea()method. - Create
Circle,Rectangle,Triangle, etc. as concrete implementations. - The
total_areafunction simply callsshape.area()for each item — it never needs to know the specific type. - Test by adding a
Triangleclass without changingtotal_area.