SOLID Principles Explained: A Practical 2026 Guide
The SOLID principles are five rules of object-oriented design that decide whether a codebase stays a pleasure to change or slowly congeals into something nobody dares touch. Every engineer eventually meets the same codebase: one class that does eleven things, a switch statement that grows a new branch every sprint, a “small” change that breaks three unrelated features. SOLID is the accumulated answer to that pain. It is not a framework, a library, or a checklist you bolt on at the end. It is a way of thinking about where responsibilities live and how the pieces of a system depend on one another, so that change stays cheap. In 2026, with AI assistants generating large volumes of code quickly, these design instincts matter more than ever — because the bottleneck is no longer typing code, it is keeping it changeable.
What this covers: what SOLID is and where it came from, each of the five principles with concrete bad-versus-good code, a small worked example that combines them, the honest trade-offs and when SOLID actually hurts, practical recommendations, and a FAQ.
What SOLID is and why it still matters in 2026

Figure 1: The five SOLID principles and the one-line idea behind each.
SOLID is an acronym for five design principles that, taken together, push you toward object-oriented code that is easier to maintain, extend, and reason about. The principles themselves were articulated by Robert C. Martin (widely known as “Uncle Bob”) around the turn of the millennium, drawing on earlier work by Bertrand Meyer and Barbara Liskov. The memorable ordering into the acronym S-O-L-I-D came later, coined by Michael Feathers. Martin’s own paper, “Design Principles and Design Patterns”, is the canonical primary source, and Martin Fowler’s writing on design has long championed the same goals.
The goal is not abstract purity. It is a very practical property: when a requirement changes — and it always does — you want to touch as little code as possible, with confidence that nothing distant breaks. Each letter attacks a different failure mode that makes change expensive. Single Responsibility limits how many reasons a class has to change. Open/Closed lets you add behavior without editing tested code. Liskov keeps inheritance honest. Interface Segregation stops clients depending on methods they ignore. Dependency Inversion frees high-level policy from low-level detail.
Think about what makes a real codebase painful. It is almost never a single bad algorithm. It is the rigidity that means a small change ripples into ten files, the fragility that means fixing one bug spawns two more in unrelated places, and the immobility that means a useful class cannot be reused because it drags half the system along with it. Robert Martin named exactly these symptoms — rigidity, fragility, immobility, and viscosity — as the signs of rotting design, and each SOLID principle is a targeted antidote. Single Responsibility and Interface Segregation fight rigidity by keeping units small and focused. Liskov and Open/Closed fight fragility by making extension safe. Dependency Inversion fights immobility by decoupling policy from detail so the valuable parts can travel.
These ideas predate the cloud, microservices, and AI pair-programmers, yet they have aged remarkably well. The reason is simple: they are really about managing dependencies and the cost of change, and that problem is timeless. A microservice with tangled responsibilities is as painful as a monolith with tangled responsibilities — arguably worse, because the tangle now spans a network. SOLID gives you a shared vocabulary for the design conversations that prevent those tangles. The principles are guidelines, not laws, but knowing them sharpens every refactoring decision you make.
It helps to know where SOLID sits among related ideas. It is not the whole of good design. It sits alongside older heuristics like high cohesion and low coupling, the DRY principle, and the broader catalogue of design patterns. In fact, several SOLID principles are just sharp restatements of cohesion and coupling: Single Responsibility is cohesion applied to a class, and Dependency Inversion is loose coupling applied to module boundaries. If you have read the Gang of Four patterns, you will recognise that Strategy, Template Method, and Adapter are essentially Open/Closed and Dependency Inversion made concrete. SOLID is the layer of principle beneath those patterns — the why the patterns exist.
The five principles
The five principles are independent ideas, but they reinforce one another. Master them individually first, then watch how they combine. For each, we will give a one-paragraph definition, a deliberately bad example, the improved version, and a note on when the principle actually bites in practice. The code is Python, chosen for readability — the lessons transfer to any object-oriented language: Java, C#, TypeScript, Kotlin, Swift, or Go all express the same ideas with slightly different syntax.
A quick word on how to read these examples. The “bad” versions are not strawmen — they are the shapes real code takes when written quickly under deadline, and most of them work perfectly well until the first change request arrives. The point of each refactoring is not that the original was wrong, but that it was brittle: correct today, expensive to change tomorrow. As you read, keep asking the only question that matters — when the next requirement lands, how much existing, tested code do I have to disturb?
S — Single Responsibility Principle
A class should have one, and only one, reason to change. Put differently, it should be responsible to a single actor or concern. “Reason to change” is the operative phrase: if business reporting rules and email-delivery rules can each force you to edit the same class, that class has two responsibilities and should be split.
The classic violation is a class that mixes computing data, formatting it, and delivering it:
# BAD: one class, three reasons to change
class Report:
def __init__(self, rows):
self.rows = rows
def total(self):
return sum(r["amount"] for r in self.rows)
def to_html(self):
body = "".join(f"<tr><td>{r['name']}</td></tr>" for r in self.rows)
return f"<table>{body}</table>"
def send_email(self, to_addr):
# SMTP wiring lives here too...
smtp_send(to_addr, self.to_html())
Change the email provider and you edit Report. Change the HTML layout and you edit Report. Change how totals are computed and you edit Report. Three unrelated teams, one collision point. The fix is to separate the concerns:
# GOOD: each class has one reason to change
class Report:
def __init__(self, rows):
self.rows = rows
def total(self):
return sum(r["amount"] for r in self.rows)
class HtmlReportFormatter:
def render(self, report):
body = "".join(f"<tr><td>{r['name']}</td></tr>" for r in report.rows)
return f"<table>{body}</table>"
class EmailReportSender:
def __init__(self, smtp_client):
self.smtp = smtp_client
def send(self, to_addr, html):
self.smtp.send(to_addr, html)

