SOLID Principles: Writing Maintainable Code
SOLID is an acronym for five design principles that make object-oriented code more maintainable, flexible, and understandable. Robert C. Martin (Uncle Bob) introduced them, and they have become a cornerstone of modern software design.
This guide explains each principle with practical examples in Java-like pseudocode.
Single Responsibility Principle (SRP)
A class should have only one reason to change.
When a class has multiple responsibilities, changes to one responsibility can break the others. The class becomes fragile, hard to understand, and difficult to test.
Violation
public class Invoice {
public void calculateTotal() { /* ... */ }
public void printInvoice() { /* ... */ }
public void saveToDatabase() { /* ... */ }
public void sendEmail() { /* ... */ }
---This class handles calculation, printing, persistence, and email — four different reasons to change.
Following SRP
public class Invoice {
public void calculateTotal() { /* ... */ }
---
public class InvoicePrinter {
public void print(Invoice invoice) { /* ... */ }
---
public class InvoiceRepository {
public void save(Invoice invoice) { /* ... */ }
---
public class InvoiceEmailService {
public void send(Invoice invoice) { /* ... */ }
---Each class has a single, well-defined responsibility. Changes to printing logic do not affect persistence logic.
Open-Closed Principle (OCP)
Classes should be open for extension but closed for modification.
You should be able to add new functionality without changing existing code. Achieve this through abstraction — interfaces, abstract classes, and polymorphism.
Violation
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.getWidth() * r.getHeight();
} else if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.getRadius() * c.getRadius();
}
return 0;
}
---Adding a new shape (e.g., Triangle) requires modifying AreaCalculator.
Following OCP
public interface Shape {
double calculateArea();
---
public class Rectangle implements Shape {
private double width, height;
public double calculateArea() { return width * height; }
---
public class Circle implements Shape {
private double radius;
public double calculateArea() { return Math.PI * radius * radius; }
---
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
---New shapes implement Shape without modifying AreaCalculator.
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types.
If a program uses a base class, it should work correctly with any of its subclasses without knowing which subclass it is using. Violations often involve overriding methods to do nothing, throw exceptions, or change expected behavior.
Violation
public class Rectangle {
private int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int getArea() { return width * height; }
---
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
super.setWidth(w);
super.setHeight(w);
}
@Override
public void setHeight(int h) {
super.setWidth(h);
super.setHeight(h);
}
---
// Client code
Rectangle rect = new Square();
rect.setWidth(5);
rect.setHeight(10);
// User expects width=5, height=10, area=50
// Actual: width=10, height=10, area=100The Square subclass violates the expected behavior of Rectangle. Square is not a good substitute for Rectangle — mathematically a square is a rectangle, but behaviorally the setter semantics differ.
Following LSP
Use a common abstraction that does not enforce incompatible contracts:
public interface Shape {
int getArea();
---
public class Rectangle implements Shape {
private int width, height;
public Rectangle(int w, int h) { this.width = w; this.height = h; }
public int getArea() { return width * height; }
---
public class Square implements Shape {
private int side;
public Square(int s) { this.side = s; }
public int getArea() { return side * side; }
---Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
Large, fat interfaces force implementing classes to provide methods they do not need. Split them into smaller, focused interfaces.
Violation
public interface Worker {
void work();
void eat();
void sleep();
---
public class HumanWorker implements Worker {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
---
public class RobotWorker implements Worker {
public void work() { /* ... */ }
public void eat() { throw new UnsupportedOperationException(); }
public void sleep() { throw new UnsupportedOperationException(); }
---Following ISP
public interface Workable {
void work();
---
public interface Eatable {
void eat();
---
public interface Sleepable {
void sleep();
---
public class HumanWorker implements Workable, Eatable, Sleepable {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
---
public class RobotWorker implements Workable {
public void work() { /* ... */ }
---Dependency Inversion Principle (DIP)
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.
Violation
public class EmailService {
public void sendEmail(String message) { /* SMTP logic */ }
---
public class NotificationService {
private EmailService emailService = new EmailService();
public void notify(String message) {
emailService.sendEmail(message);
}
---NotificationService (high-level) depends directly on EmailService (low-level). Swapping to SMS or push notifications requires changing NotificationService.
Following DIP
public interface MessageService {
void send(String message);
---
public class EmailService implements MessageService {
public void send(String message) { /* SMTP logic */ }
---
public class SmsService implements MessageService {
public void send(String message) { /* SMS logic */ }
---
public class NotificationService {
private MessageService messageService;
public NotificationService(MessageService service) {
this.messageService = service;
}
public void notify(String message) {
messageService.send(message);
}
---Dependencies are injected through the constructor. NotificationService depends on an abstraction (MessageService), not a concrete implementation.
Putting SOLID Together
SOLID principles work together. SRP keeps classes focused, OCP enables extension, LSP ensures substitutability, ISP prevents interface pollution, and DIP decouples layers. Code that follows SOLID is easier to test, extend, and refactor.
Conclusion
SOLID is not a checklist — it is a set of guidelines. Apply them pragmatically. A small project with one developer needs less rigor than a large codebase with a team of twenty. Start with SRP and DIP — they give the most benefit for the least effort — and adopt the rest as your codebase grows.
Applying SOLID in Practice
SOLID principles guide object-oriented design but apply broadly. Single Responsibility: a class should have one reason to change. Open-Closed: open for extension, closed for modification. Liskov Substitution: subtypes must be substitutable for their base types. Interface Segregation: keep interfaces focused and small. Dependency Inversion: depend on abstractions, not concretions. Violating any principle increases maintenance costs — SRP violations lead to large classes, LSP violations cause subtle bugs, and DIP violations make testing difficult.
Common SOLID Violations
God classes (too many responsibilities) violate SRP. Conditional logic based on type violates OCP — use polymorphism instead. Derived classes that throw NotImplementedException violate LSP. Fat interfaces with unused methods violate ISP. Creating concrete instances inside business logic violates DIP — inject dependencies instead.
Architecture Decision Records
Architecture Decision Records (ADRs) document significant architectural decisions and their rationale. Each ADR captures: the context (why the decision was needed), the decision (what was chosen), the alternatives considered (what was rejected and why), and the consequences (tradeoffs and implications). ADRs are stored in version control alongside the code, creating a historical record of design evolution. Benefits include: onboarding new team members faster, preventing repeated debates about past decisions, and providing rationale for future architects questioning why things are the way they are. Start an ADR repository with a lightweight template. Write ADRs for significant decisions that affect system architecture, technology choices, or design patterns. Review ADRs as a team before implementation.
Conway’s Law in Practice
Conway’s Law states that organizations design systems that mirror their communication structures. Teams that communicate frequently will produce tightly coupled components. Teams that communicate infrequently will produce loosely coupled components that communicate through well-defined interfaces. Use this law deliberately: align team boundaries with service boundaries (inverse Conway maneuver). If you want microservices, structure your teams around services. If you want a monolith, have one team. The organization chart and the architecture diagram should be congruent.
FAQ
What is the first step to learn this? Start by understanding the core principles and practicing with small, focused exercises before tackling complex projects.
Can I use this in production? Yes, these techniques are battle-tested in production environments. Always test thoroughly in your specific context.
Where can I find more resources? Official documentation, community forums, and the excellentwiki.com related articles provide comprehensive learning paths.
For a comprehensive overview, read our article on Api Design Principles.
For a comprehensive overview, read our article on Clean Code Guide.
Related Concepts and Further Reading
Understanding solid principles requires familiarity with several interconnected ideas and principles that together form a complete picture. Exploring these related concepts deepens your knowledge and provides context that makes the core material more meaningful and applicable. Each concept builds on the others, creating a web of understanding that supports deeper learning and practical application. Taking time to explore how these elements connect reveals patterns that accelerate comprehension and retention of new information.
The relationship between solid principles and adjacent fields is worth particular attention. Many of the most important insights emerge at the boundaries between disciplines, where ideas from different areas combine to create new approaches and solutions that neither field could produce alone. Exploring these connections pays dividends in both breadth and depth of understanding, revealing patterns and principles that might otherwise remain hidden from view. Cross-disciplinary knowledge is increasingly valued as problems become more complex and interconnected.
For those looking to go beyond introductory material, several excellent resources provide deeper treatment of specific aspects of solid principles. Academic journals, industry publications, authoritative reference works, and online courses each offer different perspectives and levels of detail. The key is to match your reading to your current learning goals and build knowledge progressively, focusing on quality over quantity in your study materials. A well-chosen resource that matches your current level is worth more than dozens of resources that are too basic or too advanced.