Clear, practical guidance and code examples to help you design maintainable object-oriented systems.
Introduction
SOLID is an acronym that groups five design principles that help developers build maintainable, extensible, and testable object-oriented software. These principles were popularized by Robert C. Martin ("Uncle Bob") and are:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Below we explain each principle, show practical signs you might be violating it, and provide small code examples (Java-like pseudocode) and suggested remedies.
1. Single Responsibility Principle (SRP)
Rule: A class should have one, and only one, reason to change. In practice that means a single responsibility or job.
Why it matters
Combining unrelated responsibilities (e.g., business logic + persistence + formatting) makes classes hard to test and change. Changes in one concern ripple into others.
Bad example
// UserService handles business logic, persistence and email notifications — too many responsibilities
class UserService {
void register(User u) {
saveToDatabase(u); // persistence
sendWelcomeEmail(u); // notification
}
}
Refactor / Good example
// Separate concerns into focused classes
class UserRepository {
void save(User u) { /* persist user */ }
}
class EmailSender {
void sendWelcome(User u) { /* send email */ }
}
class UserService {
private final UserRepository repo;
private final EmailSender email;
void register(User u) {
repo.save(u); // responsibility: user business flow
email.sendWelcome(u); // responsibility: delegated to EmailSender
}
}
Signs of SRP violation: large classes, multiple groups of methods dealing with different concerns, many unrelated reasons for modification.
2. Open/Closed Principle (OCP)
Rule: Software entities (classes, modules, functions) should be open for extension, but closed for modification.
Why it matters
When new requirements arrive, you should be able to add new behavior without changing tested, working code. That reduces regression risk.
Bad example
// A method using switch/case based on type — requires modification for each new shape
double area(Shape s) {
if (s.type == CIRCLE) return Math.PI * s.radius * s.radius;
if (s.type == RECT) return s.width * s.height;
// adding TRIANGLE requires editing this method
}
Refactor / Good example
// Use polymorphism: add new Shape implementations without changing area calculation
interface Shape {
double area();
}
class Circle implements Shape { double area() { return Math.PI * r*r; } }
class Rectangle implements Shape { double area() { return w*h; } }
// No switch: adding new shape doesn't modify existing code
double totalArea(List shapes) {
double sum = 0;
for (Shape s : shapes) sum += s.area();
return sum;
}
3. Liskov Substitution Principle (LSP)
Rule: Subtypes must be substitutable for their base types without altering desirable program properties (correctness, task performed, etc.).
Why it matters
Violating LSP leads to surprises when using polymorphism: code that expects a base type breaks when given a subtype.
Bad example
// Rectangle with setWidth/setHeight; Square extends Rectangle but breaks behavior
class Rectangle {
void setWidth(int w) { this.w = w; }
void setHeight(int h) { this.h = h; }
}
class Square extends Rectangle {
void setWidth(int w) { super.setWidth(w); super.setHeight(w); }
void setHeight(int h) { super.setHeight(h); super.setWidth(h); }
}
// Client code expecting independent width and height will break when given a Square
Refactor / Good example
// Prefer composition or distinct abstractions rather than inheritance that implies incompatible contracts
interface Shape {
int area();
}
class Rectangle implements Shape { /* ... */ }
class Square implements Shape { /* ... */ }
// Client code uses Shape, and each implementation follows its own contract
4. Interface Segregation Principle (ISP)
Rule: Clients should not be forced to depend upon interfaces they do not use. Prefer many small, specific interfaces over one large, general-purpose interface.
Why it matters
Large interfaces create classes that must implement irrelevant methods. This leads to empty implementations or fragile code.
Bad example
// A fat interface with unrelated methods
interface Worker {
void work();
void eat();
void sleep();
}
class Robot implements Worker {
void eat() { throw new UnsupportedOperationException(); }
}
Refactor / Good example
// Split into focused interfaces
interface Workable { void work(); }
interface Eatable { void eat(); }
class Human implements Workable, Eatable { /* implements both */ }
class Robot implements Workable { /* only work */ }
5. Dependency Inversion Principle (DIP)
Rule: 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.
Why it matters
Tight coupling to concrete classes makes code hard to change and hard to test. Using abstractions (interfaces) decouples implementation and enables easier testing (mocking).
Bad example
// High-level class creates a low-level concrete dependency directly
class OrderProcessor {
private final SqlPaymentGateway gateway = new SqlPaymentGateway();
void process(Order o) { gateway.pay(o); }
}
Refactor / Good example
// Depend on an interface and inject the implementation
interface PaymentGateway { void pay(Order o); }
class SqlPaymentGateway implements PaymentGateway { void pay(Order o) { /* ... */ } }
class OrderProcessor {
private final PaymentGateway gateway;
OrderProcessor(PaymentGateway gateway) { this.gateway = gateway; }
void process(Order o) { gateway.pay(o); }
}
// At runtime you provide a concrete implementation; for tests you provide a mock implementation.
Putting it together — design pattern examples
SOLID principles often guide the selection and application of design patterns. A few examples:
- Strategy — supports OCP by letting you add algorithms without modifying clients.
- Adapter — helps follow DIP by depending on small abstractions rather than concrete third-party classes.
- Factory — centralizes object creation and helps adhere to SRP and DIP when used to provide abstractions.
Small example: Strategy + DIP
// Payment strategy interface
interface PaymentStrategy { void pay(Order o); }
class PayPalStrategy implements PaymentStrategy { void pay(Order o) { /* paypal */ } }
class StripeStrategy implements PaymentStrategy { void pay(Order o) { /* stripe */ } }
class Checkout {
private final PaymentStrategy strategy;
Checkout(PaymentStrategy s) { this.strategy = s; }
void complete(Order o) { strategy.pay(o); }
}
Adding a new payment provider doesn't change Checkout (OCP). Checkout depends on the abstraction PaymentStrategy (DIP).
Conclusion & further reading
SOLID is a pragmatic set of principles: use them as guiding rules, not dogma. They help reduce coupling and increase cohesion which improves maintainability and testability. When you apply SOLID, aim for small, comprehensible classes; prefer abstractions over concrete dependencies; and continually refactor when smells appear.
Next steps:
- Refactor a small module in your codebase focusing on SRP first.
- Introduce interfaces and dependency injection to reduce direct coupling.
- Write unit tests for behavior and use mock implementations to validate DIP.
Further reading: books and articles by Robert C. Martin, "Design Patterns" by the Gang of Four, and many practical refactoring guides.
0 Comments