Figure 2: Single Responsibility splits one overloaded class into focused collaborators.
Notice that the split is not arbitrary. Each new class now answers to a single actor — the people who request changes. The reporting team owns Report. The design team owns HtmlReportFormatter. The infrastructure team owns EmailReportSender. When the design team wants a new layout, they touch one class that the reporting and infrastructure teams never read. That alignment between “reason to change” and “team that drives the change” is the real test of SRP, and it is more useful than counting methods or lines.
Where it bites: SRP failures rarely look bad on day one. The “god class” grows by accretion — one helper method at a time — until it is two thousand lines and every merge conflicts. Each addition seems reasonable in isolation; only in aggregate does the class become unmaintainable. The discipline is to notice when a class is starting to answer to more than one actor and split it early, while splitting is still cheap. A practical signal is the commit history: if one file keeps appearing in commits driven by wildly different concerns — security, formatting, persistence — it is probably carrying more than one responsibility.
O — Open/Closed Principle
Software entities should be open for extension but closed for modification. You should be able to add new behavior by adding new code, not by editing existing, already-tested code. In practice this usually means programming to an abstraction and adding new implementations rather than editing a growing conditional.
The smell is a type-switch that you must reopen for every new case:
# BAD: every new shape forces an edit here
class AreaCalculator:
def area(self, shape):
if shape.kind == "circle":
return 3.14159 * shape.radius ** 2
elif shape.kind == "rectangle":
return shape.width * shape.height
# add a triangle? edit this method again...
Each new shape means reopening, re-reading, and re-testing AreaCalculator, risking the cases that already worked. Invert it so each shape owns its own area:
# GOOD: add shapes without touching the calculator
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height
class AreaCalculator:
def total(self, shapes):
return sum(s.area() for s in shapes) # never changes

