SOLID Design Principles | Complete Guide with Code Examples
An in-depth guide to understanding and implementing the SOLID design principles in Low-Level Design (LLD). This guide covers the core problems of messy architectures, explores the first three principles of SOLID (Single Responsibility, Open-Closed, and Liskov Substitution) with real-world analogies, analyzes naive design violations, and walks through step-by-step code evolutions in C++.
1. Introduction to Low-Level Design (LLD) & The Cost of Messy Code
In object-oriented programming, mastering the core pillars of OOP—Abstraction, Encapsulation, Inheritance, and Polymorphism—is only the first step. While these pillars teach us how to create classes, hide data, and establish relationships, they do not inherently guide us on how to structure a large-scale system.
A real-world production application does not consist of just 5 or 10 classes; it contains hundreds or thousands of classes interacting with one another. Without structured design guidelines, these classes quickly degrade into a tangled, unmanageable web.
Writing messy, unorganized code has severe real-world consequences:
- Disastrous Bugs: Tightly coupled classes mean a change in one class can silently break functionality in five other unrelated classes.
- Monetary and Technical Loss: In production systems, bugs do not just represent technical failures; they cause direct financial losses, system downtime, and degraded user trust.
- Architectural Decay: Over time, the codebase becomes so fragile that developers fear modifying it, drastically slowing down the release of new features.
To prevent this decay, we use Design Principles—proven guidelines that help us write clean, readable, maintainable, and decoupled code.
2. The Core Problems of Messy Architectures
When software systems are built without adhering to clean design principles, they suffer from three primary architectural issues:
A. Poor Maintainability
Maintainability is the ease with which a software system can be modified to correct faults, improve performance, or adapt to a changed environment. In a messy codebase, integrating a simple new feature requires modifying multiple existing classes, which frequently introduces regression bugs.
B. Low Readability
A high "readability index" means it takes an excessive amount of time and cognitive effort for a new engineer to understand how the code works. When classes are tightly coupled and handle multiple unrelated tasks, the code becomes self-documenting poison.
C. High Debugging Overhead
When code is tightly coupled, finding the root cause of a bug is like finding a needle in a haystack. Developers end up spending more time in debugging sessions than writing actual feature code.
Real-Life Analogy: The House Wiring Mess
Imagine a house where all the electrical wiring, internet cables, and water pipes are bundled together and routed through a single, narrow conduit in the wall.
If an internet cable breaks, identifying which wire is faulty is incredibly difficult. Even worse, trying to pull out the broken internet cable might accidentally tear an electrical wire or crack a water pipe.
Because everything is tightly coupled and crammed into one place, a simple repair becomes a high-risk, disastrous task.
Software architectures behave exactly the same way. If your classes are tightly coupled, fixing a bug in one module can break an entirely different module.
3. The SOLID Design Principles: An Overview
To solve these architectural problems, computer scientist Robert C. Martin (Uncle Bob) introduced a set of design guidelines in a paper published in 2000. These guidelines were later organized under the acronym SOLID.
Note: This guide focuses on the deep-dive implementation of the first three foundational principles: SRP, OCP, and LSP.
4. Single Responsibility Principle (SRP)
Core Definition
"A class should have only one reason to change." Or, simply put: "It should do only one thing."
This principle states that a class should be dedicated to a single, well-defined responsibility. Every attribute and method inside that class should align directly with that single responsibility.
Real-Life Analogy: The TV Remote
Consider a standard TV remote control. Its sole responsibility is to control the television. Every button on it (volume, channel, power) serves this single purpose.
Now, imagine a "universal" remote designed to control your TV, your refrigerator, your air conditioner, and your car garage door all from the same interface.
If the garage door communication protocol changes, you have to modify the remote's internal chip, which risks breaking the TV control functionality. The remote now has multiple reasons to change, violating the Single Responsibility Principle.
The Common Misconception
A common mistake is believing that SRP means a class should only contain one method. This is incorrect. A class can have multiple methods and helper functions, provided they all work together to fulfill one cohesive responsibility (e.g., a database helper class can have methods like connect(), query(), and disconnect(), as they all serve the single responsibility of database connectivity).
5. SRP Evolution: Naive vs. Decoupled Design
Let's look at an e-commerce shopping cart system to see how SRP is violated and subsequently resolved.
The Naive Approach (SRP Violation)
In this design, we have a Product class and a ShoppingCard class. The ShoppingCard class manages the products, but it also takes on the responsibilities of printing invoices and saving data to a database.
Why this violates SRP:
The ShoppingCard class has three distinct reasons to change:
- If we change how the total price is calculated (e.g., adding discounts).
- If we change the invoice format (e.g., switching from plain text to PDF).
- If we change our database technology (e.g., migrating from MySQL to MongoDB).
The SRP-Compliant Decoupled Approach
To adhere to SRP, we break the ShoppingCard class into three distinct, highly cohesive classes using composition.
Now, each class has exactly one reason to change:
ShoppingCardonly changes if cart calculation rules change.CartInvoicePrinteronly changes if the presentation format changes.ShoppingCardStorageonly changes if the storage mechanism changes.
6. SRP Code Implementation (C++ Evolution)
Naive Implementation (Violating SRP)
#include <iostream>
#include <vector>
#include <string>
class Product {
private:
std::string name;
double price;
public:
Product(std::string name, double price) : name(name), price(price) {}
std::string getName() const { return name; }
double getPrice() const { return price; }
};
// Violates SRP: Handles calculations, printing, and database persistence
class ShoppingCard {
private:
std::vector<Product> products;
public:
void addProduct(const Product& p) {
products.push_back(p);
}
const std::vector<Product>& getProducts() const {
return products;
}
// Responsibility 1: Business Logic
double calculateTotalPrice() const {
double total = 0;
for (const auto& product : products) {
total += product.getPrice();
}
return total;
}
// Responsibility 2: UI/Formatting (Violation)
void printInvoice() const {
std::cout << "--- INVOICE ---" << std::endl;
for (const auto& product : products) {
std::cout << product.getName() << ": $" << product.getPrice() << std::endl;
}
std::cout << "Total: $" << calculateTotalPrice() << std::endl;
}
// Responsibility 3: Persistence (Violation)
void saveToDatabase() const {
std::cout << "Saving shopping card with " << products.size() << " items to Database." << std::endl;
}
};
int main() {
Product laptop("Laptop", 1500.0);
Product mouse("Mouse", 50.0);
ShoppingCard cart;
cart.addProduct(laptop);
cart.addProduct(mouse);
cart.printInvoice();
cart.saveToDatabase();
return 0;
}
Clean, SRP-Compliant Implementation
#include <iostream>
#include <vector>
#include <string>
class Product {
private:
std::string name;
double price;
public:
Product(std::string name, double price) : name(name), price(price) {}
std::string getName() const { return name; }
double getPrice() const { return price; }
};
// Complies with SRP: Only manages cart items and calculations
class ShoppingCard {
private:
std::vector<Product> products;
public:
void addProduct(const Product& p) {
products.push_back(p);
}
const std::vector<Product>& getProducts() const {
return products;
}
double calculateTotalPrice() const {
double total = 0;
for (const auto& product : products) {
total += product.getPrice();
}
return total;
}
};
// Complies with SRP: Only handles invoice formatting and printing
class CartInvoicePrinter {
public:
void printInvoice(const ShoppingCard& cart) const {
std::cout << "--- SRP COMPLIANT INVOICE ---" << std::endl;
for (const auto& product : cart.getProducts()) {
std::cout << product.getName() << ": $" << product.getPrice() << std::endl;
}
std::cout << "Total: $" << cart.calculateTotalPrice() << std::endl;
}
};
// Complies with SRP: Only handles database persistence
class ShoppingCardStorage {
public:
void saveToDatabase(const ShoppingCard& cart) const {
std::cout << "Saving shopping card with " << cart.getProducts().size()
<< " items to Database." << std::endl;
}
};
int main() {
Product laptop("Laptop", 1500.0);
Product mouse("Mouse", 50.0);
ShoppingCard cart;
cart.addProduct(laptop);
cart.addProduct(mouse);
CartInvoicePrinter printer;
printer.printInvoice(cart);
ShoppingCardStorage storage;
storage.saveToDatabase(cart);
return 0;
}
7. Open-Closed Principle (OCP)
Core Definition
"Software entities (classes, modules, functions) should be open for extension, but closed for modification."
- Open for Extension: You should be able to add new features or behaviors to the system.
- Closed for Modification: You should be able to add these new features without modifying the existing, tested source code of the class.
The Architectural Goal
When a new requirement comes in, you should write new code (e.g., a new subclass or a new implementation of an interface) rather than rewriting old code that is already running stably in production.
How to Achieve OCP
We achieve OCP by introducing Abstractions (Interfaces or Abstract Classes), Inheritance, and Polymorphism.
Instead of a class depending directly on a concrete implementation, it depends on an interface. When a new feature is needed, we create a new class that implements that interface.
8. OCP Code Implementation (C++ Evolution)
Naive Implementation (Violating OCP)
Suppose we want to support saving our shopping cart to different storage mediums: SQL Database, MongoDB, and a local File.
#include <iostream>
#include <vector>
#include <string>
// Assume Product and ShoppingCard classes exist as defined in SRP
class Product {
std::string name;
double price;
public:
Product(std::string n, double p) : name(n), price(p) {}
std::string getName() const { return name; }
double getPrice() const { return price; }
};
class ShoppingCard {
std::vector<Product> products;
public:
void addProduct(const Product& p) { products.push_back(p); }
const std::vector<Product>& getProducts() const { return products; }
};
// Violates OCP: Every time a new storage medium is added, we must modify this class
class ShoppingCardStorage {
public:
void saveToSqlDatabase(const ShoppingCard& cart) const {
std::cout << "Saving to SQL Database." << std::endl;
}
// Added later (Violation: Modifying existing class)
void saveToMongoDatabase(const ShoppingCard& cart) const {
std::cout << "Saving to MongoDB Database." << std::endl;
}
// Added later (Violation: Modifying existing class)
void saveToFile(const ShoppingCard& cart) const {
std::cout << "Saving to Local File." << std::endl;
}
};
int main() {
ShoppingCard cart;
cart.addProduct(Product("Laptop", 1500.0));
ShoppingCardStorage storage;
storage.saveToSqlDatabase(cart);
storage.saveToMongoDatabase(cart);
storage.saveToFile(cart);
return 0;
}
Clean, OCP-Compliant Implementation
We introduce an abstract class Persistence that acts as an interface. Any new storage medium simply implements this interface.
#include <iostream>
#include <vector>
#include <string>
#include <memory>
class Product {
std::string name;
double price;
public:
Product(std::string n, double p) : name(n), price(p) {}
std::string getName() const { return name; }
double getPrice() const { return price; }
};
class ShoppingCard {
std::vector<Product> products;
public:
void addProduct(const Product& p) { products.push_back(p); }
const std::vector<Product>& getProducts() const { return products; }
};
// Abstract base class (Interface) - Closed for modification, Open for extension
class Persistence {
public:
virtual void save(const ShoppingCard& cart) const = 0;
virtual ~Persistence() = default;
};
// Extension 1: SQL Storage
class SqlPersistence : public Persistence {
public:
void save(const ShoppingCard& cart) const override {
std::cout << "Saving shopping cart to SQL Database." << std::endl;
}
};
// Extension 2: MongoDB Storage (Added without modifying existing classes)
class MongoPersistence : public Persistence {
public:
void save(const ShoppingCard& cart) const override {
std::cout << "Saving shopping cart to MongoDB Database." << std::endl;
}
};
// Extension 3: File Storage (Added without modifying existing classes)
class FilePersistence : public Persistence {
public:
void save(const ShoppingCard& cart) const override {
std::cout << "Saving shopping cart to Local File." << std::endl;
}
};
int main() {
ShoppingCard cart;
cart.addProduct(Product("Laptop", 1500.0));
// Polymorphic execution
std::vector<std::unique_ptr<Persistence>> storageOptions;
storageOptions.push_back(std::make_unique<SqlPersistence>());
storageOptions.push_back(std::make_unique<MongoPersistence>());
storageOptions.push_back(std::make_unique<FilePersistence>());
for (const auto& storage : storageOptions) {
storage->save(cart); // Executes correct save logic polymorphically
}
return 0;
}
9. Liskov Substitution Principle (LSP)
Core Definition
"Subclasses should be substitutable for their base classes."
If is a subtype of , then objects of type may be replaced with objects of type without altering any of the desirable properties of the program (correctness, task performed, etc.).
In simple terms: A subclass must preserve the behavior and contract of its parent class. It should not narrow down, break, or disable the parent's functionality.
The Banking Account Dilemma
Let's look at a classic violation of LSP in a banking application.
Both SavingsAccount and CurrentAccount support depositing and withdrawing money. They perfectly match the contract of Account.
Now, the bank introduces a Fixed Deposit (Fixed Term) Account. In a fixed deposit account, you can deposit money, but you are legally/contractually barred from withdrawing money until the maturity date.
If we naively inherit FixedTermAccount from Account, we run into a major issue:
Because FixedTermAccount cannot support the withdraw() operation, it overrides the method to throw a runtime exception. This directly violates LSP.
10. LSP Evolution: Resolving the Violation
Why Throwing an Exception in Subclass Overrides Breaks LSP
If a client class (e.g., BankClient) holds a list of Account pointers, it expects to be able to safely call withdraw() on all of them.
The client is forced to handle unexpected runtime errors because the subclass failed to fulfill the contract of the base class.
The Bad Fix (Violating OCP & LSP)
To prevent the crash, a developer might write type-checking code inside the client:
for (Account* acc : accounts) {
if (typeid(*acc) != typeid(FixedTermAccount)) {
acc->withdraw(100);
}
}
This is a terrible fix because:
- It violates OCP: Every time we add a new non-withdrawable account type, we must modify the client's loop.
- It violates LSP: The client can no longer treat all subclasses of
Accountuniformly.
The Architectural Fix (LSP-Compliant)
We must redesign our inheritance hierarchy so that we do not force subclasses to inherit behaviors they cannot support. We split the single Account interface into two distinct interfaces:
Now:
FixedTermAccountinherits fromDepositOnlyAccount.SavingsAccountandCurrentAccountinherit fromWithdrawableAccount.- The client maintains separate lists for withdrawable and deposit-only accounts, eliminating runtime surprises and type-checking.
11. LSP Code Implementation (C++ Evolution)
Naive Implementation (Violating LSP)
#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>
class Account {
protected:
double balance;
public:
Account(double balance) : balance(balance) {}
virtual void deposit(double amount) {
balance += amount;
}
virtual void withdraw(double amount) = 0; // Pure virtual
double getBalance() const { return balance; }
virtual ~Account() = default;
};
class SavingsAccount : public Account {
public:
SavingsAccount(double balance) : Account(balance) {}
void withdraw(double amount) override {
if (amount <= balance) {
balance -= amount;
std::cout << "Savings: Withdrew $" << amount << std::endl;
} else {
std::cout << "Savings: Insufficient funds." << std::endl;
}
}
};
// Violates LSP: FixedTermAccount cannot support withdraw()
class FixedTermAccount : public Account {
public:
FixedTermAccount(double balance) : Account(balance) {}
void withdraw(double amount) override {
// Violating the base class contract by throwing a runtime exception
throw std::logic_error("Withdrawal not allowed for Fixed Term Accounts!");
}
};
// Client code
void processTransactions(const std::vector<Account*>& accounts) {
for (auto* acc : accounts) {
acc->deposit(100); // Works fine
try {
acc->withdraw(50); // Crashes or throws exception for FixedTermAccount
} catch (const std::exception& e) {
std::cout << "Error: " << e.what() << std::endl;
}
}
}
int main() {
SavingsAccount savings(1000);
FixedTermAccount fixed(5000);
std::vector<Account*> accounts = {&savings, &fixed};
processTransactions(accounts);
return 0;
}
Clean, LSP-Compliant Implementation
#include <iostream>
#include <vector>
#include <string>
// Base Interface for accounts that only support deposits
class DepositOnlyAccount {
protected:
double balance;
public:
DepositOnlyAccount(double balance) : balance(balance) {}
virtual void deposit(double amount) {
balance += amount;
std::cout << "Deposited $" << amount << ". New Balance: $" << balance << std::endl;
}
double getBalance() const { return balance; }
virtual ~DepositOnlyAccount() = default;
};
// Specialized Interface for accounts that support withdrawals
class WithdrawableAccount : public DepositOnlyAccount {
public:
WithdrawableAccount(double balance) : DepositOnlyAccount(balance) {}
virtual void withdraw(double amount) = 0;
};
// Savings Account supports both deposit and withdraw
class SavingsAccount : public WithdrawableAccount {
public:
SavingsAccount(double balance) : WithdrawableAccount(balance) {}
void withdraw(double amount) override {
if (amount <= balance) {
balance -= amount;
std::cout << "Savings: Withdrew $" << amount << ". Remaining: $" << balance << std::endl;
} else {
std::cout << "Savings: Insufficient funds." << std::endl;
}
}
};
// Fixed Term Account only supports deposit (No withdraw method forced upon it!)
class FixedTermAccount : public DepositOnlyAccount {
public:
FixedTermAccount(double balance) : DepositOnlyAccount(balance) {}
};
// Client code is now completely safe from unexpected exceptions
class BankClient {
public:
void processWithdrawals(const std::vector<WithdrawableAccount*>& accounts, double amount) {
std::cout << "\n--- Processing Withdrawals ---" << std::endl;
for (auto* acc : accounts) {
acc->withdraw(amount); // Guaranteed to succeed safely
}
}
void processDeposits(const std::vector<DepositOnlyAccount*>& accounts, double amount) {
std::cout << "\n--- Processing Deposits ---" << std::endl;
for (auto* acc : accounts) {
acc->deposit(amount); // Guaranteed to succeed safely
}
}
};
int main() {
SavingsAccount savings(1000);
FixedTermAccount fixed(5000);
BankClient client;
// Separate lists based on capabilities
std::vector<WithdrawableAccount*> withdrawableAccounts = {&savings};
std::vector<DepositOnlyAccount*> depositOnlyAccounts = {&savings, &fixed}; // Savings is also a DepositOnlyAccount
client.processDeposits(depositOnlyAccounts, 200);
client.processWithdrawals(withdrawableAccounts, 150);
return 0;
}
Summary and Key Takeaways
- The Core Goal of SOLID: These principles prevent code from becoming tightly coupled, fragile, and unreadable, ensuring that software remains maintainable as it scales.
- Single Responsibility Principle (SRP): A class must have exactly one reason to change. Separate business logic, presentation formatting, and data persistence into distinct classes.
- SRP is Not "One Method Per Class": A class can have multiple methods as long as they all collectively serve a single, cohesive responsibility.
- Open-Closed Principle (OCP): You should be able to add new features by writing new code (extending classes/interfaces) rather than modifying existing, tested production code.
- Achieving OCP: Introduce abstraction layers. Depend on interfaces rather than concrete classes, allowing polymorphic behavior at runtime.
- Liskov Substitution Principle (LSP): Subclasses must be completely substitutable for their base classes. A subclass should never narrow down or break the contract of its parent.
- LSP Violation Warning Sign: If a subclass overrides a base class method to throw an "unsupported operation" exception, it is a clear sign of an LSP violation.
- The LSP Fix: Refactor the inheritance hierarchy. Split bloated interfaces into smaller, more specific interfaces so subclasses only inherit the behaviors they actually support.