05-solid-principles.md

Module 05: SOLID Principles

Goal: Master the five design principles for writing clean, maintainable, extensible OOP code. Time: 2 days of focused study Prerequisites: Module 01-04


Table of Contents

  1. What Are SOLID Principles?
  2. S — Single Responsibility Principle
  3. O — Open/Closed Principle
  4. L — Liskov Substitution Principle
  5. I — Interface Segregation Principle
  6. D — Dependency Inversion Principle
  7. SOLID in Practice — Combined Example
  8. Common Violations & Code Smells
  9. Interview Questions

1. What Are SOLID Principles?

SOLID is an acronym for five design principles that make object-oriented code maintainable, extensible, and testable.

S — Single Responsibility Principle A class should have only ONE reason to change. O — Open/Closed Principle Open for extension, closed for modification. L — Liskov Substitution Principle Subtypes must be substitutable for their base types. I — Interface Segregation Principle Don't force clients to depend on methods they don't use. D — Dependency Inversion Principle Depend on abstractions, not on concrete implementations.

2. Single Responsibility Principle

"A class should have only one reason to change." — Robert C. Martin

A class should do one thing and do it well. If a class has multiple responsibilities, changes in one responsibility can break the other.

❌ Violation

// This class has THREE responsibilities! class Employee { string name; double salary; public: // Responsibility 1: Employee data management string getName() const { return name; } void setSalary(double s) { salary = s; } // Responsibility 2: Pay calculation (business logic) double calculatePay() { double tax = salary * 0.3; double insurance = 200; return salary - tax - insurance; } // Responsibility 3: Database operations (persistence) void saveToDatabase() { // SQL query to save employee cout << "INSERT INTO employees ..." << endl; } // Responsibility 4: Formatting (presentation) string generateReport() { return "Employee: " + name + "\nSalary: $" + to_string(salary); } }; // If tax rules change → modify this class // If database schema changes → modify this class // If report format changes → modify this class // THREE reasons to change = SRP violation!

✅ Fixed

// Each class has ONE responsibility class Employee { string name; double salary; public: Employee(string n, double s) : name(n), salary(s) {} string getName() const { return name; } double getSalary() const { return salary; } }; class PayCalculator { public: double calculateNetPay(const Employee& emp) { double tax = emp.getSalary() * 0.3; double insurance = 200; return emp.getSalary() - tax - insurance; } }; class EmployeeRepository { public: void save(const Employee& emp) { cout << "Saving " << emp.getName() << " to database" << endl; } Employee findById(int id) { /* ... */ } }; class EmployeeReportGenerator { public: string generate(const Employee& emp) { return "Employee: " + emp.getName() + "\nSalary: $" + to_string(emp.getSalary()); } }; // Now: // Tax rules change → only modify PayCalculator // Database changes → only modify EmployeeRepository // Report format changes → only modify EmployeeReportGenerator // Each class has ONE reason to change!

How to Identify SRP Violations

Ask: "What does this class do?" If the answer uses "AND", it violates SRP: "This class manages users AND sends emails AND logs events" "This class manages user data" "This class sends emails" "This class logs events"

3. Open/Closed Principle

"Software entities should be open for extension, but closed for modification."

You should be able to add new behavior without modifying existing code. Use polymorphism and abstractions.

❌ Violation

