SOLID Principles

Learning Objectives

State each SOLID principle precisely and recognise its violations.

Refactor a poor design into one that follows each principle — and know when not to.

Distinguish the Dependency Inversion Principle from dependency injection.

Prerequisites: Modules 4–8.

Intuition and Definition

Smell when violated Single Responsibility A module should have one coherent *reason to change* (one stakeholder/axis of change)

A class changes for tax rules *and* for PDF layout Open for extension, closed for modification: add behaviour without editing stable, tested code

Every new payment method edits a big switch Liskov Substitution

Subtypes must be usable through the base type without surprising callers: no stronger preconditions, no weaker postconditions, invariants preserved

Square that breaks Rectangle::setWidth expectations Interface Segregation

Clients should not depend on methods they don't use IMachine { print(); scan(); fax(); } forced on a simple printer Dependency Inversion

High-level policy and low-level details both depend on abstractions owned by the policy

OrderService creates new MySqlDatabase() SRP does not mean "one method per class"; it means "things that change together live together, things that change for different reasons live apart."

DIP vs DI: Dependency *inversion* is about the direction of source-code dependencies (details depend on policy's abstractions). Dependency *injection* is a technique for supplying an object's dependencies from outside (constructor parameters). DI is the usual way to implement DIP, but you can inject concrete classes without inverting anything.

Why It Matters

SOLID principles reduce the blast radius of change. They are heuristics with costs — more types and indirection — so apply them where change is likely.

Syntax and Language Notes

All five principles are language-neutral. Interfaces (interface, abstract classes, protocols, structural types) are the main tool for O, I and D; inheritance discipline is the tool for L.

// ISP in TypeScript: small role interfaces, composed where needed
interface Printer { print(doc: string): void; }
interface Scanner { scan(): string; }
class OfficeMachine implements Printer, Scanner { print(d: string) {} scan() { return ""; } }
class SimplePrinter implements Printer { print(d: string) {} }   // no fake scan()/fax()

Complete Example

A notification service before and after applying SOLID. Before: one class formats, chooses the channel with a switch, and writes directly to the console.

After: formatting, transport and auditing are separate (S); new channels are new classes (O); every Channel honours the same contract (L); the auditor depends on a one-method interface (I); and the service depends on abstractions it owns, injected from outside (D).

A classic LSP violation: a Square that inherits from a mutable Rectangle.

#include <iostream>
#include <string>

class NotificationService {                    // violates S, O and D
public:
    void notify(const std::string& channel, const std::string& user, const std::string& event) {
        std::string text = "Hello " + user + ", " + event;          // formatting
        if (channel == "email") std::cout << "[smtp] " << text << "\n";   // transport
        else if (channel == "sms") std::cout << "[sms] " << text.substr(0, 20) << "\n";
        else std::cout << "[??] unsupported channel " << channel << "\n";
        std::cout << "[audit] notified " << user << "\n";           // auditing
    }
};

int main() {
    NotificationService s;
    s.notify("email", "Ana", "your car is ready");
    s.notify("push", "Raj", "service due");
}
[smtp] Hello Ana, your car is ready
[audit] notified Ana
[??] unsupported channel push
[audit] notified Raj
#include <iostream>
#include <map>
#include <memory>
#include <string>

struct Formatter {                                    // S: one reason to change (wording)
    std::string format(const std::string& user, const std::string& event) const { return "Hello " + user + ", " + event; }
};

class Channel {                                       // O + L: extend by adding implementations
public:
    virtual ~Channel() = default;
    virtual void send(const std::string& text) = 0;  // contract: delivers or throws, never silently drops
};
class EmailChannel : public Channel { public: void send(const std::string& t) override { std::cout << "[smtp] " << t << "\n"; } };
class SmsChannel : public Channel {
public:
    void send(const std::string& t) override { std::cout << "[sms] " << t.substr(0, 20) << "\n"; }
};
class PushChannel : public Channel { public: void send(const std::string& t) override { std::cout << "[push] " << t << "\n"; } };

class AuditLog {                                      // I: the service only needs record()
public:
    virtual ~AuditLog() = default;
    virtual void record(const std::string& entry) = 0;
};
class ConsoleAudit : public AuditLog { public: void record(c
…
[smtp] Hello Ana, your car is ready
[audit] notified Ana via email
[push] Hello Raj, service due
[audit] notified Raj via push
[audit] no channel 'pager' for Lee
#include <iostream>

class Rectangle {
protected:
    int w_ = 0, h_ = 0;
public:
    virtual ~Rectangle() = default;
    virtual void setWidth(int w) { w_ = w; }
    virtual void setHeight(int h) { h_ = h; }
    int area() const { return w_ * h_; }
};

class Square : public Rectangle {                 // "a square is a rectangle"… mathematically
public:
    void setWidth(int w) override { w_ = h_ = w; }
    void setHeight(int h) override { w_ = h_ = h; }
};

// client written against Rectangle's contract: setting width must not change height
void stretch(Rectangle& r) {
    r.setWidth(5);
    r.setHeight(4);
    std::cout << "expected area 20, got " << r.area() << "\n";
}

int main() {
    Rectangle r;
    Square s;
    stretch(r);
    stretch(s);       // substitution breaks the client's expectation
}
expected area 20, got 20
expected area 20, got 16

Step-by-Step Walkthrough

Before: adding "push" requires editing notify; the unsupported channel is only printed — and the audit line still claims "notified Raj" although nothing was sent, because auditing is tangled with sending; tests can't observe any of it; formatting, transport and audit changes all touch one class.

After: NotificationService knows only Channel, AuditLog and Formatter. A push channel is one new class registered at start-up; the service is untouched.

The AuditLog passed in from main is constructor injection — tests can pass a fake that records entries.

LSP: Square strengthens the postcondition of setWidth (it also changes the height). Code that is correct for every Rectangle breaks for Square. Fixes: make shapes immutable value types, or don't relate Square and Rectangle by inheritance at all (both implement a read-only Shape with area()).

classDiagram
  class NotificationService {
    +notify(channel, user, event) bool
  }
  class Channel {
    <<interface>>
    +send(text)
  }
  class AuditLog {
    <<interface>>
    +record(entry)
  }
  NotificationService --> Channel
  NotificationService --> AuditLog
  NotificationService *-- Formatter
  Channel <|.. EmailChannel
  Channel <|.. SmsChannel
  Channel <|.. PushChannel
  AuditLog <|.. ConsoleAudit

Common Mistakes

SRP as "one method per class" — leads to anaemic, scattered code.

OCP everywhere: interfaces for things that will never vary. A switch over three stable cases is fine.

LSP checked only by signatures — LSP is about *behaviour*: exceptions thrown, preconditions, postconditions, invariants.

ISP ignored for test fakes — big interfaces make every fake huge.

"We use a DI container, so we follow DIP" — not if the interfaces are owned by the low-level module and mirror its API.

Trade-offs and Alternatives

Small scripts, prototypes Abstractions + indirection Stable, closed sets of cases (use switch/variant)

Design discipline, sometimes no inheritance at all Never relax — violations are bugs Interfaces with a single client Interfaces + wiring

Leaf utilities, value types, standard library

Real-World Use

Payment gateways, notification services, storage back ends and vehicle-service schedulers commonly follow this shape: a policy core with small interfaces (PaymentProvider, Channel, Repository) implemented by adapters at the edges (Module 72).

Practice

Q1. (Easy) Which principle does "a class changes for tax rules and for PDF layout" violate?

Answer: Single Responsibility — two independent reasons to change.

Q2. (Medium) A ReadOnlyFile subclass of File throws in write(). Which principle is at risk, and what is a better design?

Answer: Liskov substitution (and interface segregation): callers of File::write get an unexpected exception. Split into Readable and Writable interfaces; ReadOnlyFile implements only Readable.

Q3. (Advanced) Write a test-style program that injects a fake AuditLog into the notification service and asserts the recorded entries.

#include <cassert>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Channel { virtual ~Channel() = default; virtual void send(const std::string&) = 0; };
struct AuditLog { virtual ~AuditLog() = default; virtual void record(const std::string&) = 0; };
struct NullChannel : Channel { void send(const std::string&) override {} };
struct FakeAudit : AuditLog {                       // test double that remembers calls
    std::vector<std::string> entries;
    void record(const std::string& e) override { entries.push_back(e); }
};
class Service {
    Channel& ch_; AuditLog& audit_;
public:
    Service(Channel& c, AuditLog& a) : ch_(c), audit_(a) {}
    void notify(const std::string& user) { ch_.send("hi " + user); audit_.record("notified " + user); }
};
int main() {
    NullChannel ch; FakeAudit audit;
    Service s(ch, audit);
    s.notify("Ana"); s.notify("Raj");
    assert(audit.entries.size() == 2 && audit.entries[1] == "notified Raj");
    std::cout << "test passed: " << audit.entries.size() << " audit entries\n";
}
test passed: 2 audit entries

Interview Question

Q1. "Give a real LSP violation you'd catch in code review, and fix it."

Answer: A PremiumAccount overrides withdraw and throws NotAllowedOnWeekends, while Account::withdraw documents only InsufficientFunds. Callers written against Account don't handle the new failure — the subtype strengthened the precondition. Fixes: make the restriction part of the base contract (withdraw may refuse with a reason via a result type), or model the rule as a policy object (WithdrawalPolicy) composed into the account instead of a subclass. The test: does every caller that is correct for the base remain correct for the subtype?

Quick Quiz

Q1. What does OCP protect?

Answer: Stable, tested code from modification when new behaviour is added.

Q2. Is dependency injection the same as dependency inversion?

Answer: No — injection is a technique for supplying dependencies; inversion is about which way source dependencies point (toward abstractions owned by the policy).

Q3. Which principle favours many small interfaces?

Answer: Interface Segregation.

Summary

SRP: one reason to change. OCP: extend without editing. LSP: behavioural substitutability. ISP: small client-specific interfaces. DIP: depend on abstractions owned by the policy.

Apply where change is likely; never violate LSP.

Related: Modules 20–22, Module 40 (Strategy), Module 72 (hexagonal architecture).