Software Architecture Principle SOLID

Table Of Content
- Mastering SOLID Principles in Software Design
- What are SOLID Principles?
- S: Single Responsibility Principle (SRP)
- What it is
- Why it is important
- Example
- Where it goes wrong
- O: Open/Closed Principle (OCP)
- What it is
- Why it is important
- Example
- Where it goes wrong
- L: Liskov Substitution Principle (LSP)
- What it is
- Why it is important
- Example
- I: Interface Segregation Principle (ISP)
- What it is
- Why it is important
- Example
- D: Dependency Inversion Principle (DIP)
- What it is
- Why it is important
- Example
- SOLID Beyond Classes
- Conclusion
Mastering SOLID Principles in Software Design
In software development, maintaining clean, manageable, and scalable code is paramount. The SOLID principles, popularized by Robert C. Martin, offer a robust framework for achieving it. They are also — and this is the part most write-ups skip — among the most misapplied ideas in software engineering, regularly invoked to justify layers of abstraction that make codebases worse. So this post does two jobs: explain each principle precisely, with examples, and mark the boundary where each one stops being good advice.
What are SOLID Principles?
SOLID is an acronym that stands for:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
One framing before diving in: all five principles are managing the same underlying quantity — coupling, the degree to which changing one piece of code forces changes in another. Every principle below is a different strategy for making change local. Judge every application of them by that test: did this indirection make future change cheaper, or did it just add a file?
S: Single Responsibility Principle (SRP)
What it is
A class should have only one reason to change. The popular paraphrase — "a class should do one thing" — is subtly wrong, and the difference matters. Martin's actual definition is about sources of change: a class should be answerable to only one stakeholder or concern. His later, sharper formulation: gather together the things that change for the same reasons; separate things that change for different reasons. A class can do several things and still satisfy SRP, so long as those things change together.
Why it is important
- Ease of maintenance: when a class serves one concern, you know exactly where to look — and, just as importantly, what you won't break elsewhere when you touch it.
- Reduced merge friction: classes serving multiple masters become merge-conflict magnets, because unrelated changes keep landing in the same file.
- Improved testability: a single-concern class needs a fraction of the test scaffolding.
Example
Imagine a library application. It's tempting to create a single Book class that holds the data, persists itself, and formats itself for display:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def save_to_database(self):
# Code to save the book to a database
pass
def print_details(self):
print(f"{self.title} by {self.author}")This class answers to three different concerns that change on three different schedules: the domain model (what a book is), the persistence strategy (a DBA concern), and presentation (a UI concern). Separate them:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
class BookDatabase:
def save_to_database(self, book):
pass
class BookPrinter:
def print_details(self, book):
print(f"{book.title} by {book.author}")Where it goes wrong
Taken to its extreme, SRP produces the codebase where every class has one method and understanding a single user action requires opening eleven files. The corrective is the "changes together" test: if two behaviors have always changed in the same commit, separating them adds navigation cost and buys nothing. Cohesion is the goal; fragmentation is the failure mode wearing the goal's clothes.
O: Open/Closed Principle (OCP)
What it is
Software entities should be open for extension but closed for modification — you should be able to add new behavior without editing existing, tested code. The mechanism is always some form of abstraction: new behavior arrives as a new implementation of an existing interface, and the surrounding code doesn't change because it only ever spoke to the interface.
Why it is important
- Change without regression risk: untouched code can't acquire new bugs. Every plugin system, middleware stack, and driver model in the software you use is OCP industrialized — VS Code doesn't get modified when you install an extension.
- Independent deployability: in larger systems, extension-over-modification is what lets teams add behavior without coordinating a change to shared core code.
Example
A notification system that starts with email:
class Notification:
def send_email(self, message):
print(f"Sending email with message: {message}")Adding SMS by editing the class means retesting everything that touches it — and doing so again for every future channel:
class Notification:
def send_email(self, message):
print(f"Sending email with message: {message}")
def send_sms(self, message):
print(f"Sending SMS with message: {message}")Design it instead around an abstraction:
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, message):
pass
class EmailNotifier(Notifier):
def send(self, message):
print(f"Sending email with message: {message}")
class SMSNotifier(Notifier):
def send(self, message):
print(f"Sending SMS with message: {message}")
class PushNotifier(Notifier):
def send(self, message):
print(f"Sending push notification with message: {message}")New channels are new classes; existing code never reopens.
Where it goes wrong
OCP has a prerequisite nobody states: you have to guess the axis of change correctly. The abstraction above pays off only if the thing that varies is the channel. If the actual future change is "notifications need batching and retry," the Notifier hierarchy doesn't help — you'll be modifying everything anyway. Speculative abstraction against changes that never come is how codebases fill with interfaces that have exactly one implementation. The practical discipline: extract the abstraction when the second concrete case arrives, not before. The first duplication is information; the abstraction should be built from evidence.
L: Liskov Substitution Principle (LSP)
What it is
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This is the most formally precise of the five — Barbara Liskov's actual formulation is about behavioral contracts, not method signatures: a subtype may not strengthen preconditions (demand more than the parent did), may not weaken postconditions (deliver less than the parent promised), and must preserve the parent's invariants. A subclass that compiles cleanly can still violate LSP — which is why type checkers won't save you, and why LSP violations surface as runtime surprises in code that "looks right."
Why it is important
- Reliable polymorphism: the entire point of an inheritance hierarchy is that callers can program against the base type. An LSP-violating subclass silently invalidates every such caller — the bug lives in the subclass but detonates in code that did nothing wrong.
- Honest hierarchies: LSP is the test that reveals when "is-a" intuitions from the real world make bad type hierarchies. A square is a rectangle in geometry; a mutable Square that inherits Rectangle's independent
set_width/set_heightbreaks every caller that assumed the two were independent. Real-world taxonomy and behavioral substitutability are different relations.
Example
The classic bird problem:
class Bird:
def fly(self):
print("Flying")
class Sparrow(Bird):
pass
class Penguin(Bird):
def fly(self):
raise NotImplementedError("Penguins cannot fly")Penguin.fly raising where Bird.fly succeeds is a strengthened precondition — callers holding a Bird now need to know which birds are safe to fly, which defeats the abstraction entirely. Restructure so the ability lives only where it's true:
class Bird:
pass
class FlyableBird(Bird):
def fly(self):
print("Flying")
class Sparrow(FlyableBird):
pass
class Penguin(Bird):
passThe heuristic version worth memorizing: if a subclass overrides a method to throw, return a dummy value, or do nothing, the hierarchy is lying to you. That's not a style problem — it's a signal that the abstraction boundary is drawn in the wrong place, and composition will usually serve better than inheritance there.
I: Interface Segregation Principle (ISP)
What it is
A class should not be forced to implement interfaces it does not use. Instead of one large interface, many smaller, role-specific interfaces are preferred — clients should depend only on the methods they actually call.
Why it is important
- Coupling control: a client that depends on a fat interface is coupled to every method on it — including changes to methods it never calls. Small interfaces shrink each client's exposure to exactly its real dependency.
- Honest capabilities: segregated interfaces make capability explicit in the type system.
SimplePrinter implements Printertells the truth; aSimplePrinterwith afax()that throws is an LSP violation manufactured by an ISP violation — the two principles fail together more often than separately.
Example
class Printer:
def print(self, document):
pass
def scan(self, document):
pass
def fax(self, document):
pass
class SimplePrinter(Printer):
def print(self, document):
print(f"Printing: {document}")
def scan(self, document):
raise NotImplementedError("SimplePrinter cannot scan")
def fax(self, document):
raise NotImplementedError("SimplePrinter cannot fax")Split by role:
class Printer:
def print(self, document):
pass
class Scanner:
def scan(self, document):
pass
class Fax:
def fax(self, document):
pass
class SimplePrinter(Printer):
def print(self, document):
print(f"Printing: {document}")
class MultiFunctionPrinter(Printer, Scanner, Fax):
def print(self, document):
print(f"Printing: {document}")
def scan(self, document):
print(f"Scanning: {document}")
def fax(self, document):
print(f"Faxing: {document}")A language note that shows the principle is bigger than class hierarchies: Python's typing.Protocol and Go's implicitly-satisfied interfaces are ISP built into the language — in Go, the consumer defines the small interface it needs (io.Reader is one method), and anything with that method satisfies it. The convention "accept interfaces, return structs" is ISP as idiom.
D: Dependency Inversion Principle (DIP)
What it is
High-level modules should not depend on low-level modules; both should depend on abstractions. And the direction matters more than the summary suggests: the abstraction is owned by the high-level policy, and the low-level detail conforms to it. Your business logic defines the UserRepository interface it needs; the Postgres implementation answers to that interface — not the other way around. That inversion of ownership is what the "inversion" in the name refers to.
Why it is important
- The core outlives the details: databases, frameworks, and message brokers get swapped; business rules survive. DIP is what makes the survival cheap — this is the load-bearing idea inside hexagonal architecture ("ports and adapters") and clean architecture, both of which are DIP applied at system scale.
- Testability by construction: when dependencies arrive through an abstraction, tests inject fakes without patching or monkeying — the seam is designed in, not carved out later.
Example
The direct dependency:
class Database:
def get_user(self, user_id):
pass
class UserService:
def __init__(self):
self.database = Database()
def get_user(self, user_id):
return self.database.get_user(user_id)Inverted — the service defines what it needs, the database conforms:
from abc import ABC, abstractmethod
class UserRepository(ABC):
@abstractmethod
def get_user(self, user_id):
pass
class Database(UserRepository):
def get_user(self, user_id):
pass
class UserService:
def __init__(self, user_repository: UserRepository):
self.user_repository = user_repository
def get_user(self, user_id):
return self.user_repository.get_user(user_id)And the testing payoff, with no mocking framework in sight:
class MockRepository(UserRepository):
def get_user(self, user_id):
return {"user_id": user_id, "name": "Mock User"}
user_service = UserService(MockRepository())
print(user_service.get_user(1)) # {'user_id': 1, 'name': 'Mock User'}One practical addendum: something still has to construct the real object graph — decide that UserService gets the real Database in production. Do that wiring in one place at the application's entry point (the composition root), whether by hand or with a DI container. Scattered Database() constructions throughout the codebase are DIP violations wearing a trench coat.
SOLID Beyond Classes
Here's the part that keeps these principles relevant long after class-heavy OOP stopped being the default style: they describe module boundaries, and everything is modules. A microservice that owns billing and notifications has an SRP problem, and it will manifest as two teams fighting over one deploy pipeline. A REST API that breaking-changes a response contract violates LSP against every client in the field — API versioning is LSP discipline at network scale. A Terraform module with forty required variables is a fat interface begging for ISP. And an application that speaks to "object storage" through an interface rather than importing the AWS SDK into its domain logic is doing DIP, which is why it can move clouds. The vocabulary was born in 1990s object orientation; the coupling problems it names moved into distributed systems and took the principles with them.
Conclusion
All five SOLID principles manage coupling — they are strategies for making change local, so that tomorrow's requirement costs a new file instead of a regression hunt. SRP separates things that change for different reasons; OCP lets behavior grow without reopening tested code; LSP keeps hierarchies honest enough that polymorphism can be trusted; ISP keeps clients coupled only to what they use; DIP points dependencies at abstractions owned by the policy, not the plumbing.
And all five share the same failure mode: applied speculatively, they generate indirection that costs more than the flexibility it buys. The principles are diagnostic tools, not commandments — reach for them when change is painful, extract abstractions when the second case arrives, and let every layer of indirection justify itself by a change it actually made cheaper. Used that way, SOLID delivers exactly what it promises: a codebase that bends where it needs to bend, and stays rigid everywhere else.