SOLID Principles Explained as Simply as Possible (+ Examples)
If you’ve been coding for a while, you’ve probably had that moment where you open an old file, scroll a bit, and think:
“What kind of madman wrote this?”
…wait… oh no. It was me.
Don’t worry, you’re in the right place. Every developer has been there. Projects grow, requirements shift, and that neat, elegant little script you were so proud of can slowly morph into something that looks like a plate of spaghetti left out overnight.
The SOLID principles are basically a set of common-sense guidelines to help you avoid that situation.
SOLID is an acronym for:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
They sound a bit academic, but the ideas are very simple. Let’s walk through each one, with examples in Python that actually make sense.
Why should I know SOLID?
The SOLID principles are often learned alongside design patterns. Decades ago, programmers couldn't easily google "top programming tips" or use LLMs to refactor their code.
This had two considerations:
- Best practices weren't fully mapped out yet.
- Information was harder to pass around, and was mostly found in textbooks.
So, early developers had to develop a common language for good, clean code. SOLID was created to help write clean, object-oriented code.
Although the principles have complicated names, SOLID is quite easy to understand, and will supercharge your code readability and maintainability.
1. Single Responsibility Principle (SRP)
The rule:
A class should have one reason to change.
The Single Responsibility Principle is basically saying: don’t make a class do too much. Every class should have just one main reason to exist, and only one type of change should affect it. If a class is juggling multiple unrelated tasks, changes to one part of it can accidentally break another. This makes debugging harder and creates hidden dependencies in your code. By keeping responsibilities separate, you get code that’s easier to test, maintain, and extend without side effects.
Think of it like this: if you had to explain to a new developer what a class does, you should be able to do it in one sentence. If you start saying “Well, it also handles…”, you’ve probably broken SRP.
Example of ignoring SRP
Here, Report
:
class Report:
def __init__(self, content):
self.content = content
def format_report(self):
return f"""*** Report ***
{self.content}
**************"""
def save_to_file(self, filename):
with open(filename, 'w') as f:
f.write(self.format_report())
- Knows how to format a report.
- Knows how to save it.
If saving changes (e.g., now you need to save to a database or send over an API), you have to touch this class, even if the report format didn’t change. That’s coupling two unrelated concerns.
Following SRP
Now:
class ReportSRP:
def __init__(self, content):
self.content = content
def format_report(self):
return f"*** Report ***
{self.content}
**************"
class ReportSaver:
def save_to_file(self, report: ReportSRP, filename):
with open(filename, 'w') as f:
f.write(report.format_report())
Report
deals with content.ReportSaver
deals with persistence.
This makes each one easier to test, change, and reuse.
2. Open/Closed Principle (OCP)
The rule:
Software entities should be open for extension, but closed for modification.
The Open/Closed Principle is about designing your code so that you can add new functionality without touching the existing, stable code. If every time you want to support a new feature you have to crack open an old class and change it, you’re risking introducing bugs. Instead, you write your classes so they can be extended (usually via inheritance or composition) without rewriting what already works. This makes your codebase safer to evolve, especially in larger projects where many people are working on different parts. It’s like adding new Lego bricks instead of reshaping the old ones.
You shouldn’t have to change existing code just to add new behavior. Instead, you should be able to extend it.
Breaking OCP
If we add a “student” discount, we have to edit this class, risking breaking something.
class DiscountCalculator:
def calculate(self, customer_type, price):
if customer_type == "regular":
return price
elif customer_type == "vip":
return price * 0.8
elif customer_type == "student":
return price * 0.9 # Adding this means modifying old code
OCP-friendly approach
Now, adding a new discount type doesn’t require editing existing code — just make a new class.
class BaseDiscount(ABC):
@abstractmethod
def apply_discount(self, price):
pass
class RegularDiscount(BaseDiscount):
def apply_discount(self, price):
return price
class VIPDiscount(BaseDiscount):
def apply_discount(self, price):
return price * 0.8
class StudentDiscount(BaseDiscount):
def apply_discount(self, price):
return price * 0.9
3. Liskov Substitution Principle (LSP)
The rule:
Subclasses should be replaceable for their base classes without breaking the program.
The Liskov Substitution Principle sounds fancy but is simple in practice: if you have a function that works with a base class, it should work with any subclass without weird surprises. A subclass shouldn’t remove behavior that the base class promised or suddenly change its meaning. If a subclass pretends to be compatible but actually changes expectations (like throwing errors where the base class wouldn’t), it’s a red flag. Following LSP helps keep your class hierarchies predictable and avoids subtle bugs where a subclass "kind of" fits but breaks things in edge cases.
In short: if you write code that works with a base class, it should still work if you give it any subclass.
Violating LSP
Imagine we’re building a payment processing system.
Now we add a subclass for PayPal payments:
class PaymentProcessor:
def process_payment(self, amount):
print(f"Processing payment of ${amount}")
class PayPalProcessor(PaymentProcessor):
def process_payment(self, amount):
print(f"Processing PayPal payment of ${amount}")
So far, so good.
Then someone creates a subclass for a “Trial” payment option:
class TrialPaymentProcessor(PaymentProcessor):
def process_payment(self, amount):
raise Exception("Trial accounts cannot process payments")
Uh-oh. This breaks LSP.
Functionality is totally different in the extended classes. One processes the payment, one raises an exception. Its behavior completely changes expectations; instead of processing a payment, it blows up.
LSP-friendly design
We can fix this by not making TrialPaymentProcessor
a payment processor at all — because it’s not capable of doing that.
We keep responsibilities clear:
- Payment processors can always process a payment.
- Trial accounts are just a different type of user, not a payment processor.
class PaymentProcessorLSP(ABC):
@abstractmethod
def process_payment(self, amount):
pass
class PayPalProcessorLSP(PaymentProcessorLSP):
def process_payment(self, amount):
print(f"Processing PayPal payment of ${amount}")
class CreditCardProcessorLSP(PaymentProcessorLSP):
def process_payment(self, amount):
print(f"Processing credit card payment of ${amount}")
# Trial account is not a payment processor
class TrialAccount:
def __init__(self, username):
self.username = username
4. Interface Segregation Principle (ISP)
The rule:
Don’t force a class to implement methods it doesn’t use.
The Interface Segregation Principle is about avoiding “fat” interfaces that make classes implement methods they don’t actually need. If you’ve ever seen a class with a bunch of raise NotImplementedError
stubs, you’ve probably found an ISP violation. This happens when a single interface or base class tries to cover too many unrelated abilities. Breaking them into smaller, more focused interfaces lets each class pick only the capabilities it really uses. This leads to cleaner designs and fewer useless methods hanging around.
Breaking ISP
OldPrinter
is forced to have useless methods.
class Machine(ABC):
@abstractmethod
def print_doc(self, document):
pass
@abstractmethod
def scan_doc(self, document):
pass
@abstractmethod
def fax_doc(self, document):
pass
class OldPrinter(Machine):
def print_doc(self, document):
print(f"Printing: {document}")
def scan_doc(self, document):
raise NotImplementedError("Scan not supported")
def fax_doc(self, document):
raise NotImplementedError("Fax not supported")
ISP-friendly
Now classes only implement what they actually do.
class Printer(ABC):
@abstractmethod
def print_doc(self, document):
pass
class Scanner(ABC):
@abstractmethod
def scan_doc(self, document):
pass
class Fax(ABC):
@abstractmethod
def fax_doc(self, document):
pass
class SimplePrinter(Printer):
def print_doc(self, document):
print(f"Printing: {document}")
class MultiFunctionPrinter(Printer, Scanner, Fax):
def print_doc(self, document):
print(f"Printing: {document}")
def scan_doc(self, document):
print(f"Scanning: {document}")
def fax_doc(self, document):
print(f"Faxing: {document}")
5. Dependency Inversion Principle (DIP)
The rule:
Depend on abstractions, not on concrete implementations.
The Dependency Inversion Principle says that high-level code (big-picture logic) shouldn’t depend directly on low-level code (specific details). Instead, both should depend on abstractions like interfaces or abstract classes. This way, you can swap out the low-level details without rewriting the high-level logic. For example, your application shouldn’t care whether it’s talking to MySQL or PostgreSQL — it should just talk to something that behaves like a database. DIP makes code more modular, testable, and flexible for future changes.
Instead of high-level modules calling specific low-level classes directly, both should depend on a common abstraction.
Without DIP
If we switch to PostgreSQL, we must change App
.
class MySQLDatabase:
def connect(self):
print("Connecting to MySQL database")
class App:
def __init__(self):
self.db = MySQLDatabase() # Direct dependency
def run(self):
self.db.connect()
With DIP
Now, switching databases is as simple as passing a different class.
class Database(ABC):
@abstractmethod
def connect(self):
pass
class MySQLDatabaseDIP(Database):
def connect(self):
print("Connecting to MySQL database")
class PostgreSQLDatabaseDIP(Database):
def connect(self):
print("Connecting to PostgreSQL database")
class AppDIP:
def __init__(self, db: Database):
self.db = db # Depend on abstraction
def run(self):
self.db.connect()
Wrapping It Up
SOLID isn’t a magic formula that guarantees perfect code. You can still write awful code while “following” these rules. But they’re a great mental checklist:
- SRP: Does this class have only one reason to change?
- OCP: Can I add new behavior without editing existing code?
- LSP: Will my subclasses work anywhere the base class works?
- ISP: Am I forcing classes to implement stuff they don’t need?
- DIP: Am I depending on abstractions instead of concrete details?
Even applying one or two of these principles can make a big difference in how maintainable your projects feel.