SOLID Principles Explained with Real Code Examples
SOLID is a set of five design principles that make object-oriented code easier to extend, test, and maintain. Each principle targets a specific way that classes and their dependencies tend to go wrong as a codebase grows.
Published June 25, 2026Robert Martin introduced the SOLID principles in the early 2000s as a synthesis of patterns he had observed in maintainable codebases. The acronym was assembled later by Michael Feathers. The principles are language-agnostic — they apply wherever you have types, interfaces, and dependencies — but they resonate most clearly in statically typed object-oriented languages like Java, C#, and TypeScript.
None of the five principles is an absolute rule. They are heuristics: violations are symptoms worth examining, not always evidence of wrong code. A class that violates the single responsibility principle might be perfectly fine in a small script that will never grow. The cost of violating these principles scales with codebase size and team size.
S — Single Responsibility Principle
A class should have one reason to change. Equivalently: a class should be responsible to one actor, where an actor is a group of stakeholders who can require the class to change for their purposes.
The violation to watch for is a class that mixes multiple concerns in its methods:
// Violates SRP: this class handles persistence AND formatting AND email
class OrderService {
saveOrder(order: Order): void {
db.execute("INSERT INTO orders ...", order);
const receipt = `Order #${order.id}: $${order.total}`;
emailClient.send(order.customerEmail, "Your receipt", receipt);
}
}
If the finance team changes the receipt format, you touch this class. If the infrastructure team changes the database schema, you touch this class. If the marketing team wants a different email template, you touch this class. Three different teams, one class — every change risks breaking an unrelated concern.
// Separated: each class has one reason to change
class OrderRepository {
save(order: Order): void {
db.execute("INSERT INTO orders ...", order);
}
}
class ReceiptFormatter {
format(order: Order): string {
return `Order #${order.id}: $${order.total}`;
}
}
class OrderNotifier {
notify(order: Order, receipt: string): void {
emailClient.send(order.customerEmail, "Your receipt", receipt);
}
}
Now each class can evolve independently. Tests are simpler because each class has a small, well-defined surface area.
O — Open/Closed Principle
Software entities should be open for extension but closed for modification. When you need new behavior, you should be able to add it without editing existing code. Practically, this means designing with interfaces and polymorphism so that new implementations can be plugged in without touching what already works.
# Closed for extension: adding a new payment method means editing this function
def process_payment(order, method):
if method == "stripe":
stripe_client.charge(order.total)
elif method == "paypal":
paypal_client.charge(order.total)
elif method == "crypto": # new: must edit existing code
crypto_wallet.send(order.total)
else:
raise ValueError("Unknown method")
Every new payment method requires modifying the function, retesting it, and risking regressions in the Stripe and PayPal paths. The open/closed approach uses an interface:
from abc import ABC, abstractmethod
class PaymentProvider(ABC):
@abstractmethod
def charge(self, amount: float) -> None: ...
class StripeProvider(PaymentProvider):
def charge(self, amount: float) -> None:
stripe_client.charge(amount)
class CryptoProvider(PaymentProvider): # new provider: no existing code touched
def charge(self, amount: float) -> None:
crypto_wallet.send(amount)
def process_payment(order, provider: PaymentProvider):
provider.charge(order.total)
Adding a new payment provider now means writing a new class that implements the interface. The process_payment function is never touched.
L — Liskov Substitution Principle
Objects of a subclass should be substitutable for objects of the superclass without altering the correctness of the program. Formally: if S is a subtype of T, then objects of type T may be replaced with objects of type S without changing any desirable properties of the program.
Violations often look like a subclass that overrides a method but weakens its contract:
class Rectangle:
def set_width(self, w: int): self.width = w
def set_height(self, h: int): self.height = h
def area(self) -> int: return self.width * self.height
class Square(Rectangle):
def set_width(self, w: int):
self.width = w
self.height = w # squares must stay equal-sided
def set_height(self, h: int):
self.width = h
self.height = h
# Code written for Rectangle breaks when given a Square
def resize_to_landscape(shape: Rectangle):
shape.set_height(10)
shape.set_width(20)
assert shape.area() == 200 # fails if shape is a Square
The square-rectangle problem is the classic LSP violation: the geometric "is-a" relationship (a square is a rectangle) does not translate directly into a correct inheritance relationship. The fix is to recognize that Square and Rectangle have different behavioral contracts and should not share a class hierarchy. Use separate types or a shared interface that only exposes the behavior both can fulfill.
I — Interface Segregation Principle
Clients should not be forced to depend on interfaces they do not use. A fat interface that bundles many unrelated methods forces all implementors to provide stubs for methods they do not need, coupling them to changes in methods they do not care about.
// Fat interface: every implementor must provide all methods
interface DocumentProcessor {
parse(content: string): Document;
render(doc: Document): string;
validate(doc: Document): boolean;
encrypt(doc: Document): Buffer;
transmit(doc: Document, destination: string): void;
}
A class that only needs to validate documents must still implement parse, render, encrypt, and transmit, even as stubs. Changes to transmit's signature force a recompile in the validator even though the validator never uses it.
// Segregated: small, focused interfaces
interface Parser {
parse(content: string): Document;
}
interface Renderer {
render(doc: Document): string;
}
interface Validator {
validate(doc: Document): boolean;
}
// A class can implement only what it needs
class HtmlValidator implements Validator {
validate(doc: Document): boolean { ... }
}
Interface segregation pairs naturally with the dependency inversion principle: clients depend on small, narrow interfaces rather than large concrete classes, which makes swapping implementations straightforward.
D — Dependency Inversion Principle
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.
The principle targets the direction of dependency. When a high-level business rule directly imports and instantiates a low-level infrastructure class, it couples the business logic to the infrastructure choice. You cannot test the business rule without the infrastructure, and you cannot swap the infrastructure without touching the business rule.
# High-level module directly depends on low-level module
class OrderProcessor:
def __init__(self):
self.db = PostgreSQLDatabase() # hard dependency
self.mailer = SendgridMailer() # hard dependency
def process(self, order):
self.db.save(order)
self.mailer.send_confirmation(order)
Testing OrderProcessor requires a real PostgreSQL database and a real Sendgrid account. Switching to MySQL or Mailgun means editing OrderProcessor. The dependency inversion fix is to depend on interfaces:
from abc import ABC, abstractmethod
class OrderStore(ABC):
@abstractmethod
def save(self, order): ...
class Mailer(ABC):
@abstractmethod
def send_confirmation(self, order): ...
class OrderProcessor:
def __init__(self, store: OrderStore, mailer: Mailer):
self.store = store
self.mailer = mailer
def process(self, order):
self.store.save(order)
self.mailer.send_confirmation(order)
# Production
processor = OrderProcessor(PostgreSQLStore(), SendgridMailer())
# Test
class FakeStore(OrderStore):
def save(self, order): self.saved = order
class FakeMailer(Mailer):
def send_confirmation(self, order): self.sent = order
processor = OrderProcessor(FakeStore(), FakeMailer())
Now the business rule (process an order: save it, then send a confirmation) is tested without any real infrastructure. The concrete implementations are interchangeable. This is what frameworks call dependency injection — DI is the mechanism; dependency inversion is the principle that makes DI valuable.
Using SOLID as a diagnostic tool
The principles are most useful as questions you ask when code starts to feel wrong. If a class is hard to test in isolation, it probably violates dependency inversion. If adding a new feature requires touching the same file repeatedly, open/closed is being violated. If a subclass frequently overrides methods to do nothing or throw, LSP is in question. If a test has to mock a dozen methods to exercise one, the interface is probably too fat.
The principles are a vocabulary for diagnosing these symptoms, not a checklist to impose on every class from the start. Apply them where the codebase is actually painful — that is where they return value.