class NotificationService { public: void send(const string& type, const string& message) { if (type == "email") { cout << "Sending email: " << message << endl; // SMTP logic... } else if (type == "sms") { cout << "Sending SMS: " << message << endl; // SMS API logic... } else if (type == "push") { cout << "Sending push notification: " << message << endl; // Push notification logic... } // Every time we add a new notification type, // we MODIFY this existing class! // What if we need Slack? WhatsApp? Telegram? // This function grows forever! } };

✅ Fixed

// Abstract interface — CLOSED for modification class INotificationChannel { public: virtual void send(const string& message) = 0; virtual string channelName() const = 0; virtual ~INotificationChannel() = default; }; // Concrete implementations — OPEN for extension class EmailNotification : public INotificationChannel { string recipient; public: EmailNotification(string to) : recipient(to) {} void send(const string& message) override { cout << "Email to " << recipient << ": " << message << endl; } string channelName() const override { return "Email"; } }; class SMSNotification : public INotificationChannel { string phone; public: SMSNotification(string ph) : phone(ph) {} void send(const string& message) override { cout << "SMS to " << phone << ": " << message << endl; } string channelName() const override { return "SMS"; } }; // Adding Slack? Just create a new class — NO existing code modified! class SlackNotification : public INotificationChannel { string channel; public: SlackNotification(string ch) : channel(ch) {} void send(const string& message) override { cout << "Slack #" << channel << ": " << message << endl; } string channelName() const override { return "Slack"; } }; // Notification service works with ANY channel — now and in the future class NotificationService { vector<unique_ptr<INotificationChannel>> channels; public: void addChannel(unique_ptr<INotificationChannel> channel) { channels.push_back(move(channel)); } void notifyAll(const string& message) { for (auto& channel : channels) { channel->send(message); } } }; int main() { NotificationService service; service.addChannel(make_unique<EmailNotification>("alice@test.com")); service.addChannel(make_unique<SMSNotification>("+1234567890")); service.addChannel(make_unique<SlackNotification>("general")); service.notifyAll("Server is down!"); // Sends via all channels // Adding WhatsApp? Just create WhatsAppNotification class. // NotificationService doesn't change at all! }

4. Liskov Substitution Principle

"If S is a subtype of T, then objects of type T can be replaced with objects of type S without altering the correctness of the program."

This is the trickiest SOLID principle. In plain English: a derived class should be able to replace its base class without breaking anything.

❌ Classic Violation: Rectangle-Square Problem

class Rectangle { protected: int width, 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 getWidth() const { return width; } int getHeight() const { return height; } int area() const { return width * height; } }; class Square : public Rectangle { public: Square(int side) : Rectangle(side, side) {} // A square must keep width == height void setWidth(int w) override { width = w; height = w; // Must change both! } void setHeight(int h) override { height = h; width = h; // Must change both! } }; // ❌ This function BREAKS with Square: void resize(Rectangle& r) { r.setWidth(5); r.setHeight(10); // For a Rectangle: area = 5 * 10 = 50 ← CORRECT // For a Square: area = 10 * 10 = 100 ← WRONG! // setWidth(5) set both to 5 // setHeight(10) set both to 10 // width is 10, not 5! assert(r.area() == 50); // FAILS for Square! } // Square CANNOT substitute Rectangle — LSP violated!

✅ Fix: Separate Hierarchies or Immutable Shapes

// Option 1: Don't make Square inherit from Rectangle class Shape { public: virtual double area() const = 0; virtual ~Shape() = default; }; class Rectangle : public Shape { int width, height; public: Rectangle(int w, int h) : width(w), height(h) {} double area() const override { return width * height; } }; class Square : public Shape { int side; public: Square(int s) : side(s) {} double area() const override { return side * side; } }; // Option 2: Make shapes immutable (no setWidth/setHeight) class ImmutableRectangle { const int width, height; public: ImmutableRectangle(int w, int h) : width(w), height(h) {} int area() const { return width * height; } // No setters — can't violate invariants! };

LSP Rules (Behavioral Subtyping)

A subtype must follow ALL of these: 1. PRECONDITIONS: Derived can only WEAKEN preconditions Base accepts positive int → Derived can accept any int (weaker) Base accepts positive int → Derived requires even positive int ← ❌ STRONGER! 2. POSTCONDITIONS: Derived can only STRENGTHEN postconditions Base returns non-null → Derived also returns non-null ← ✅ same or stronger Base returns non-null → Derived might return null ← ❌ WEAKER! 3. INVARIANTS: Derived must maintain all base invariants Base: balance >= 0 always Derived must also keep balance >= 0 4. EXCEPTION RULE: Derived should not throw new exception types that the base doesn't throw 5. HISTORY RULE: Derived should not modify state in ways the base doesn't allow

Another Violation: Bird That Can't Fly

class Bird { public: virtual void fly() { cout << "Flying!" << endl; } }; class Penguin : public Bird { public: void fly() override { throw runtime_error("Penguins can't fly!"); // ❌ LSP violation! } }; // Code expecting Bird::fly() to work will crash with Penguin! void makeBirdFly(Bird& b) { b.fly(); // Throws for Penguin — substitution broke the program! } // ✅ FIX: Separate flying and non-flying birds class Bird { public: virtual void eat() = 0; virtual ~Bird() = default; }; class FlyingBird : public Bird { public: virtual void fly() { cout << "Flying!" << endl; } }; class Sparrow : public FlyingBird { void eat() override { cout << "Eating seeds" << endl; } }; class Penguin : public Bird { // Not a FlyingBird! void eat() override { cout << "Eating fish" << endl; } void swim() { cout << "Swimming!" << endl; } };

5. Interface Segregation Principle

"No client should be forced to depend on methods it does not use."

Don't create fat interfaces. Split them into smaller, focused ones.

❌ Violation — Fat Interface

class IMachine { public: virtual void print(const string& doc) = 0; virtual void scan(const string& doc) = 0; virtual void fax(const string& doc) = 0; virtual void staple(const string& doc) = 0; virtual ~IMachine() = default; }; // All-in-one machine — implements everything, fine class MultiFunctionPrinter : public IMachine { public: void print(const string& doc) override { cout << "Printing: " << doc << endl; } void scan(const string& doc) override { cout << "Scanning: " << doc << endl; } void fax(const string& doc) override { cout << "Faxing: " << doc << endl; } void staple(const string& doc) override { cout << "Stapling: " << doc << endl; } }; // Simple printer — FORCED to implement methods it can't do! class SimplePrinter : public IMachine { public: void print(const string& doc) override { cout << "Printing: " << doc << endl; } void scan(const string& doc) override { throw runtime_error("Can't scan!"); // ❌ Forced to implement! } void fax(const string& doc) override { throw runtime_error("Can't fax!"); // ❌ Forced to implement! } void staple(const string& doc) override { throw runtime_error("Can't staple!"); // ❌ Forced to implement! } };

✅ Fixed — Segregated Interfaces

class IPrinter { public: virtual void print(const string& doc) = 0; virtual ~IPrinter() = default; }; class IScanner { public: virtual void scan(const string& doc) = 0; virtual ~IScanner() = default; }; class IFaxer { public: virtual void fax(const string& doc) = 0; virtual ~IFaxer() = default; }; // Simple printer — only implements what it can do class SimplePrinter : public IPrinter { public: void print(const string& doc) override { cout << "Printing: " << doc << endl; } }; // Multifunction — implements all relevant interfaces class MultiFunctionPrinter : public IPrinter, public IScanner, public IFaxer { public: void print(const string& doc) override { cout << "Printing: " << doc << endl; } void scan(const string& doc) override { cout << "Scanning: " << doc << endl; } void fax(const string& doc) override { cout << "Faxing: " << doc << endl; } }; // Functions depend ONLY on what they need: void printDocument(IPrinter& printer, const string& doc) { printer.print(doc); // Works with SimplePrinter AND MultiFunctionPrinter } void scanAndPrint(IPrinter& printer, IScanner& scanner, const string& doc) { scanner.scan(doc); printer.print(doc); }

6. Dependency Inversion Principle

"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 — High-level depends on low-level

// Low-level module class MySQLDatabase { public: void save(const string& data) { cout << "Saving to MySQL: " << data << endl; } string read(int id) { return "data from MySQL"; } }; // High-level module — DIRECTLY depends on MySQL! class UserService { MySQLDatabase db; // ← TIGHT COUPLING to MySQL! public: void createUser(const string& name) { db.save(name); } }; // Problems: // - Can't switch to PostgreSQL without modifying UserService // - Can't test UserService without a real MySQL database // - UserService and MySQLDatabase are tightly coupled

✅ Fixed — Both depend on abstraction

// ABSTRACTION (interface) — defined by the high-level module class IDatabase { public: virtual void save(const string& data) = 0; virtual string read(int id) = 0; virtual ~IDatabase() = default; }; // Low-level module — implements the abstraction class MySQLDatabase : public IDatabase { public: void save(const string& data) override { cout << "Saving to MySQL: " << data << endl; } string read(int id) override { return "MySQL data"; } }; class PostgreSQLDatabase : public IDatabase { public: void save(const string& data) override { cout << "Saving to PostgreSQL: " << data << endl; } string read(int id) override { return "PostgreSQL data"; } }; class InMemoryDatabase : public IDatabase { // For testing! public: map<int, string> store; void save(const string& data) override { store[store.size()] = data; } string read(int id) override { return store[id]; } }; // High-level module — depends on ABSTRACTION, not concrete class class UserService { IDatabase& db; // ← Depends on interface, not MySQL! public: // Dependency is INJECTED from outside UserService(IDatabase& database) : db(database) {} void createUser(const string& name) { db.save(name); // Works with ANY database! } }; int main() { // Production: MySQLDatabase mysql; UserService service(mysql); service.createUser("Alice"); // Switch to PostgreSQL? Just change the injected dependency: PostgreSQLDatabase postgres; UserService service2(postgres); service2.createUser("Bob"); // Testing? Use in-memory: InMemoryDatabase testDb; UserService testService(testDb); testService.createUser("TestUser"); }

Dependency Injection Patterns

// 1. CONSTRUCTOR INJECTION (recommended) class Service { IDatabase& db; public: Service(IDatabase& database) : db(database) {} }; // 2. SETTER INJECTION class Service { IDatabase* db = nullptr; public: void setDatabase(IDatabase* database) { db = database; } }; // 3. INTERFACE INJECTION (via method parameter) class Service { public: void process(IDatabase& db) { db.save("data"); } };

7. SOLID in Practice

Combined Example: Payment Processing System

// ---- ISP: Segregated interfaces ---- class IPaymentProcessor { public: virtual bool charge(double amount) = 0; virtual ~IPaymentProcessor() = default; }; class IRefundable { public: virtual bool refund(double amount) = 0; virtual ~IRefundable() = default; }; class IRecurringPayment { public: virtual bool subscribe(double amount, int intervalDays) = 0; virtual bool cancelSubscription() = 0; virtual ~IRecurringPayment() = default; }; // ---- OCP + LSP: Implementations that can substitute the interface ---- class StripePayment : public IPaymentProcessor, public IRefundable, public IRecurringPayment { public: bool charge(double amount) override { cout << "Stripe: Charged $" << amount << endl; return true; } bool refund(double amount) override { cout << "Stripe: Refunded $" << amount << endl; return true; } bool subscribe(double amount, int days) override { cout << "Stripe: Subscribed $" << amount << " every " << days << " days" << endl; return true; } bool cancelSubscription() override { return true; } }; class CashPayment : public IPaymentProcessor { // Only implements IPaymentProcessor — can't refund or subscribe public: bool charge(double amount) override { cout << "Cash: Received $" << amount << endl; return true; } }; // ---- SRP: Each class has one responsibility ---- class PaymentLogger { public: void log(const string& event) { cout << "[LOG] " << event << endl; } }; class ReceiptGenerator { public: string generate(double amount, const string& method) { return "Receipt: $" + to_string(amount) + " via " + method; } }; // ---- DIP: High-level depends on abstractions ---- class CheckoutService { IPaymentProcessor& processor; // Depends on abstraction! PaymentLogger& logger; ReceiptGenerator& receiptGen; public: CheckoutService(IPaymentProcessor& p, PaymentLogger& l, ReceiptGenerator& r) : processor(p), logger(l), receiptGen(r) {} bool checkout(double amount) { logger.log("Starting checkout for $" + to_string(amount)); if (processor.charge(amount)) { string receipt = receiptGen.generate(amount, "payment"); logger.log("Success: " + receipt); return true; } logger.log("Payment failed!"); return false; } }; int main() { StripePayment stripe; CashPayment cash; PaymentLogger logger; ReceiptGenerator receipts; // Works with Stripe CheckoutService stripeCheckout(stripe, logger, receipts); stripeCheckout.checkout(99.99); // Works with Cash — no code change! CheckoutService cashCheckout(cash, logger, receipts); cashCheckout.checkout(50.00); }

8. Common Violations

GOD CLASS (SRP violation): A class that does everything — manages users, sends emails, generates reports, connects to database. Fix: Split into focused classes. SWITCH/IF-ELSE on type (OCP violation): if (type == "email") { ... } else if (type == "sms") { ... } else if (type == "slack") { ... } Fix: Use polymorphism — each type is a class. SQUARE inherits RECTANGLE (LSP violation): A subtype changes behavior in a way that breaks callers. Fix: Use separate types or make immutable. FAT INTERFACE (ISP violation): Interface with 20 methods — most implementors only need 3. Fix: Split into smaller, role-specific interfaces. CONCRETE DEPENDENCY (DIP violation): class Service { MySQLDatabase db; } Fix: class Service { IDatabase& db; } with constructor injection. HOW TO DETECT: - Class has too many responsibilities? → SRP - Adding a feature requires modifying existing classes? → OCP - Subclass throws UnsupportedOperationException? → LSP - Class forced to implement methods it doesn't need? → ISP - Can't test a class without its real dependencies? → DIP

9. Interview Questions

Q1: What is the Single Responsibility Principle?

Answer: A class should have only one reason to change — it should have one responsibility or one job. If a class handles user data AND sends emails AND generates reports, changes to email logic could accidentally break user data management. Split into UserManager, EmailService, and ReportGenerator. Benefits: easier to understand, test, and maintain.

Q2: What is the Open/Closed Principle? How do you achieve it?

Answer: Classes should be open for extension but closed for modification. You should be able to add new behavior without changing existing code. Achieve it through: polymorphism (abstract base classes/interfaces), strategy pattern, template method pattern. Example: a NotificationService that works with an INotificationChannel interface — add new channels (Slack, WhatsApp) without modifying the service.

Q3: Explain the Liskov Substitution Principle with an example.

Answer: Objects of a derived class should be substitutable for objects of the base class without breaking correctness. The classic violation is Square extending Rectangle: calling setWidth() on a Square changes both width and height, breaking code that expects them to be independent. Fix: either don't make Square inherit from Rectangle, or make shapes immutable. Subtypes must honor base class contracts (preconditions, postconditions, invariants).

Q4: What is the Interface Segregation Principle?

Answer: Clients should not be forced to depend on interfaces they don't use. Instead of one large interface with many methods, create smaller, focused interfaces. Example: instead of IMachine with print/scan/fax/staple, create IPrinter, IScanner, IFaxer. A simple printer only implements IPrinter. This prevents classes from having empty or exception-throwing method stubs.

Q5: What is the Dependency Inversion Principle?

Answer: High-level modules should depend on abstractions, not low-level modules. Instead of class UserService { MySQLDatabase db; }, use class UserService { IDatabase& db; }. The database is injected through the constructor. Benefits: can swap implementations (MySQL → PostgreSQL), can use mock databases for testing, reduces coupling. The "inversion" is that the interface is defined by the consumer (high-level), not the provider (low-level).

Q6: What is Dependency Injection? How does it relate to DIP?

Answer: Dependency Injection is a technique for implementing DIP. Instead of a class creating its own dependencies internally, they're "injected" from outside — via constructor (preferred), setter, or method parameter. DIP is the principle (depend on abstractions); DI is the mechanism (pass abstractions in from outside). DI frameworks (like Spring in Java) automate this wiring.

Q7: How do SOLID principles relate to each other?

Answer: They're complementary: SRP keeps classes focused; OCP uses abstraction (from ISP) for extensibility; LSP ensures derived classes honor base contracts (required by OCP); ISP creates focused interfaces (supporting DIP); DIP ties everything together with abstractions. Following one naturally leads to following others. Violating one often means violating others.

Q8: What is the difference between OCP and DIP?

Answer: OCP focuses on extensibility — adding new behavior without modifying existing code. DIP focuses on decoupling — high-level modules don't depend on low-level details. They're related: DIP enables OCP. By depending on abstractions (DIP), you can add new implementations without changing the high-level code (OCP). DIP is about dependency direction; OCP is about change management.

Q9: Give a real-world example of LSP violation.

Answer: A ReadOnlyCollection inheriting from Collection that has add/remove methods. Calling add() on a ReadOnlyCollection throws an exception — it can't substitute for Collection. Fix: have both implement separate interfaces (IReadable and IWritable). Another: a FreeUser extending User where calling accessPremiumFeature() throws — the derived class can't fulfill the base class contract.

Q10: Why is the Rectangle-Square problem an LSP violation?

Answer: Mathematically, a square IS a rectangle. But in OOP, Square inheriting from Rectangle violates LSP because: Rectangle has independent width and height (you can set one without changing the other). Square's invariant (width == height) means setWidth() must also change height, breaking the Rectangle contract that setWidth() only changes width. Code that works with Rectangle assumes independent dimensions — substituting a Square breaks this assumption.

Q11: Can you over-apply SOLID?

Answer: Yes. Over-engineering with too many abstractions, interfaces, and classes makes code harder to understand. A simple script doesn't need DIP with injectable interfaces. Apply SOLID when complexity warrants it. Start simple, refactor toward SOLID when you see the problems (multiple reasons to change, hard to extend, can't test). Pragmatism over dogmatism.

Q12: How do you apply OCP without using inheritance?

Answer: (1) Strategy pattern — inject different algorithms. (2) Template/generic programming — compile-time polymorphism. (3) Function objects/lambdas — pass behavior as parameters. (4) Plugin architectures — load implementations dynamically. (5) Configuration-driven behavior. Inheritance is just one tool; composition and dependency injection are often better.


Next Module: 06 - Design Patterns — Creational, Structural, and Behavioral patterns for solving recurring design problems.