Five foundational principles that guide object-oriented design toward code that is maintainable, extensible, and testable — from beginner-friendly intuition to production-grade application.
SOLID is an acronym coined by Robert C. Martin ("Uncle Bob") that groups five object-oriented design principles. These principles are not rules to follow mechanically — they are design heuristics that help you avoid the most common structural problems in growing codebases.
Each principle addresses a specific pain point: code that is hard to change, fragile after modification, impossible to test in isolation, or tightly coupled to infrastructure. Applied together, they push your code toward loose coupling and high cohesion.
Single Responsibility
Solves: Classes that change for too many reasons
Open / Closed
Solves: Adding features breaks existing code
Liskov Substitution
Solves: Subclasses that surprise their callers
Interface Segregation
Solves: Fat interfaces with unused methods
Dependency Inversion
Solves: High-level code coupled to infrastructure
When to apply: SOLID principles have real costs — they add abstractions, interfaces, and indirection. Apply them proportionally: a 50-line script doesn't need SOLID. A production service with multiple contributors and changing requirements does. The goal is sustainable growth, not ceremony.
"A class should have only one reason to change."
— Robert C. Martin
"One reason to change" doesn't mean "one method" or "doing one thing" in a narrow sense. It means one actor — one person or role in your organisation — should be able to demand a change to this class. If a tax accountant, a DBA, a marketing manager, and a devops engineer could all independently request changes to the same class — that class has too many responsibilities.
Think of a Swiss Army knife: it technically does many things, but it does none of them well. A dedicated chef's knife outperforms it at its specific job every time. SRP is about giving each class the right blade for exactly one job.
Domain rules, calculations, validations — owned by business analysts
SQL, ORM, schema — owned by the DBA or infrastructure team
Templates, providers, channels — owned by the marketing team
// ❌ VIOLATION — One class, five reasons to change
public class OrderProcessor {
// Reason 1: business rules change
public double calculateTotal(Order order) {
double sub = order.getItems().stream()
.mapToDouble(i -> i.getPrice() * i.getQty()).sum();
return sub + (sub * 0.18); // GST hardcoded here
}
// Reason 2: DB schema or ORM changes
public void saveOrder(Order order) {
String sql = "INSERT INTO orders (id, total) VALUES (?, ?)";
jdbcTemplate.update(sql, order.getId(), calculateTotal(order));
}
// Reason 3: email provider or template changes
public void sendConfirmation(Order order) {
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(order.getCustomerEmail());
msg.setSubject("Order Confirmed #" + order.getId());
msg.setText("Your order total is ₹" + calculateTotal(order));
mailSender.send(msg);
}
// Reason 4: log format or destination changes
public void logOrder(Order order) {
String line = LocalDateTime.now() + " | ORDER | " + order.getId();
Files.write(Paths.get("orders.log"), line.getBytes(), APPEND);
}
// Reason 5: report format changes
public String generateDailyReport(List<Order> orders) {
return orders.stream()
.map(o -> o.getId() + ": ₹" + calculateTotal(o))
.collect(Collectors.joining("\n"));
}
}Spring's stereotype annotations enforce SRP by architecture: controllers handle HTTP, services handle business logic, repositories handle data access.
Express middleware chains (auth, validation, rate-limiting, handler) each do one thing. The router composes them — each piece has one reason to change.
Django separates Model (data + constraints), Form (validation + rendering), and View (request handling) — three actors, three classes.
"Do one thing and do it well" — cat, grep, sort, uniq are the SRP in CLI form. They compose via pipes rather than cramming everything into one command.
"Software entities should be open for extension, but closed for modification."
— Bertrand Meyer (1988), popularised by Robert C. Martin
Every time you modify existing, tested code to add a new feature, you risk introducing regressions. OCP says: design your code so that adding a new feature = writing a new class/function, never editing an old one.
Think of a USB port: the laptop is "closed" — you don't open it up to add new peripheral support. But it's "open" — you just plug in a new device. The port (abstraction) is the contract; new peripherals (extensions) implement the protocol without touching the laptop.
In code, this usually means identifying the axis of variation (what's likely to change: payment types, export formats, discount rules) and hiding it behind an abstraction. New variants are added as new implementations — existing code never changes.
OCP and Prediction: You can't hide everything behind abstractions — that leads to over-engineering. Apply OCP to the parts of your code that have already changed once or that domain knowledge tells you will change. Don't abstract for imaginary future requirements.
// ❌ VIOLATION — Adding a new payment type means editing this class
public class PaymentProcessor {
public void processPayment(Payment payment) {
if (payment.getType().equals("CREDIT_CARD")) {
// credit card logic
System.out.println("Charging card ending in " + payment.getLast4());
} else if (payment.getType().equals("UPI")) {
// UPI logic
System.out.println("Sending UPI request to " + payment.getUpiId());
} else if (payment.getType().equals("NET_BANKING")) {
// net banking logic
System.out.println("Redirecting to bank: " + payment.getBankCode());
}
// ← next engineer adds "CRYPTO" here, touching existing code
// Every new payment type = modification = risk of breaking existing types
}
}Collections.sort() is closed — it never changes. You extend it by providing a new Comparator. The sorting algorithm is stable; the comparison strategy is the extension point.
The bundler core is closed. You add capabilities (TypeScript, CSS modules, image compression) by writing new plugins. The bundler never changes to support new file types.
The HTTP pipeline is closed. You extend it by composing middleware — auth, logging, CORS, rate-limit. Each new concern is a new function, not a change to the framework.
Python's Timsort is closed. You extend its behaviour via key= or functools.cmp_to_key(). The sort algorithm is untouched; custom ordering is the extension.
"Objects of a subtype must be substitutable for objects of the supertype without altering the correctness of the program."
— Barbara Liskov, 1987 (Turing Award winner)
LSP is the correctness contract for inheritance. If class B extends class A, then anywhere your code uses an A, you must be able to silently swap in a B and observe no difference in correct behaviour — no new exceptions, no broken invariants, no surprise outputs.
A practical test: if you find yourself writing if (obj instanceof SubClass) in client code, or a subclass method throws UnsupportedOperationException, LSP is almost certainly violated.
The classic textbook counterexample is Square extends Rectangle. Geometrically, every square is a rectangle. But behaviourally, a Square cannot honour the Rectangle contract (setting width and height independently). LSP cares about behavioural substitutability, not geometric taxonomy.
A subtype cannot strengthen preconditions. If the parent accepts any positive integer, the child cannot additionally require it to be even.
Violation sign: Subclass throws where parent did not
A subtype cannot weaken postconditions. If the parent guarantees a non-null return, the child cannot return null.
Violation sign: Subclass returns unexpected values or types
A subtype must preserve all invariants of the supertype. If the parent guarantees balance ≥ 0, the child must too.
Violation sign: Subclass leaves object in invalid state
// ❌ VIOLATION — Square breaks the contract of Rectangle
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int getArea() { return width * height; }
}
// Square IS-A Rectangle... geometrically. But behaviourally?
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // ← forces equal sides
}
@Override
public void setHeight(int h) {
this.width = h; // ← forces equal sides
this.height = h;
}
}
// Client code written against Rectangle contract
public void resizeAndCheck(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
// Developer expects: 5 × 10 = 50
assert r.getArea() == 50; // ✓ passes for Rectangle
// ✗ FAILS for Square — area is 100!
}
// Square cannot be substituted for Rectangle without breaking behaviour.
// This violates the Liskov Substitution Principle.ArrayList and LinkedList both implement List. Code written against List works correctly with either. LinkedList doesn't throw for operations ArrayList handles — it just has different performance characteristics.
TextIOWrapper, BytesIO, StringIO all substitute for IOBase. Functions accepting a file-like object work with any of them. No surprises, no broken contracts.
In React, a specialised Button component (PrimaryButton, IconButton) should accept all props a base Button accepts and add more. Removing expected props violates LSP.
A 201 Created response is a subtype of 2xx Success. Any code that handles 2xx responses must correctly handle 201. HTTP itself is designed around LSP.
"Clients should not be forced to depend on interfaces they do not use."
— Robert C. Martin
ISP is SRP applied to interfaces. A fat interface forces every implementor to provide methods they don't need — leading to empty methods, throw new UnsupportedOperationException() stubs, and misleading contracts. Worse, a change to any method in the fat interface forces a recompile of every implementor, even those that don't use it.
The remedy is to split fat interfaces into small, cohesive role interfaces — each representing one capability. Implementors then pick only the roles they can genuinely fulfil. Clients program to the minimal interface they actually need.
Think of a restaurant menu: a vegetarian diner doesn't want a menu that forces them to order meat. You give them a vegetarian menu. ISP is about giving each client the exact menu of capabilities they need — no more, no less.
SRP is about classes — a class should have one reason to change. It governs how you split implementation code.
ISP is about interfaces — clients should not see methods they don't use. It governs how you design contracts. A single class can implement multiple narrow interfaces without violating SRP.
// ❌ VIOLATION — Fat interface forces irrelevant method implementations
public interface Worker {
void work();
void eat(); // ← robots don't eat
void sleep(); // ← robots don't sleep
void attendMeeting(); // ← contractors might not
void submitTimesheet();
}
// Robot is forced to implement biologically absurd methods
public class Robot implements Worker {
public void work() { System.out.println("Welding..."); }
public void eat() { throw new UnsupportedOperationException("Robots don't eat!"); }
public void sleep() { throw new UnsupportedOperationException("Robots don't sleep!"); }
public void attendMeeting() { System.out.println("Attending meeting..."); }
public void submitTimesheet() { throw new UnsupportedOperationException("No timesheet for robots!"); }
}
// Freelancer forced to implement internal HR methods
public class Freelancer implements Worker {
public void work() { System.out.println("Working remotely..."); }
public void eat() { System.out.println("Eating lunch..."); }
public void sleep() { System.out.println("Sleeping..."); }
public void attendMeeting() { throw new UnsupportedOperationException("Not invited!"); }
public void submitTimesheet() { throw new UnsupportedOperationException("Not an employee!"); }
}Java separates Readable, Writable, Closeable, Flushable, AutoCloseable into distinct interfaces. A Scanner only implements Readable + Closeable — it doesn't need Writable.
Python's structural subtyping via Protocol lets you define minimal interfaces. A function accepting Sized only needs __len__ — not Iterable, not Reversible.
Instead of one big useApp() hook, React encourages useAuth(), useCart(), usePricing() — each component imports only the hooks it actually needs.
API consumers are given minimal contracts — a mobile client gets a slim API, an admin dashboard gets an expanded one. Neither is forced to parse fields they don't use.
"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."
— Robert C. Martin
Without DIP, your business logic directly instantiates infrastructure: new MySQLRepository(), new SendGridMailer(), new RedisCache(). The high-level business logic depends on low-level infrastructure. Change the database → change the business logic. That's backwards.
DIP inverts this: the business layer defines the interfaces it needs (e.g., UserRepository, MailService). The infrastructure layer implements those interfaces. Now the direction of dependency flows toward the business layer, not away from it.
Think of an electrical socket: your appliance (high-level) defines the plug shape it needs. Any power source (low-level) that fits the standard is usable. The appliance doesn't care if it's grid power, a generator, or a battery bank — it just uses the socket contract.
Changing the DB changes business logic
Swap DB without touching business logic
These are often confused but are distinct concepts. DIP is a principle — a design goal about the direction of dependencies. Dependency Injection (DI) is a pattern/technique that helps you achieve DIP by passing dependencies in from the outside (constructor injection, method injection) rather than creating them internally. DI containers (Spring, Angular DI, Python's dependency-injector) automate DI but are not required — manual DI (passing dependencies via the constructor) fully satisfies DIP.
// ❌ VIOLATION — High-level module hardwired to low-level details
public class OrderService {
// High-level module directly instantiates low-level modules
private MySQLOrderRepository repository = new MySQLOrderRepository();
private GmailNotificationService emailer = new GmailNotificationService();
private FileAuditLogger logger = new FileAuditLogger();
public Order placeOrder(Cart cart) {
Order order = Order.from(cart);
repository.save(order); // ← tightly coupled to MySQL
emailer.sendConfirmation(order); // ← tightly coupled to Gmail
logger.log("Order placed: " + order.getId()); // ← coupled to file logger
return order;
}
}
// To switch from MySQL → PostgreSQL:
// 1. Find every 'new MySQLOrderRepository()' across the codebase
// 2. Replace each one — risky, error-prone
// 3. Recompile and hope nothing breaks
// OrderService knows too much about infrastructure details.Spring's entire raison d'être is DIP. You declare @Service and @Repository; Spring wires them at runtime. Your business beans depend on interfaces; Spring injects the right implementation.
Angular's DI system lets components declare which services they need via constructor typing. The injector provides the right implementation — easily swapped in tests via providers.
Mock works because well-designed Python services accept their dependencies. You inject a Mock object in place of a real DB or API client — business logic runs in complete isolation.
DDD's Repository pattern is DIP applied to data access. Domain entities depend on a Repository interface defined in the domain layer. Infrastructure layer implements it.
| Letter | Principle | Core Question to Ask |
|---|---|---|
| S | Single Responsibility | "How many reasons does this class have to change?" |
| O | Open / Closed | "Do I have to edit existing code to add this feature?" |
| L | Liskov Substitution | "Can every subtype silently replace its parent?" |
| I | Interface Segregation | "Does every implementor actually use every method?" |
| D | Dependency Inversion | "Does my business logic instantiate its own infrastructure?" |