SOLID principles
Learn the five SOLID principles by building a real order-processing system in Java, with before and after code for every principle.
Introduction
SOLID is a set of five principles for keeping object-oriented code understandable as it grows. Each principle highlights a different design pressure: too many reasons to change, rigid extension points, broken subtype contracts, overly broad interfaces, or dependencies that point at implementation details.
TL;DR / mental model
Treat SOLID as five design questions: does each class have a focused reason to change, can behavior be extended at the right boundary, do subtypes honour their contracts, are interfaces shaped around clients, and do high-level policies depend on abstractions? The principles are heuristics for choosing boundaries, not a scorecard or a requirement to add abstractions everywhere.
| Letter | Principle | The one-line version |
|---|---|---|
| S | Single Responsibility | One class, one job |
| O | Open/Closed | Extend, don't modify |
| L | Liskov Substitution | Subclasses must honour parent contracts |
| I | Interface Segregation | Thin interfaces beat fat ones |
| D | Dependency Inversion | Depend on abstractions, not concretions |
Problem and Context
The running example below is an order-processing system for an e-commerce platform. All five principles appear in the same codebase, so you can see how they work together rather than in isolation.
The goal is not to make every class pass an arbitrary checklist. Use the principles to make likely changes local, keep contracts honest, and make dependencies replaceable where that provides real value.
Participants and Structure
This diagram shows how the order-processing system applies all five SOLID principles. Notice that every arrow points toward an interface, never toward a concrete class.
S lives in the class boundaries: OrderService orchestrates, EmailNotificationService sends, InvoiceService generates. O lives in Discount: add SeasonalDiscount without editing PricingService. D lives in every dashed arrow pointing at an interface.
S: Single Responsibility Principle
Rule
A class should have one and only one reason to change.
If a single class handles saving orders, sending confirmation emails, and generating PDF invoices, it has three reasons to change. When the email provider switches, your order-saving logic goes in for a review. That's the smell.
Split along "reasons to change", not just along methods.
Here is what changes when you apply SRP:
// β
One job: orchestrate the order lifecycle.
// This class has exactly ONE reason to change: order business logic.
public class OrderService {
private final OrderRepository orders;
private final NotificationService notifications;
private final InvoiceService invoices;
public OrderService(OrderRepository orders,
NotificationService notifications,
InvoiceService invoices) {
this.orders = orders;
this.notifications = notifications;
this.invoices = invoices;
}
public void placeOrder(Order order) {
order.setStatus("confirmed");
orders.save(order);
notifications.notifyOrderPlaced(order);
invoices.generate(order);
}
public void cancelOrder(String orderId) {
Order order = orders.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId));
order.setStatus("cancelled");
orders.save(order);
notifications.notifyOrderCancelled(order);
}
}The bad version put all three behaviours in one OrderService. After the split, changing the PDF library touches only PdfInvoiceService, and an email provider migration touches only EmailNotificationService; OrderService does not need to change for those provider migrations.
The design rationale is: split OrderService into three classes because each has a different reason to change. Email template changes should not require retesting order logic.
O: Open/Closed Principle
Rule
Software entities should be open for extension but closed for modification.
If new discount types are expected and each one requires reopening PricingService to add another if branch, the design is drifting away from OCP. The fix is a strategy interface: define how a discount works, then add new types as new classes rather than new conditionals.
Here is what changes when you apply OCP:
// The abstraction. This never changes.
// New discount types implement this interface. PricingService never needs editing.
public interface Discount {
/** Returns the discounted total for the given order amount. */
double apply(double originalAmount);
/** Human-readable description shown on the invoice. */
String describe();
}The pattern here is called the Strategy pattern. OCP and Strategy often reinforce each other. A growing switch or if-else tree based on a "type" field is a useful signal to examine whether the variation belongs behind an extension point.
The design rationale is explicit: adding a new discount type means adding a class, not adding another branch to PricingService.
L: Liskov Substitution Principle
Rule
Subtypes must be substitutable for their base types without breaking the program's correctness.
LSP is the principle most developers violate accidentally. The classic example is Square extends Rectangle. It compiles. It runs. And then a test that was passing for rectangles silently fails for squares.
Here is what changes when you apply LSP correctly:
// Base class. Contract: any Member can place orders up to their credit limit.
public class Member {
private final String id;
private final String name;
protected double creditLimit;
public Member(String id, String name, double creditLimit) {
this.id = id;
this.name = name;
this.creditLimit = creditLimit;
}
public boolean canPlaceOrder(double amount) {
return amount <= getCreditLimit();
}
public double getCreditLimit() {
return creditLimit;
}
public String getId() { return id; }
public String getName() { return name; }
}The test for LSP: write a function that accepts the base type. It should work correctly when you pass a subtype without knowing the concrete class. If the subtype breaks anything, you have an LSP violation.
A useful LSP heuristic is: if an override changes what a method means rather than how it works, stop and rethink the hierarchy.
I: Interface Segregation Principle
Rule
Clients should not be forced to depend on interfaces they do not use.
Fat interfaces are contagious. One Repository with 10 methods means every class that needs read-only access must implement (or stub) the write methods too. Split by capability.
Here is what changes when you apply ISP:
import java.util.List;
import java.util.Optional;
// β
Full read+write contract for the repository layer.
// Split into OrderReader + OrderWriter if you need read-only views.
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomer(String customerId);
void delete(String id);
}The rule of thumb: if a class implementing your interface has to write throw new UnsupportedOperationException() for any method, you have an ISP violation. That method belongs in a different interface.
D: Dependency Inversion Principle
Rule
High-level modules should not depend on low-level modules. Both should depend on abstractions.
DIP is what makes all the other principles actionable. Without it, OrderService would directly instantiate new SqlOrderRepository() and your whole service is glued to one database. With it, you swap databases by swapping the concrete class at composition time.
Here is what changes when you apply DIP:
import java.util.List;
import java.util.Optional;
// Low-level module. Implements the abstraction (OrderRepository).
public class SqlOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
System.out.println("[SQL] Upsert order " + order.getId());
// db.query("INSERT INTO orders ... ON CONFLICT DO UPDATE ...")
}
@Override
public Optional<Order> findById(String id) {
System.out.println("[SQL] SELECT * FROM orders WHERE id = '" + id + "'");
return Optional.empty(); // placeholder
}
@Override
public List<Order> findByCustomer(String customerId) {
System.out.println("[SQL] SELECT * FROM orders WHERE customer_id = '" + customerId + "'");
return List.of();
}
@Override
public void delete(String id) {
System.out.println("[SQL] DELETE FROM orders WHERE id = '" + id + "'");
}
}The composition root pattern is key: one place wires the concrete implementations together. The rest of the application depends on interfaces, which makes the service easier to test and the database easier to swap without changing business logic.
How It Works, Step by Step
This sequence shows what happens when a client calls placeOrder(). Notice that no concrete class name appears in the interaction between OrderService and its dependencies. Every call targets an interface.
- Client calls
placeOrder()onOrderService. It has no idea which repository or notification service is wired underneath. OrderServicedelegates to interfaces. At runtime,OrderRepositorymight beSqlOrderRepositoryin production orInMemoryOrderRepositoryin tests. Same code path, different backends.- Each collaborator has one job. If the email provider changes, only
EmailNotificationServiceis touched.OrderServiceis never reopened.
This is DIP in action: the arrows in the sequence diagram point at interfaces, not concrete classes.
All five together
The same codebase demonstrates all five principles cooperating:
- S:
OrderService,EmailNotificationService,PdfInvoiceServiceeach have one job - O: New discount types (Discount implementations) can be added without editing the pricing policy
- L:
PremiumMemberpreserves theMembercontract - I:
NotificationService,InvoiceService,PaymentGatewayare each focused - D:
OrderServicedepends onOrderRepository, notSqlOrderRepository
The useful way to apply SOLID is to point to a concrete boundary in the design: OrderService receives an OrderRepository rather than creating a database client, so the high-level policy depends on an abstraction and can be tested with another implementation.
Real-World Examples
SOLID violations and the corresponding fixes appear in frameworks and libraries. These examples connect the principles to code you may already use.
- Spring Framework makes dependency injection common: beans can be wired through constructor parameters, often against application-owned interfaces. A test profile can select different implementations without changing business logic.
- Java's
java.util.Collectionsdemonstrates ISP. Instead of one giantCollectioninterface, the JDK separatesList,Set,Queue, andMap. A method that only needs iteration acceptsIterable, notList. - Java Streams API respects LSP. Every intermediate operation (
filter(),map(),sorted()) returns aStreamthat behaves identically to the input stream. You can chain them without worrying about contract violations. - Servlet Filters follow OCP. You extend request handling by adding new filters to the chain, not by modifying existing servlet code. Each filter has one job (authentication, logging, compression) and the chain is open for extension.
Trade-offs, Alternatives, and When Not to Use
Apply SOLID when:
- Several classes collaborate and changes have different causes or owners
- You anticipate change in specific dimensions (new payment providers, new notification channels)
- You need testability through dependency injection
- Boundaries will reduce coordination or make responsibilities clearer in a shared module
Skip or relax when:
- You are writing a prototype, CLI tool, or throwaway script
- The class has exactly one implementation and no foreseeable variation
- Applying the principle adds more indirection than the problem warrants (one-method interfaces wrapping one-line calls)
SOLID improves separation and testability, but it can also add interfaces, files, and indirection. A direct method, a small module, or a concrete dependency can be clearer when the code is local and stable. A useful heuristic is to split a class when its responsibilities change for independent reasons, introduce an abstraction when a dependency needs substitution, and split an interface when implementors cannot support part of its contract.
30-Second Explanation
SOLID is five complementary ways to choose object-oriented boundaries. SRP keeps responsibilities focused, OCP localizes extension, LSP protects substitutability, ISP keeps interfaces client-shaped, and DIP points high-level code at abstractions. Together they aim to make change safer and more local, but each adds value only when it addresses a real source of coupling.
5-Minute Explanation
Start with the order service. Separate order orchestration from notification and invoice generation (SRP), pass discount behavior through a Discount abstraction so new discounts can be added at the boundary (OCP), and ensure subclasses such as PremiumMember preserve the Member contract (LSP). Split broad contracts into focused capabilities (ISP), then inject repository, notification, and invoice implementations through interfaces at the composition root (DIP). The result is not βmore classesβ for its own sake; it is a design where each likely change has a smaller area of impact and each dependency has an explicit contract.
Common Mistakes and Misconceptions
-
Reciting definitions without applying them. A principle is useful when it explains a boundary: split
OrderServicefromNotificationServicebecause email template changes should not force a change to order logic. -
Confusing OCP with "never modify code." OCP means a chosen extension point can accept new behavior without changing the existing policy. Configuration changes, bug fixes, and refactors are still normal.
-
Over-applying ISP. Creating one interface per method leads to interface explosion. Group by role or capability, not by method count.
IOrderReaderwithfindById()andfindByCustomer()is one cohesive role, not two interfaces. -
Ignoring LSP until it breaks in production. The Square/Rectangle trap compiles and may pass basic tests. The problem is that the subtype changes the meaning of
setWidth, not just its implementation. -
Thinking DIP means "use interfaces everywhere." DIP is about the direction of dependency, not the existence of interfaces. If a dependency has one stable implementation and no testing or substitution need, an interface may add ceremony without value.
Test Your Understanding
Recap
- SRP: Split classes by reason to change, not by method count. If changing email templates forces redeployment of order logic, you have an SRP violation.
- OCP: When you see a growing
if-elseorswitchblock that changes every sprint, extract an interface and let new types be new classes. - LSP: If overriding a method changes what it means (not just how it works), the subtype is not substitutable. Rethink the hierarchy.
- ISP: If an implementor stubs methods with "not supported", the interface is too fat. Split by role or capability.
- DIP: Point your dependencies at interfaces, not concrete classes. The composition root is the one place that knows which concrete class to use.
- Together: SOLID principles reinforce each other. SRP creates small classes, OCP makes them extensible, DIP makes them testable, ISP keeps interfaces focused, and LSP keeps hierarchies honest.
- Use the principles as questions about boundaries and contracts. Point to the specific class or dependency affected and explain the trade-off it addresses.
Related Principles and Patterns
- Dependency Injection pattern - A practical way to apply DIP at the composition boundary.
- Strategy pattern - A common OCP technique for interchangeable algorithms or policies.
- Decorator pattern - Adds behavior around an abstraction without modifying the wrapped implementation.
- Specification pattern - Encapsulates reusable business rules and composes them across contexts.