Figure 3: Open/Closed — new shapes plug into the abstraction without editing the calculator.
The payoff is concrete. Adding a Triangle means writing one new class that implements area(). You never reopen AreaCalculator, so you cannot accidentally break the circle and rectangle cases that already pass their tests. The conditional version, by contrast, forces you to re-read and re-test the whole method every time the catalogue grows — and conditionals have a way of growing into dozens of branches that no one fully understands. Open/Closed converts a risky edit into a safe addition.
Where it bites: do not chase Open/Closed everywhere. The right time to introduce the abstraction is when you actually see the second or third variant arriving. Adding a Shape hierarchy before you have a single concrete reason is speculative generality — you pay the cost of indirection up front and may guess the wrong seam. A single if/else with two branches is perfectly fine; reach for the abstraction when the conditional starts to multiply or when you can name a clear axis of future variation. This tension between flexibility and simplicity is covered honestly in the trade-offs section.
L — Liskov Substitution Principle
Subtypes must be substitutable for their base types without breaking the program. Any code that works with the base class must keep working when handed a subclass, with no surprises. If a subtype strengthens preconditions, weakens guarantees, or throws where the parent did not, it violates Liskov — even if it compiles fine.
The textbook trap is the square-is-a-rectangle relationship:
# BAD: Square breaks code written against Rectangle
class Rectangle:
def __init__(self, w, h):
self._w, self._h = w, h
def set_width(self, w): self._w = w
def set_height(self, h): self._h = h
def area(self): return self._w * self._h
class Square(Rectangle):
def set_width(self, w):
self._w = self._h = w # side effect!
def set_height(self, h):
self._w = self._h = h
def resize_and_check(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
assert rect.area() == 20 # holds for Rectangle, fails for Square
Square passes for a Rectangle in the type system but violates the contract: callers expect width and height to vary independently. The honest fix is to stop forcing the inheritance and model both as siblings under a shared abstraction:
# GOOD: no false is-a relationship
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self): return self.w * self.h
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self): return self.side ** 2
The deeper lesson is that inheritance models a behavioral contract, not just a shared shape. A square genuinely is a rectangle in geometry, but the Rectangle class promises independent width and height, and a square cannot keep that promise. The mistake was letting real-world taxonomy override the code’s contract. Barbara Liskov’s original formulation, from which the principle takes its name, makes the requirement precise: if S is a subtype of T, objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.
Where it bites: Liskov violations hide behind “it’s basically the same thing.” A ReadOnlyList that subclasses List but raises on append is the everyday version — it looks like a list, passes the type checker, then explodes the first time generic code tries to add an item. The test is behavioral, not structural. Ask whether every promise the base type makes still holds for the child: same return types, no new exceptions, no stronger preconditions, no weaker guarantees. When the answer is no, favour composition over inheritance, or introduce a narrower shared abstraction that both types can honestly satisfy.
I — Interface Segregation Principle
No client should be forced to depend on methods it does not use. Many small, focused interfaces beat one large, general-purpose interface. When a class implements a fat interface, it ends up with stub methods that throw or do nothing — a clear sign the interface is doing too much.
Consider a single Worker interface forced onto things that are not human:
# BAD: a fat interface forces meaningless methods
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
@abstractmethod
def sleep(self): ...
class RobotWorker(Worker):
def work(self): ...
def eat(self): raise NotImplementedError # robots don't eat
def sleep(self): raise NotImplementedError # or sleep
RobotWorker is now a landmine: any code calling eat() on a worker may explode. Split the fat interface into role-sized pieces:
# GOOD: small interfaces, implement only what applies
class Workable(ABC):
@abstractmethod
def work(self): ...
class Eatable(ABC):
@abstractmethod
def eat(self): ...
class Sleepable(ABC):
@abstractmethod
def sleep(self): ...
class HumanWorker(Workable, Eatable, Sleepable):
def work(self): ...
def eat(self): ...
def sleep(self): ...
class RobotWorker(Workable):
def work(self): ... # implements only what is real

Figure 4: Interface Segregation replaces one fat interface with small, role-sized ones.
Interface Segregation and Single Responsibility are close cousins — both are about not bundling unrelated things together — but they operate at different levels. SRP is about a class having one reason to change; ISP is about a contract exposing only what a given client needs. A useful way to design interfaces is from the consumer’s side: instead of asking “what can this object do?”, ask “what does this particular caller actually require?” and define an interface that says exactly that and no more. This is sometimes called role-based or client-specific interface design, and it keeps each dependency edge as thin as possible.
Where it bites: fat interfaces creep in when you “future-proof” by adding every method a consumer might want. Each consumer then drags along methods it ignores, and a change to one method ripples to clients that never called it — recompilation in some languages, retesting in all of them, and a wider blast radius for every edit. Keep interfaces as small as the consumer’s actual need; it is always cheaper to combine two small interfaces later than to split one bloated interface that a dozen classes already depend on.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules; both should depend on abstractions. And abstractions should not depend on details — details should depend on abstractions. Concretely: your business logic should talk to an interface, and the concrete database or HTTP client should be injected, not hard-wired.
The violation is high-level policy reaching straight for a concrete dependency:
# BAD: business logic welded to a concrete database
class MySqlUserRepository:
def save(self, user): ... # MySQL-specific
class UserService:
def __init__(self):
self.repo = MySqlUserRepository() # hard dependency
def register(self, user):
self.repo.save(user)
UserService now cannot be unit-tested without a MySQL instance, and swapping the store means editing the service. Invert the dependency with an abstraction and constructor injection:
# GOOD: depend on an abstraction, inject the detail
from abc import ABC, abstractmethod
class UserRepository(ABC):
@abstractmethod
def save(self, user): ...
class MySqlUserRepository(UserRepository):
def save(self, user): ...
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo # injected abstraction
def register(self, user):
self.repo.save(user)
# wiring happens at the edge of the app
service = UserService(MySqlUserRepository())
Now UserService depends only on the UserRepository contract. In a test you pass a fake; in production you pass MySQL or Postgres. The high-level policy and the low-level detail both depend on the abstraction, which is exactly the inversion the principle names.
It is worth distinguishing two terms that often blur together. Dependency Inversion is the principle: depend on abstractions, and let the direction of source-code dependencies point toward the abstraction rather than the detail. Dependency Injection is one technique for achieving it: instead of a class creating its own collaborators, they are handed in from outside — through the constructor, a setter, or a framework’s container. You can practise dependency inversion with plain constructor parameters and no framework at all; the heavyweight injection containers are a convenience, not a requirement. The principle is the goal, injection is a means.
The direction of dependency is the subtle part. Without inversion, UserService (high-level policy) points at MySqlUserRepository (low-level detail), so a change in the database can force a change in the policy. With inversion, both point at the UserRepository abstraction, which the policy owns. The detail now conforms to the policy’s needs, not the other way around — that flip is literally the “inversion” in the name.
Where it bites: dependency inversion is what makes code testable, so its absence shows up first as untestable units that need a real database, queue, or third-party API to run. If you find yourself spinning up Docker containers to test a piece of business logic, a missing abstraction is usually the cause. Constructor injection — pass collaborators in rather than new-ing them inside — is the simplest practical expression of the principle, and it costs almost nothing to adopt from the start.
SOLID in practice: putting it together
The principles shine brightest in combination. Take a checkout flow that starts as one tangled OrderService reaching directly for MySQL, an SMTP client, and Stripe. It is hard to test, hard to change, and every new payment provider means editing the service. Watch how the five principles reshape it.

