SOLID Design Principles | Part 2
This chapter covers the remaining SOLID design principles: the Liskov Substitution Principle (LSP) guidelines, the Interface Segregation Principle (ISP), and the Dependency Inversion Principle (DIP). It explores the subtle ways these principles are violated in production environments, provides concrete C++ code refactoring patterns, and establishes practical mental models for balancing clean architecture with real-world business trade-offs.
1. Deep Dive into Liskov Substitution Principle (LSP) Guidelines
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. While conceptually straightforward, LSP is one of the most frequently violated design principles in software engineering. Developers often struggle to identify subtle behavioral mismatches between parent and child classes that compile perfectly but fail at runtime.
To ensure a hierarchy strictly adheres to LSP, we categorize its design guidelines into three distinct rule sets:
2. LSP Signature Rules
Signature rules govern the syntactic contract of overridden methods. They ensure that the compiler and runtime environment can seamlessly swap subclass instances for base class instances without type mismatches or unhandled runtime crashes.
The Concept of "Broad" vs. "Narrow" Types
To understand signature rules, we must define type relationships:
- Broad Type (Contravariant/Ancestor): A class higher up in the inheritance hierarchy (e.g.,
Animalis broader thanDog). - Narrow Type (Covariant/Descendant): A class lower down in the inheritance hierarchy (e.g.,
Dogis narrower thanAnimal).
A. Method Argument Rule
The method argument rule states that when overriding a method, the subclass method arguments must be the same or broader than the parent class method arguments.
In strongly-typed languages like C++ and Java, the compiler strictly enforces identical signatures for overriding. If you change the argument type in a subclass, the compiler treats it as method overloading rather than overriding, protecting you from breaking this rule at compile time.
B. Return Type Rule (Covariance)
The return type rule states that an overridden method in a subclass must return the same or a narrower (more specific) type than the method in the parent class. This is known as covariance.
Real-Life Analogy
If a client requests a general Animal from a provider, and the provider hands them a Dog, the client is satisfied because a Dog is guaranteed to have all the properties of an Animal. However, if the client requests an Animal and the provider returns a generic Organism (broader), the client's code will break because an Organism might not have animal-specific behaviors like makeSound().
C++ Covariant Return Type Implementation
#include <iostream>
#include <memory>
// Return Type Hierarchy
class Animal {
public:
virtual void makeSound() const { std::cout << "Generic animal sound\n"; }
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void makeSound() const override { std::cout << "Woof! Woof!\n"; }
void fetch() const { std::cout << "Fetching the ball...\n"; }
};
// Service Hierarchy demonstrating Covariant Return Types
class AnimalShelter {
public:
virtual Animal* adopt() {
std::cout << "Adopting a generic animal\n";
return new Animal();
}
virtual ~AnimalShelter() = default;
};
class DogShelter : public AnimalShelter {
public:
// Covariant Return Type: Dog* is narrower than Animal*
Dog* adopt() override {
std::cout << "Adopting a dog\n";
return new Dog();
}
};
// Client Code
void processAdoption(AnimalShelter& shelter) {
// Client expects an Animal*
Animal* pet = shelter.adopt();
pet->makeSound();
delete pet;
}
int main() {
DogShelter dogShelter;
processAdoption(dogShelter); // Works seamlessly without breaking the client
return 0;
}
C. Exception Rule
The exception rule states that an overridden method in a subclass must throw the same or narrower (more specific) exceptions than the parent class method. It must never throw broader or completely unrelated exceptions.
Why This Matters
If a client calls a method expecting a LogicalError and has a catch(const LogicalError& e) block, but a subclass overrides the method and throws a RunTimeError, the exception will bypass the catch block. This leads to an unhandled exception crash at runtime.
C++ Exception Rule Demonstration
#include <iostream>
#include <stdexcept>
// Exception Hierarchy: std::out_of_range is a subclass of std::logic_error
class Parent {
public:
virtual int getValue(int index) noexcept(false) {
if (index < 0) {
throw std::logic_error("Parent: Logic Error");
}
return index * 10;
}
virtual ~Parent() = default;
};
class CompliantChild : public Parent {
public:
// Compliant: std::out_of_range is narrower than std::logic_error
int getValue(int index) noexcept(false) override {
if (index < 0) {
throw std::out_of_range("Child: Out of Range Error");
}
return index * 10;
}
};
class NonCompliantChild : public Parent {
public:
// Non-Compliant: std::runtime_error is NOT a subclass of std::logic_error
int getValue(int index) noexcept(false) override {
if (index < 0) {
throw std::runtime_error("Child: Runtime Error occurred!");
}
return index * 10;
}
};
void clientInterface(Parent& p) {
try {
p.getValue(-1);
} catch (const std::logic_error& e) {
std::cout << "Successfully caught expected logic error: " << e.what() << "\n";
}
}
int main() {
CompliantChild goodChild;
clientInterface(goodChild); // Safe: out_of_range is caught by logic_error catch block
NonCompliantChild badChild;
// clientInterface(badChild); // CRITICAL: Will crash the program because runtime_error is uncaught
return 0;
}
3. LSP Property Rules
Property rules govern the state, invariants, and behavioral expectations of classes. They ensure that subclasses do not violate the logical assumptions established by the parent class.
A. Class Invariant Rule
An invariant is a condition or rule that must always remain true for a class throughout its entire lifecycle (e.g., a bank account balance must never be negative). The class invariant rule states that any invariant established by a parent class must be preserved or strengthened by its subclasses.
Real-Life Analogy: The Cheat Account
Imagine a standard BankAccount class with an invariant: balance >= 0. A subclass called CheatAccount overrides the withdrawal mechanism to allow the balance to drop to -1000. This violates the class invariant. Any client code relying on the assumption that balances are non-negative will fail or behave unpredictably when interacting with a CheatAccount.
#include <iostream>
#include <stdexcept>
class BankAccount {
protected:
double balance;
// Invariant: balance >= 0
public:
BankAccount(double initialBalance) {
if (initialBalance < 0) {
throw std::invalid_argument("Balance cannot be negative");
}
balance = initialBalance;
}
virtual void withdraw(double amount) {
if (balance - amount < 0) {
throw std::runtime_error("Insufficient funds");
}
balance -= amount;
std::cout << "Withdrew " << amount << ". Balance: " << balance << "\n";
}
double getBalance() const { return balance; }
virtual ~BankAccount() = default;
};
// Non-Compliant Subclass violating Class Invariant
class CheatAccount : public BankAccount {
public:
CheatAccount(double initialBalance) : BankAccount(initialBalance) {}
void withdraw(double amount) override {
// Violates parent invariant: balance can now become negative
balance -= amount;
std::cout << "[Cheat] Withdrew " << amount << ". Balance: " << balance << "\n";
}
};
B. History Constraint Rule
The history constraint rule states that subclasses must not modify state in a way that violates the parent class's state transition rules or immutability guarantees.
If a parent class specifies that a state variable is immutable after initialization, or that a certain operation must always be allowed, the child class must respect that history.
Real-Life Analogy: Fixed Deposit Account
A standard BankAccount allows withdrawals at any time. A FixedDepositAccount inherits from BankAccount but overrides withdraw() to throw an exception because funds are locked.
This violates the history constraint: the parent class established a behavioral history where withdrawals are always permitted. Forcing a client to handle unexpected "locked" states breaks substitution.
// Non-Compliant Subclass violating History Constraint
class FixedDepositAccount : public BankAccount {
public:
FixedDepositAccount(double initialBalance) : BankAccount(initialBalance) {}
// Violates History Constraint: Withdrawals should always be allowed on a BankAccount
void withdraw(double amount) override {
throw std::runtime_error("Withdrawal not allowed on Fixed Deposit Accounts!");
}
};
Note on Immutability: If a parent class declares a property as immutable (e.g., using
finalor omitting setters), a subclass must not introduce custom methods that mutate this property. Doing so breaks the client's assumption of state stability.
4. LSP Method Rules
Method rules govern the execution contracts (pre-conditions and post-conditions) of class methods.
A. Pre-condition Rule
A pre-condition is a condition that must be met before a method can execute safely. The pre-condition rule states that a subclass can weaken (make less restrictive) pre-conditions, but it can never strengthen (make more restrictive) them.
Real-Life Analogy: Password Length
- Parent Class (
User): Requires a password of length characters (Pre-condition:len >= 8). - Compliant Child (
AdminUser): Weakens the pre-condition to require a password of length characters (Pre-condition:len >= 6). This is safe. If a client passes an 8-character password, both the parent and child accept it. - Non-Compliant Child: Strengthens the pre-condition to require a password of length characters (Pre-condition:
len >= 12). This breaks LSP. A client passing an 8-character password (which is perfectly valid according to theUsercontract) will crash when interacting with this subclass.
#include <iostream>
#include <string>
#include <stdexcept>
class User {
protected:
std::string password;
public:
virtual void setPassword(const std::string& pw) {
// Pre-condition: Password length must be >= 8
if (pw.length() < 8) {
throw std::invalid_argument("Password too short!");
}
password = pw;
}
virtual ~User() = default;
};
class AdminUser : public User {
public:
// Compliant: Weakening the pre-condition (allowing length >= 6)
void setPassword(const std::string& pw) override {
if (pw.length() < 6) {
throw std::invalid_argument("Admin password too short!");
}
password = pw;
}
};
B. Post-condition Rule
A post-condition is a condition that is guaranteed to be true after a method completes execution. The post-condition rule states that a subclass can strengthen (guarantee more) post-conditions, but it can never weaken (guarantee less) them.
Real-Life Analogy: Applying Car Brakes
- Parent Class (
Car): Guarantees that callingapplyBrakes()reduces the speed of the vehicle (Post-condition:speed_after < speed_before). - Compliant Child (
HybridCar): Strengthens the post-condition. It guarantees that the speed reduces and the battery charges via regenerative braking (Post-condition:speed_after < speed_beforeANDcharge_after > charge_before). The client's primary expectation (slowing down) is fully met. - Non-Compliant Child: Weakens the post-condition. It reduces speed only under certain conditions, or does not guarantee a speed reduction at all. This is highly dangerous and violates the contract.
#include <iostream>
class Car {
protected:
double speed = 100.0;
public:
virtual void applyBrakes() {
speed -= 20.0; // Post-condition: Speed is reduced
std::cout << "Brakes applied. Speed: " << speed << " km/h\n";
}
virtual ~Car() = default;
};
class HybridCar : public Car {
private:
double batteryCharge = 50.0;
public:
// Compliant: Strengthening the post-condition
void applyBrakes() override {
speed -= 20.0; // Guarantees parent post-condition
batteryCharge += 10.0; // Adds additional guarantee (strengthened)
std::cout << "Hybrid Brakes applied. Speed: " << speed
<< " km/h, Battery: " << batteryCharge << "%\n";
}
};
5. Summary of LSP Rules
6. Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) states: "Many client-specific interfaces are better than one general-purpose interface."
No client should be forced to depend on methods it does not use. When interfaces become bloated, implementing classes are forced to write empty method bodies or throw "Not Implemented" exceptions, violating both ISP and LSP.
The Problem: General-Purpose "Fat" Interfaces
Consider a system modeling geometric shapes. We design a single, general-purpose Shape interface to handle all operations:
[Shape Interface]
(Area() & Volume() methods)
/ | \
/ | \
v v v
[Square] [Rectangle] [Cube]
(Throws on (Throws on (Implements
Volume) Volume) Both)
Because Square and Rectangle are 2D shapes, they have no concept of volume. However, because they implement the fat Shape interface, they are forced to implement getVolume() and throw runtime errors.
Non-Compliant Implementation (ISP Violation)
#include <iostream>
#include <stdexcept>
// Fat Interface violating ISP
class Shape {
public:
virtual double getArea() = 0;
virtual double getVolume() = 0; // Violates ISP for 2D shapes
virtual ~Shape() = default;
};
class Square : public Shape {
private:
double side;
public:
Square(double s) : side(s) {}
double getArea() override { return side * side; }
// Forced to implement a method that makes no sense
double getVolume() override {
throw std::logic_error("Volume not applicable to 2D Square!");
}
};
class Cube : public Shape {
private:
double side;
public:
Cube(double s) : side(s) {}
double getArea() override { return 6 * side * side; }
double getVolume() override { return side * side * side; }
};
The Solution: Segregated, Client-Specific Interfaces
To resolve this, we split the fat interface into smaller, highly focused interfaces: TwoDimensionalShape and ThreeDimensionalShape.
[TwoDimensionalShape] [ThreeDimensionalShape]
(Area() method) (Area() & Volume() methods)
/ \ |
/ \ v
[Square] [Rectangle] [Cube]
Compliant Implementation (ISP Adherent)
#include <iostream>
// Segregated Interfaces
class TwoDimensionalShape {
public:
virtual double getArea() = 0;
virtual ~TwoDimensionalShape() = default;
};
class ThreeDimensionalShape {
public:
virtual double getArea() = 0;
virtual double getVolume() = 0;
virtual ~ThreeDimensionalShape() = default;
};
// Square only implements what it needs
class Square : public TwoDimensionalShape {
private:
double side;
public:
Square(double s) : side(s) {}
double getArea() override { return side * side; }
};
// Cube implements 3D operations
class Cube : public ThreeDimensionalShape {
private:
double side;
public:
Cube(double s) : side(s) {}
double getArea() override { return 6 * side * side; }
double getVolume() override { return side * side * side; }
};
int main() {
Square sq(5.0);
Cube cb(3.0);
std::cout << "Square Area: " << sq.getArea() << "\n";
std::cout << "Cube Volume: " << cb.getVolume() << "\n";
return 0;
}
7. Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) states:
- 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.
High-Level vs. Low-Level Modules
- High-Level Modules: Contain the core business logic and use cases of the application (e.g.,
UserService,OrderProcessor,BillingEngine). - Low-Level Modules: Handle infrastructure, system operations, and external integrations (e.g.,
MySqlDatabase,FileLogger,SmtpEmailSender).
The Problem: Tight Coupling to Concrete Details
If a high-level UserService directly instantiates and calls a concrete MySqlDatabase class, the system becomes tightly coupled.
If you decide to migrate from MySQL to MongoDB, you must modify the internal code of UserService. This violates the Open-Closed Principle (OCP) and makes unit testing difficult because you cannot easily mock the database layer.
Non-Compliant Implementation (DIP Violation)
#include <iostream>
#include <string>
// Low-Level Module
class MySqlDatabase {
public:
void saveUser(const std::string& username) {
std::cout << "Saving " << username << " to MySQL Database...\n";
}
};
// Low-Level Module
class MongoDatabase {
public:
void saveUser(const std::string& username) {
std::cout << "Saving " << username << " to MongoDB...\n";
}
};
// High-Level Module directly dependent on Low-Level details
class UserService {
private:
MySqlDatabase mysqlDb; // Tight coupling
MongoDatabase mongoDb; // Tight coupling
public:
void registerUser(const std::string& username, const std::string& dbType) {
if (dbType == "MySQL") {
mysqlDb.saveUser(username);
} else if (dbType == "Mongo") {
mongoDb.saveUser(username);
}
}
};
The Solution: Introducing an Abstraction Layer
We introduce an abstract interface, Database. The high-level UserService depends solely on this interface. The concrete database implementations (MySqlDatabase, MongoDatabase) implement this interface, inverting the dependency.
Compliant Implementation (DIP Adherent)
#include <iostream>
#include <string>
#include <memory>
// Abstraction Layer
class Database {
public:
virtual void save(const std::string& data) = 0;
virtual ~Database() = default;
};
// Low-Level Module implementing Abstraction
class MySqlDatabase : public Database {
public:
void save(const std::string& data) override {
std::cout << "Saving \"" << data << "\" to MySQL Database via SQL Query.\n";
}
};
// Low-Level Module implementing Abstraction
class MongoDatabase : public Database {
public:
void save(const std::string& data) override {
std::cout << "Saving \"" << data << "\" to MongoDB via BSON Document.\n";
}
};
// High-Level Module depending ONLY on Abstraction
class UserService {
private:
std::shared_ptr<Database> db; // Dependency Injection via Abstraction
public:
// Constructor Injection
UserService(std::shared_ptr<Database> database) : db(database) {}
void registerUser(const std::string& username) {
// Business logic
std::cout << "Processing user registration for: " << username << "\n";
db->save(username); // Delegated to abstraction
}
};
int main() {
// We can dynamically swap databases without changing UserService code
auto mysql = std::make_shared<MySqlDatabase>();
UserService service1(mysql);
service1.registerUser("Alice");
std::cout << "--------------------------------------\n";
auto mongo = std::make_shared<MongoDatabase>();
UserService service2(mongo);
service2.registerUser("Bob");
return 0;
}
8. Real-World Analogy: The Corporate Hierarchy
To understand the Dependency Inversion Principle, consider a corporate organizational structure:
- High-Level (CEO): Focuses on high-level business strategy and execution.
- Low-Level (Developers/QAs): Focuses on concrete execution details (writing code, running tests).
- The Abstraction (Manager): Acts as the interface between the CEO and the developers.
The CEO does not interact directly with individual developers to assign daily tasks. Instead, the CEO communicates expectations to the Manager. The Manager then coordinates with the developers.
If a developer leaves and a new one joins (changing low-level details), the CEO's workflow remains completely unaffected. The CEO continues to interact with the same Manager interface.
9. Real-World Mapping & The Pragmatic Trade-offs of SOLID
Mapping Complex Real-World Objects to Code
In object-oriented programming, we often say we are "mapping the real world to code." However, real-world entities are incredibly complex. A human being can eat, sleep, sing, walk, write code, and drive a car.
If we modeled a Human class with all of these behaviors, we would severely violate the Single Responsibility Principle (SRP).
Instead, we map real-world objects based on the application context:
- In a Ride-Sharing App (Ola/Uber), the human is modeled as a
Riderwith methods likebookRide()andcancelRide(). - In a Food Delivery App (Swiggy/Zomato), the same human is modeled as a
Customerwith methods likeorderFood()andcancelOrder().
We only extract and model the specific behaviors relevant to the application's domain. This keeps our classes focused and compliant with SRP.
Principles vs. Laws: The Engineering Trade-off
It is important to remember that SOLID principles are principles, not absolute laws. They are guidelines designed to help you write maintainable code, but they are not dogmatic rules that must be followed at all costs.
In production environments, software engineering is a series of trade-offs:
- Over-Engineering: Splitting every class into tiny interfaces and applying DIP everywhere can introduce unnecessary layers of abstraction. This makes the codebase harder to navigate for simple applications.
- Pragmatic Violations: Sometimes, to meet a tight business deadline or optimize performance in a critical path, you may need to tolerate minor violations of SOLID.
- The Golden Rule: If Open-Closed Principle (OCP) is your target, Dependency Inversion Principle (DIP) is your solution. Use these principles to protect your core business logic from volatile external dependencies.
10. Summary and Key Takeaways
- LSP is Behavioral, Not Just Syntactic: Compiling without errors does not guarantee LSP compliance. Subclasses must honor the behavioral contracts, state invariants, and history constraints of their parent classes.
- Covariance vs. Contravariance: When overriding methods, subclass return types can be narrower (covariant), but method arguments must be the same or broader (contravariant).
- Exception Safety in LSP: Subclasses must only throw exceptions that are the same or narrower than those thrown by the parent class. This prevents unhandled runtime crashes in client try-catch blocks.
- Pre-conditions and Post-conditions: Subclasses can weaken pre-conditions (accept more inputs) and strengthen post-conditions (guarantee more outcomes), but they can never do the reverse.
- ISP Prevents "Fat" Interfaces: Avoid creating monolithic interfaces. Split them into smaller, client-specific interfaces so that implementing classes are not forced to write dummy methods or throw "Not Implemented" exceptions.
- DIP Decouples Core Logic: High-level business logic should never depend on low-level infrastructure details like databases or file systems. Both must depend on shared abstractions (interfaces).
- SOLID is a Guide, Not a Dogma: Always balance design patterns with pragmatic constraints. Code should be as clean as possible, but it must ultimately deliver business value and remain performant.