SOLID is the acronym for five object-oriented design principles popularized by Robert C. Martin. Although they are usually presented as programming rules, their true value is architectural: when applied with good judgment, they produce systems whose components are independent, substitutable, and resistant to change. Each principle attacks a specific form of rigidity or fragility. In this lesson we will study all five, seeing in each case an example of a violation in Java, its correction, and, above all, what the principle implies when we raise it from the class level to the level of modules and services.
Contents
- What SOLID means and why it is architectural
- S — Single Responsibility Principle (SRP)
- O — Open/Closed Principle (OCP)
- L — Liskov Substitution Principle (LSP)
- I — Interface Segregation Principle (ISP)
- D — Dependency Inversion Principle (DIP)
- SOLID at the architecture level
- Common mistakes and tips
- Exercises
- Conclusion
- What SOLID means and why it is architectural
| Letter | Principle | Core idea |
|---|---|---|
| S | Single Responsibility | A class should have a single reason to change |
| O | Open/Closed | Open to extension, closed to modification |
| L | Liskov Substitution | Subtypes must be replaceable by their base types |
| I | Interface Segregation | Small, specific interfaces, not monolithic ones |
| D | Dependency Inversion | Depend on abstractions, not on concretions |
The common thread of all five is managing dependencies and reasons for change. At the class level they improve the code; at the architecture level they determine how the boundaries between modules are drawn and in which direction the dependencies point.
- S — Single Responsibility Principle (SRP)
A class should have a single reason to change.
"Reason to change" means a single actor or business concern responsible for requesting modifications. If a class serves several actors, changes for one may break what another needs.
// VIOLATION: three reasons to change in one class
public class Employee {
public double calculatePayroll() { /* HR logic */ return 0; }
public void save() { /* persistence logic (DB) */ }
public String generateHoursReport() { /* reporting logic */ return ""; }
}Explanation of the problem: calculatePayroll changes if the HR rules change; save changes if the database schema changes; generateHoursReport changes if the report format changes. Three different actors, a single class: any change risks breaking the others.
// CORRECTION: one responsibility per class
public class Employee {
private double baseSalary;
private double hoursWorked;
// only the employee's own data and rules
}
public class PayrollCalculator {
public double calculate(Employee e) { /* HR rules */ return 0; }
}
public class EmployeeRepository {
public void save(Employee e) { /* persistence */ }
}
public class HoursReportGenerator {
public String generate(Employee e) { /* reporting */ return ""; }
}Each class now has a single reason to change and a single business owner behind it.
- O — Open/Closed Principle (OCP)
Entities should be open to extension but closed to modification.
We should be able to add new behavior without touching code that is already tested and in production. The usual tool is polymorphism.
// VIOLATION: each new type forces the method to be modified
public class AreaCalculator {
public double calculate(Object shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius * c.radius;
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.width * r.height;
}
return 0;
}
}Explanation of the problem: adding a Triangle requires modifying calculate, recompiling, and retesting everything. The method grows without limit and concentrates the risk.
// CORRECTION: extension through new classes, without modifying existing ones
public interface Shape {
double area();
}
public class Circle implements Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
public double area() { return Math.PI * radius * radius; }
}
public class Rectangle implements Shape {
private final double width, height;
public Rectangle(double width, double height) { this.width = width; this.height = height; }
public double area() { return width * height; }
}
// To add a Triangle: a new class that implements Shape. Nothing else changes.
- L — Liskov Substitution Principle (LSP)
If S is a subtype of T, objects of type T must be replaceable by objects of type S without altering the correctness of the program.
Inheriting is not enough: the subclass must respect the contract of the superclass. The classic example is the rectangle-square.
// VIOLATION: Square breaks the contract of Rectangle
public class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
public void setWidth(int w) { this.width = w; this.height = w; }
public void setHeight(int h) { this.width = h; this.height = h; }
}Explanation of the problem: code that expects a Rectangle assumes that setting width=5 and height=4 makes the area 20. With a Square the area would be 16. The subclass violates the client's expectation; polymorphism becomes treacherous.
// CORRECTION: model what they share, do not force inheritance
public interface Shape {
int area();
}
public final class Rectangle implements Shape {
private final int width, height;
public Rectangle(int width, int height) { this.width = width; this.height = height; }
public int area() { return width * height; }
}
public final class Square implements Shape {
private final int side;
public Square(int side) { this.side = side; }
public int area() { return side * side; }
}Practical rules for not breaking LSP: do not strengthen preconditions, do not weaken postconditions, do not throw unexpected new exceptions, and do not change the meaning of the contract.
- I — Interface Segregation Principle (ISP)
No client should be forced to depend on methods it does not use.
"Fat" interfaces force implementations to carry irrelevant methods.
// VIOLATION: monolithic interface
public interface Worker {
void work();
void eat();
void collectSalary();
}
// A robot has to implement eat() with no meaning
public class Robot implements Worker {
public void work() { /* ok */ }
public void eat() { throw new UnsupportedOperationException(); }
public void collectSalary() { throw new UnsupportedOperationException(); }
}Explanation of the problem: Robot is forced to implement eat() and collectSalary(), which do not apply to it, producing empty methods or methods that throw exceptions (which, incidentally, also violates LSP).
// CORRECTION: small, focused interfaces
public interface Workable { void work(); }
public interface Feedable { void eat(); }
public interface Salaried { void collectSalary(); }
public class Human implements Workable, Feedable, Salaried {
public void work() {}
public void eat() {}
public void collectSalary() {}
}
public class Robot implements Workable {
public void work() {}
}Each class implements only what it truly needs.
- D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level ones; both should depend on abstractions. Abstractions should not depend on details.
This is the most deeply architectural principle: it inverts the natural direction of dependencies.
// VIOLATION: the business logic depends on a concrete detail
public class BillingService {
private final MySqlRepository repo = new MySqlRepository(); // concrete detail
public void bill(Invoice f) {
repo.save(f);
}
}Explanation of the problem: the high-level class (BillingService) depends directly on a low-level detail (MySqlRepository). Switching from MySQL to PostgreSQL, or testing without a database, forces you to touch the business logic.
// CORRECTION: both depend on an abstraction defined by the business
public interface InvoiceRepository { // abstraction (defined by the domain)
void save(Invoice f);
}
public class BillingService { // high level
private final InvoiceRepository repo;
public BillingService(InvoiceRepository repo) { this.repo = repo; }
public void bill(Invoice f) { repo.save(f); }
}
public class MySqlRepository implements InvoiceRepository { // low level (detail)
public void save(Invoice f) { /* JDBC */ }
}Explanation of the improvement: the InvoiceRepository interface conceptually belongs to the business layer. The MySQL detail implements it. This way the dependency arrow points toward the domain, not toward the infrastructure: this is the inversion.
- SOLID at the architecture level
When we move up from the class to the system, each principle takes on a broader reading:
| Principle | At the class level | At the architecture level |
|---|---|---|
| SRP | One reason to change per class | One service/module per business capability (context boundaries) |
| OCP | Polymorphism to extend | Plugins, extensions, and feature flags without redeploying the core |
| LSP | Substitutable subtypes | Interchangeable implementations of a contract (e.g., providers) |
| ISP | Small interfaces | Cohesive service APIs and contracts; avoid "fat" APIs |
| DIP | Dependency injection | Hexagonal/clean architecture: domain independent of infrastructure |
DIP is the foundation of clean and hexagonal architectures, where the domain defines ports (interfaces) and the infrastructure provides adapters. The following diagram illustrates it:
graph TD
UI[Web Adapter] --> P1[Inbound Port]
P1 --> D[Domain / Use Cases]
D --> P2[Outbound Port: InvoiceRepository]
DB[MySQL Adapter] -.implements.-> P2Explanation of the diagram: the domain sits at the center and points to no detail. The adapters (web, database) depend on the domain through ports. The dependency rule always points inward.
Common Mistakes and Tips
- Overapplying SRP to the point of creating an explosion of anemic microclasses. The criterion is "one reason to change," not "one line per class."
- Confusing OCP with prohibiting all change. You do modify to fix bugs; what you avoid is modifying to add predictable variants.
- Inheriting to reuse code instead of for a genuine "is-a" relationship: the main source of LSP violations. Prefer composition.
- Mechanically creating an interface per class (a misunderstood DIP). DIP calls for abstractions where there is variation or a boundary, not everywhere.
- Placing the interfaces (ports) in the infrastructure layer. For DIP to truly invert, the abstraction must belong to the side that consumes it (the domain).
- Tip: SOLID are guidelines, not laws of physics. The ultimate goal is maintainable code; if a principle leads you to more complexity without benefit, reconsider it.
Exercises
Exercise 1 (SRP). This class violates SRP. State the reasons for change and propose a separation:
public class UserManager {
public void register(User u) { /* validates and saves to DB */ }
public void sendWelcome(User u) { /* sends email */ }
}Exercise 2 (OCP). Refactor to comply with Open/Closed:
public class PriceCalculator {
public double price(String customerType, double base) {
if (customerType.equals("VIP")) return base * 0.8;
if (customerType.equals("NORMAL")) return base;
return base;
}
}Exercise 3 (DIP). Which principle does this code violate and how do you fix it?
public class ReportGenerator {
private final EpsonPrinter printer = new EpsonPrinter();
public void print(String r) { printer.print(r); }
}Solutions
Solution 1. There are two reasons to change: registration/persistence and email communication. We separate them:
public class RegistrationService {
private final UserRepository repo;
public RegistrationService(UserRepository repo) { this.repo = repo; }
public void register(User u) { /* validate */ repo.save(u); }
}
public class WelcomeService {
private final EmailGateway email;
public WelcomeService(EmailGateway email) { this.email = email; }
public void send(User u) { email.send(u.getEmail(), "Welcome"); }
}Solution 2. We model the customer type as a polymorphic strategy:
public interface PricePolicy {
double apply(double base);
}
public class VipPrice implements PricePolicy {
public double apply(double base) { return base * 0.8; }
}
public class NormalPrice implements PricePolicy {
public double apply(double base) { return base; }
}
public class PriceCalculator {
public double price(PricePolicy policy, double base) {
return policy.apply(base);
}
}A new customer type is a new class, without touching the calculator.
Solution 3. It violates DIP (and hinders testing): it depends on the concrete EpsonPrinter. Correction:
public interface Printer { void print(String r); }
public class ReportGenerator {
private final Printer printer;
public ReportGenerator(Printer printer) { this.printer = printer; }
public void print(String r) { printer.print(r); }
}
public class EpsonPrinter implements Printer {
public void print(String r) { /* ... */ }
}Conclusion
The SOLID principles are five concrete answers to the question of how to manage dependencies and reasons for change. SRP separates responsibilities, OCP allows extending without breaking, LSP guarantees safe polymorphism, ISP keeps contracts focused, and DIP inverts dependencies to protect the domain. Taken to the architectural plane, they underpin clean and hexagonal architectures. It is worth remembering that they are guidelines in service of maintainability, not ends in themselves. In the next lesson we will complement SOLID with another family of more pragmatic principles —DRY, KISS, and YAGNI— that regulate the balance between rigor and simplicity.
Application Architecture Course
Module 1: Fundamentals of Application Architecture
- What Is Application Architecture?
- The Role of the Software Architect
- Quality Attributes and Non-Functional Requirements
- Architectural Decisions and Trade-offs
- Architecture Documentation: Views and the C4 Model
Module 2: Design Principles and Tactics
- Coupling, Cohesion and Separation of Concerns
- SOLID Principles Applied to Architecture
- DRY, KISS, YAGNI and Other Design Principles
- Architectural Tactics for Quality Attributes
- Managing Technical Debt
Module 3: Architectural Styles and Patterns
- Monolithic Architecture
- Layered Architecture (N-Tier)
- Client-Server Architecture
- Hexagonal Architecture (Ports and Adapters)
- Clean and Onion Architecture
Module 4: Distributed Architectures and Microservices
- Introduction to Distributed Systems
- Microservices Architecture
- Service Decomposition and Bounded Contexts
- API Gateway, Service Discovery and Inter-Service Communication
- Resilience Patterns: Circuit Breaker, Retry and Bulkhead
- The CAP Theorem and Data Consistency
Module 5: Event-Driven Architectures and Messaging
- Fundamentals of Event-Driven Architecture
- Asynchronous Messaging: Queues and Brokers
- Event Patterns: Event Sourcing and CQRS
- Managing Distributed Transactions: The Saga Pattern
- Real-Time Data Streaming
Module 6: Domain-Driven Design (DDD)
- Core DDD Concepts
- Strategic Design: Bounded Contexts and Ubiquitous Language
- Tactical Design: Entities, Aggregates and Repositories
- Context Mapping
Module 7: Data and Persistence
- Persistence Strategies: SQL vs NoSQL
- Data Access Patterns: Repository, Unit of Work and DAO
- Database per Service and Distributed Data Management
- Caching and Invalidation Strategies
Module 8: Cloud Architecture and Deployment
- Cloud Computing Fundamentals (IaaS, PaaS, SaaS)
- Containers and Orchestration with Docker and Kubernetes
- Serverless Architecture
- Cloud-Native Design Patterns
- Infrastructure as Code (IaC)
Module 9: Quality, Security and Observability
- Scalability: Horizontal vs Vertical and Load Balancing
- High Availability and Fault Tolerance
- Security by Design and Authentication/Authorization
- Observability: Logging, Metrics and Tracing
- Performance and Load Testing