Figure 5: Before, the service is welded to concrete tools. After, it depends only on abstractions.
First, Single Responsibility: pull persistence, notification, and payment out of OrderService into separate collaborators. Second, Dependency Inversion: define OrderRepository, Notifier, and PaymentGateway interfaces, and inject implementations through the constructor. Third, Interface Segregation: keep each interface narrow — PaymentGateway exposes only charge(), not a grab-bag of refund, payout, and reconciliation methods that most callers ignore.
class OrderService:
def __init__(self, repo: OrderRepository,
notifier: Notifier,
payments: PaymentGateway):
self.repo = repo
self.notifier = notifier
self.payments = payments
def place(self, order):
self.payments.charge(order.total) # Stripe, PayPal, fake...
self.repo.save(order) # MySQL, Postgres, in-memory...
self.notifier.send(order.user, "Order confirmed")
Now Open/Closed falls out naturally: adding PayPal means writing a new PaymentGateway implementation, not editing OrderService. And Liskov governs those implementations — every PaymentGateway must honor the same contract, so a FakePaymentGateway used in tests behaves like the real one within the promised bounds. The result is a service you can unit-test in milliseconds with in-memory fakes, extend without reopening tested code, and reason about one collaborator at a time. That payoff — cheap change and fast tests — is what SOLID is ultimately for.
It is worth noticing how the principles chained together here, because that is how they show up in real design work. You rarely sit down and apply “Interface Segregation” in isolation. Instead you feel a pain — this service is impossible to test — and pulling the thread surfaces several principles at once. Extracting the database into an injected abstraction (Dependency Inversion) immediately raises the question of what that abstraction should expose (Interface Segregation), which in turn forces you to decide what OrderService is actually responsible for (Single Responsibility). The letters are five facets of one underlying instinct: keep the things that change for different reasons apart, and let the stable parts depend on abstractions rather than details.
Trade-offs, gotchas, and when SOLID hurts
SOLID is a set of heuristics, not commandments, and applied dogmatically it backfires. The most common failure is over-abstraction: wrapping every class in an interface “just in case,” creating a maze of one-implementation interfaces that add indirection without ever paying off. If a UserRepository interface will only ever have one implementation, the abstraction is pure ceremony — you can extract it later, in the five minutes it actually becomes useful.
A related trap is premature interface design. Open/Closed and Dependency Inversion both reward you for finding the right seam, but you usually cannot see the right seam until the second or third variant appears. Guessing early produces abstractions that fit none of the real cases and have to be torn out. The pragmatic rule is “rule of three”: let duplication exist twice, abstract on the third occurrence.
There is also a real cognitive cost. A flow split across six small classes and three interfaces can be harder to follow than one honest hundred-line function, especially for a newcomer tracing a bug. Indirection has a price: every interface is a place where the reader has to stop and ask “which implementation actually runs here?” When the answer is always the same single implementation, you have paid that tax for nothing. SOLID trades local simplicity for global flexibility, and that trade is only worth it when change is actually likely. For a throwaway script or a stable, never-touched module, the simpler shape wins.
Finally, beware dogmatism. The principles are heuristics that point in a direction; they were never meant to be enforced by a linter or used as a cudgel in code review. “This violates SOLID” is not, by itself, a valid objection — the real question is always whether the code will be cheaper to change given how this part of the system actually evolves. Some of the most respected practitioners are quick to say that simple, readable code beats principled code that no one can follow. Treat the five letters as conversation starters, not as rules to be satisfied, and never let the acronym win an argument that good judgment should settle.
Practical recommendations
Treat SOLID as a lens for reviewing and refactoring, not a gate you must pass before writing a line. Apply it when you feel friction — a class that keeps growing, a test you cannot write, a conditional you keep reopening — and let it guide the fix. Here is a working checklist:
- Single Responsibility: Can you describe the class in one sentence without “and”? If not, consider splitting.
- Open/Closed: Does adding a new variant mean editing a tested class, or adding a new one? Prefer the latter — once you have a second variant.
- Liskov: Does every subclass honor every promise of its parent — same exceptions, same guarantees, no surprising side effects?
- Interface Segregation: Does any implementer stub out methods it does not need? Split the interface.
- Dependency Inversion: Can you unit-test the class with fakes, no real database or network? If not, inject the dependencies.
- Restraint: Is this abstraction earning its keep right now, or is it speculative? Delete it if it is the latter.
A good habit is to let your tests pull the design in the SOLID direction rather than applying the principles by force. Code that is hard to test is almost always code that violates one of these principles — usually Dependency Inversion or Single Responsibility. So when a unit test is awkward to write, treat that friction as a design signal, not a testing problem. Add the seam that makes the test easy, and you will usually find you have improved the design at the same time. This test-driven feedback loop is, in practice, the most reliable way to arrive at SOLID code without over-engineering it: you only introduce an abstraction at the moment a test demands one, which neatly sidesteps speculative generality. Above all, optimize for change you can actually foresee. SOLID is insurance, and like all insurance it has a premium. Pay it where the risk is real.
Frequently asked questions
Are SOLID principles still relevant in 2026?
Yes. SOLID is fundamentally about managing dependencies and the cost of change, which is timeless. Microservices, serverless, and AI-generated code have not removed the problem — if anything, a flood of quickly-generated code makes design discipline more valuable. The principles are guidelines, and you apply them with judgment, but the underlying goals remain central to maintainable software.
Do SOLID principles apply to functional programming?
Partly. SOLID is framed for object-oriented design, so the literal mechanics (classes, interfaces, inheritance) map imperfectly. But the spirit transfers: single responsibility becomes small, focused functions; dependency inversion becomes passing functions or capabilities as arguments; open/closed becomes composition over modification. The vocabulary differs, but the goal of cheap, safe change is shared.
What problems do SOLID principles solve?
They attack the specific failure modes that make code expensive to change: god classes with too many reasons to change, conditionals you must reopen for every new case, broken inheritance, fat interfaces that couple unrelated clients, and business logic welded to concrete infrastructure. Each principle targets one of these, and together they keep a codebase changeable as it grows.
Is it bad to not follow SOLID?
Not inherently. SOLID is a tool, and over-applying it — especially premature abstraction — can make code worse, not better. For small scripts, prototypes, or stable code that rarely changes, a simpler shape is often the right call. The judgment is knowing where change is likely and spending your design budget there.
What is the most important SOLID principle?
Most practitioners point to Single Responsibility and Dependency Inversion as the highest-leverage. SRP keeps classes cohesive and prevents the god-class spiral, while Dependency Inversion is what makes code testable and swappable. The other three often fall out naturally once those two are in place.
Further reading
- Conceptual vs Logical vs Physical Architecture: A Comparison — how the same design discipline scales from class structure up to system architecture.
- SCADA Historian Architecture from First Principles — a worked example of dependency-driven design in an industrial data system.
- Robert C. Martin, “Design Principles and Design Patterns” — the canonical primary source for the principles behind SOLID.
- Refactoring.Guru: Design Principles — clear, example-driven explanations of SOLID and related design ideas.
Riju writes about software architecture, IoT, and digital twins at iotdigitaltwinplm.com. More about the author.
