What are SOLID principles?
SOLID is a set of five design principles that help you write code that is easier to maintain, test, and extend. They were introduced by Robert C. Martin (Uncle Bob) in the early 2000s and have become a cornerstone of object-oriented design. The five principles are:
- <strong>S</strong> — Single Responsibility Principle
- <strong>O</strong> — Open/Closed Principle
- <strong>L</strong> — Liskov Substitution Principle
- <strong>I</strong> — Interface Segregation Principle
- <strong>D</strong> — Dependency Inversion Principle
These principles are not rules. They are guidelines. Violating them does not break your code immediately, but it creates design debt that makes future changes harder, testing more painful, and bugs more likely.
Why SOLID still matters
You might hear developers say "SOLID is outdated" or "it is just for enterprise Java". That is not true. Every time you:
- Change one class and accidentally break something unrelated
- Add a feature and have to modify ten files
- Try to write a unit test but cannot because the class creates its own dependencies
- Subclass something and get unexpected behaviour
...you are feeling the absence of SOLID principles. They are not academic. They address concrete problems that appear in real codebases of any size.
How this guide is structured
Each principle section contains:
- A clear definition and the problem it solves
- A Java example showing the violation (code that works but is fragile)
- A refactored example showing the fix
- A deeper look at nuances and tradeoffs
- When to apply it and when to relax it
At the end, there is a complete <strong>client project walkthrough</strong> — a Spring Boot e-commerce order processing system — that demonstrates all five principles working together in production-style code. ---
S — Single Responsibility Principle (SRP)
Definition
> A class should have only one reason to change. This is Robert C. Martin's definition. "Reason to change" means a stakeholder (a user, a business owner, a database administrator) who would request a change. If two different stakeholders could request changes to the same class for different reasons, that class has multiple responsibilities. In practice: each class should do one thing and do it well. If you can describe what a class does using the word "and", it probably violates SRP.
Why developers violate SRP
The most common reason: it feels faster. When you are building a feature, it is tempting to put everything in one class — data, validation, persistence, formatting — because it works and you can see all the logic in one place. The problem shows up later when that class needs to change for multiple reasons and every change risks breaking something unrelated.
Violation example
Imagine a class that handles both invoice data and file persistence:
public class Invoice {
private double amount;
private String customerEmail;
public Invoice(double amount, String customerEmail) {
this.amount = amount;
this.customerEmail = customerEmail;
}
public double calculateTotal() {
return amount * 1.18; // with tax
}
// This does not belong here
public void saveToFile() {
try (FileWriter writer = new FileWriter("invoice_" + customerEmail + ".txt")) {
writer.write("Amount: " + amount);
} catch (IOException e) {
e.printStackTrace();
}
}
}Why this violates SRP:
- The <code>Invoice</code> class is responsible for <strong>business logic</strong> (calculating total) <strong>and</strong> <strong>persistence</strong> (saving to file).
- If the file format changes from <code>.txt</code> to <code>.json</code>, you must modify <code>Invoice</code> even though the business logic did not change.
- If tax calculation changes from 18% to 20%, you must modify <code>Invoice</code> even though persistence logic did not change.
- Two completely different reasons to change the same class.
- You cannot reuse <code>Invoice</code> in a context that does not involve files (e.g., saving to a database).
Progressive refactoring
<strong>Step 1 — Extract persistence:</strong>
// Invoice only handles business data and logic
public class Invoice {
private double amount;
private String customerEmail;
public Invoice(double amount, String customerEmail) {
this.amount = amount;
this.customerEmail = customerEmail;
}
public double calculateTotal() {
return amount * 1.18;
}
public double getAmount() { return amount; }
public String getCustomerEmail() { return customerEmail; }
}
// Persistence is a separate responsibility
public class InvoiceRepository {
public void saveToFile(Invoice invoice) {
try (FileWriter writer = new FileWriter(
"invoice_" + invoice.getCustomerEmail() + ".txt")) {
writer.write("Amount: " + invoice.calculateTotal());
} catch (IOException e) {
e.printStackTrace();
}
}
}Now:
- <code>Invoice</code> changes only when business rules change.
- <code>InvoiceRepository</code> changes only when persistence logic changes.
- You can test each class independently.
- You can swap persistence strategy without touching <code>Invoice</code>.
<strong>Step 2 — Further SRP refinement (optional but cleaner):</strong> You could split <code>Invoice</code> further if the class grows — for example, separate <code>InvoiceCalculator</code> for tax logic and <code>Invoice</code> as a pure data class. SRP is a spectrum, not a binary.
Testing before and after
Before SRP:
// To test saveToFile, you must create an Invoice.
// But Invoice also has business logic, so you are testing both at once.
public class InvoiceTest {
@Test
void testSaveToFile() {
Invoice invoice = new Invoice(100, "test@test.com");
invoice.saveToFile();
// Did it work? You have to check the file system.
// What if calculateTotal() has a bug? This test breaks too.
}
}After SRP:
public class InvoiceRepositoryTest {
@Test
void testSaveToFile() {
InvoiceRepository repo = new InvoiceRepository();
Invoice invoice = new Invoice(100, "test@test.com");
repo.saveToFile(invoice);
// Only tests persistence. Business logic is tested separately.
}
}
public class InvoiceTest {
@Test
void testCalculateTotal() {
Invoice invoice = new Invoice(100, "test@test.com");
assertEquals(118.0, invoice.calculateTotal(), 0.01);
}
}How to detect SRP violations in your code
| Symptom | Likely cause |
|---|---|
| A class has more than ~200 lines | Multiple responsibilities crammed together |
| A class has fields that are only used by some methods | Not all methods belong to the same concern |
| You use the word "and" describing the class | "This class handles orders and sends emails" |
| A single change touches multiple unrelated methods | The class does too many things |
| You cannot easily mock part of the class in tests | Responsibilities are tightly coupled |
When SRP matters most
SRP is most useful when your classes have more than ~200 lines or you frequently change the same file for unrelated reasons. For very small utility classes (getters/setters, simple DTOs), strict SRP can lead to unnecessary indirection. Use judgment. ---
O — Open/Closed Principle (OCP)
Definition
> A class should be open for extension but closed for modification. "Open for extension" means you can add new behaviour to the system. "Closed for modification" means you should not have to change existing, tested code to add that behaviour. The usual technique is <strong>polymorphism</strong>: define an interface or abstract class, then add new implementations instead of modifying existing ones.
Why developers violate OCP
The most common reason: if-else chains are the easiest thing to write. When you need to handle a new case, adding another <code>else if</code> takes five seconds. The problem is that every time you do this, the method grows, the class accumulates responsibility, and eventually a change in one branch accidentally breaks another.
Violation example
Imagine a payment processor that uses if-else to handle different payment types:
public class PaymentProcessor {
public void process(String paymentType, double amount) {
if (paymentType.equals("CREDIT_CARD")) {
// connect to credit card gateway
System.out.println("Processing credit card payment of " + amount);
} else if (paymentType.equals("PAYPAL")) {
// connect to PayPal API
System.out.println("Processing PayPal payment of " + amount);
} else if (paymentType.equals("BANK_TRANSFER")) {
// connect to bank API
System.out.println("Processing bank transfer of " + amount);
}
}
}Why this violates OCP:
- Every time you add a new payment method (e.g., <code>"CRYPTO"</code>), you must modify <code>PaymentProcessor</code>.
- The class is never truly "closed" for modification.
- The method grows longer with every new payment type.
- Testing becomes harder because one method handles many behaviours.
- Different payment methods cannot be tested in isolation.
OCP-compliant version
// Define a closed interface
public interface PaymentMethod {
void pay(double amount);
}
// Implementations are open for extension
public class CreditCardPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing credit card payment of " + amount);
}
}
public class PayPalPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing PayPal payment of " + amount);
}
}
public class BankTransferPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing bank transfer of " + amount);
}
}
// Processor is now closed for modification
public class PaymentProcessor {
public void process(PaymentMethod paymentMethod, double amount) {
paymentMethod.pay(amount);
}
}Now:
- Adding a new payment type means creating a new class, not modifying existing code.
- <code>PaymentProcessor</code> never changes.
- Each payment implementation is isolated and independently testable.
- You can add <code>CryptoPayment</code> without touching a single existing file.
Factory pattern to bridge OCP and client code
Clients still need to decide which <code>PaymentMethod</code> to use. You can use a factory that itself is OCP-compliant:
public class PaymentMethodFactory {
private final Map<String, PaymentMethod> methods = new HashMap<>();
public PaymentMethodFactory() {
// Register default methods
register("CREDIT_CARD", new CreditCardPayment());
register("PAYPAL", new PayPalPayment());
}
public void register(String type, PaymentMethod method) {
methods.put(type.toUpperCase(), method);
}
public PaymentMethod get(String type) {
PaymentMethod method = methods.get(type.toUpperCase());
if (method == null) {
throw new IllegalArgumentException("Unknown payment type: " + type);
}
return method;
}
}
// Usage — adding a new method does not change the factory class itself
PaymentMethodFactory factory = new PaymentMethodFactory();
factory.register("CRYPTO", new CryptoPayment());
PaymentProcessor processor = new PaymentProcessor();
processor.process(factory.get("CRYPTO"), 100.0);When OCP matters most
OCP is most valuable when your system has frequent feature additions or when multiple developers work on different extensions simultaneously. For very stable code that rarely changes, the abstraction overhead may not be worth it. A good rule: if a switch statement or if-else chain has more than three branches, consider OCP. ---
L — Liskov Substitution Principle (LSP)
Definition
> Objects of a superclass should be replaceable with objects of its subclasses without breaking the program. This principle was introduced by Barbara Liskov in 1987 and is the most technical of the five. It is about <strong>behavioural subtyping</strong> — not just type compatibility, but correct behaviour. In practice: if a method works with a base type, it should also work with any derived type. If you override a method to throw an exception, do nothing, or change the expected behaviour, you are probably violating LSP.
The formal definition
Barbara Liskov's original definition: > If for each object <code>o1</code> of type <code>S</code> there is an object <code>o2</code> of type <code>T</code> such that for all programs <code>P</code> defined in terms of <code>T</code>, the behaviour of <code>P</code> is unchanged when <code>o1</code> is substituted for <code>o2</code>, then <code>S</code> is a subtype of <code>T</code>. In plain language: if you have a function that works with a base class, passing any subclass should still work correctly. The subclass must <strong>honour the contract</strong> of the base class.
Design by contract
LSP is closely related to the concept of <strong>design by contract</strong> (introduced by Bertrand Meyer). Every class has:
- <strong>Preconditions</strong>: what must be true before a method is called
- <strong>Postconditions</strong>: what will be true after a method returns
- <strong>Invariants</strong>: what is always true about the class
LSP says:
- A subclass can <strong>weaken</strong> preconditions (accept more inputs than the base)
- A subclass can <strong>strengthen</strong> postconditions (guarantee more than the base)
- A subclass must <strong>preserve</strong> invariants
Violations happen when subclasses do the opposite.
Violation example
The classic Rectangle-Square problem:
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // maintain square invariant
}
@Override
public void setHeight(int height) {
this.height = height;
this.width = height; // maintain square invariant
}
}Now consider a method that works with <code>Rectangle</code>:
public void resize(Rectangle rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(10);
// This assertion passes for Rectangle, but fails for Square
assert rectangle.getArea() == 50 :
"Expected area 50, got " + rectangle.getArea();
}When you pass a <code>Square</code> into <code>resize()</code>:
- <code>setWidth(5)</code> sets both width and height to 5.
- <code>setHeight(10)</code> sets both width and height to 10.
- Area becomes 100 instead of 50.
The <code>Square</code> subclass changes the behaviour of <code>setWidth</code> and <code>setHeight</code> in a way that breaks callers that rely on the base class contract.
Why this happens
The problem is that <code>Square</code> strengthens a postcondition: after <code>setWidth(w)</code>, <code>Rectangle</code> guarantees <code>width == w</code>. <code>Square</code> adds <code>height == w</code> as well. Callers of <code>Rectangle</code> do not expect this, so their assumptions break.
LSP-compliant design
The fix is to <strong>not</strong> model Square as a subclass of Rectangle. Instead, use a common interface or separate classes:
public interface Shape {
int getArea();
}
public class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}Now:
- <code>Rectangle</code> and <code>Square</code> are unrelated in the type hierarchy.
- Each class manages its own invariant without surprising behaviour.
- Any method that works with <code>Shape</code> works correctly with both.
A more realistic LSP violation
Consider a <code>PersistentRepository</code> base class:
public abstract class Repository {
public abstract void save(Object entity);
public abstract Object findById(long id);
}
public class InMemoryRepository extends Repository {
private final Map<Long, Object> store = new HashMap<>();
@Override
public void save(Object entity) {
// In-memory save always succeeds
store.put(System.currentTimeMillis(), entity);
}
@Override
public Object findById(long id) {
return store.get(id); // Returns null if not found
}
}
public class DatabaseRepository extends Repository {
private final DataSource dataSource;
public DatabaseRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void save(Object entity) {
// This throws SQLException if connection fails
throw new RuntimeException("Database is down!");
}
@Override
public Object findById(long id) {
// This also throws if connection fails
throw new RuntimeException("Database is down!");
}
}The LSP violation: <code>InMemoryRepository.findById()</code> returns <code>null</code> when not found, but <code>DatabaseRepository.findById()</code> throws an exception. A caller written for <code>InMemoryRepository</code> will get an unexpected exception when switched to <code>DatabaseRepository</code>. The fix: agree on a contract — either both return <code>null</code> and let callers handle it, or both throw a checked exception, or both return <code>Optional.empty()</code>.
Common LSP violations in real code
| Violation | Example |
|---|---|
| Overridden method throws <code>UnsupportedOperationException</code> | <code>Collections.unmodifiableList()</code> throws on mutation |
| Overridden method does nothing | An empty <code>save()</code> override that silently ignores data |
| Strengthening preconditions | A subclass that requires non-null where base allows null |
| Weakening postconditions | A subclass that returns null where base guarantees non-null |
| Changing return type semantics | Base returns <code>Optional</code>, subclass returns <code>null</code> |
| Side effects in overridden methods | Base's <code>getTotal()</code> is read-only, subclass modifies state |
How to detect LSP violations
Look for these patterns:
- <code>instanceof</code> checks before calling a method — "if it is a Square, do this, otherwise do that"
- Overridden methods that throw <code>UnsupportedOperationException</code>
- Overridden methods that are empty (no-op)
- Documentation that says "this method does nothing in this subclass"
These all indicate that the subclass cannot fully substitute the base class.
When LSP matters most
LSP is critical when you use polymorphism heavily, especially with:
- Collections and streams
- Dependency injection (Spring injects implementations that must be interchangeable)
- Framework callbacks (JPA entity listeners, servlet filters)
- Library design (your users depend on your base classes)
For inheritance hierarchies with only one subclass, violations are less likely to surface, but they still represent technical debt. ---
I — Interface Segregation Principle (ISP)
Definition
> No client should be forced to depend on methods it does not use. In practice: large, "fat" interfaces should be split into smaller, more specific ones. A class should not have to implement methods it does not need.
Why developers violate ISP
The usual cause is "one interface to rule them all". It feels neat to have a single <code>Worker</code> interface or a single <code>DatabaseOperations</code> interface. The problem becomes visible when:
- You add a new method to the interface and suddenly every implementation breaks
- Some implementations throw <code>UnsupportedOperationException</code> for methods they do not support
- Clients that only need a small subset of the interface must depend on the entire thing
Violation example
A single <code>Worker</code> interface that covers everything:
public interface Worker {
void work();
void eat();
void sleep();
}
public class HumanWorker implements Worker {
@Override
public void work() { System.out.println("Human working"); }
@Override
public void eat() { System.out.println("Human eating"); }
@Override
public void sleep() { System.out.println("Human sleeping"); }
}
public class RobotWorker implements Worker {
@Override
public void work() { System.out.println("Robot working"); }
// Robots do not eat!
@Override
public void eat() {
throw new UnsupportedOperationException("Robots do not eat");
}
// Robots do not sleep!
@Override
public void sleep() {
throw new UnsupportedOperationException("Robots do not sleep");
}
}Why this violates ISP:
- <code>RobotWorker</code> is forced to implement <code>eat()</code> and <code>sleep()</code> that it does not need.
- Callers of <code>Worker</code> cannot safely call <code>eat()</code> or <code>sleep()</code> without checking the type first.
- Empty or throwing implementations are a code smell that the interface is too broad.
ISP-compliant version
// Segregated interfaces — each with a single, focused responsibility
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
// Human implements all three
public class HumanWorker implements Workable, Eatable, Sleepable {
@Override
public void work() { System.out.println("Human working"); }
@Override
public void eat() { System.out.println("Human eating"); }
@Override
public void sleep() { System.out.println("Human sleeping"); }
}
// Robot implements only what it needs
public class RobotWorker implements Workable {
@Override
public void work() { System.out.println("Robot working"); }
}Now:
- <code>RobotWorker</code> only implements what it actually needs.
- Callers can depend on the specific interface they care about.
- No more <code>UnsupportedOperationException</code> in polymorphic code.
- Adding a new capability (e.g., <code>Rechargeable</code> for robots) does not affect existing implementors.
A more realistic example: data access
Before ISP:
public interface UserRepository {
User findById(long id);
List<User> findAll();
void save(User user);
void delete(long id);
void exportToCsv(String filePath);
byte[] generateReport();
}The problem: a read-only service that only needs <code>findById</code> and <code>findAll</code> must still depend on <code>save</code>, <code>delete</code>, <code>exportToCsv</code>, and <code>generateReport</code>. If any of those methods change, the read-only service must be recompiled. After ISP:
public interface UserReader {
User findById(long id);
List<User> findAll();
}
public interface UserWriter {
void save(User user);
void delete(long id);
}
public interface UserReporter {
void exportToCsv(String filePath);
byte[] generateReport();
}
// Full implementation
public class DatabaseUserRepository implements UserReader, UserWriter, UserReporter {
// implements all methods
}
// Read-only service only depends on UserReader
public class UserQueryService {
private final UserReader userReader;
public UserQueryService(UserReader userReader) {
this.userReader = userReader;
}
public User getUser(long id) {
return userReader.findById(id);
}
}When ISP matters most
ISP is most useful when interfaces are consumed by many diverse clients. If an interface has only one implementation, splitting it rarely adds value. The sweet spot is when different clients use different subsets of an interface's methods. ---
D — Dependency Inversion Principle (DIP)
Definition
> 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. In practice: depend on interfaces or abstract classes, not on concrete implementations. This is usually achieved through <strong>dependency injection</strong> — passing dependencies into a class rather than having the class create them.
Why DIP is often confused with dependency injection
DIP is the principle. Dependency injection (DI) is the technique that implements it. Inversion of Control (IoC) containers like the Spring container automate DI so you do not have to wire everything manually.
Why developers violate DIP
The most common reason: <code>new</code> keyword is the simplest way to get a dependency. It works immediately, no configuration needed. The problem appears when:
- You need to switch implementations (e.g., from <code>EmailSender</code> to <code>SmsSender</code>)
- You want to write a unit test but the class creates real database connections
- The dependency chain becomes deep — class A creates B, B creates C, C creates D
Violation example
A notification service that directly instantiates its dependencies:
public class EmailSender {
public void send(String message) {
System.out.println("Sending email: " + message);
}
}
// High-level module depends directly on a low-level module
public class NotificationService {
private EmailSender emailSender;
public NotificationService() {
// Direct instantiation — creates a hard dependency
this.emailSender = new EmailSender();
}
public void notify(String message) {
emailSender.send(message);
}
}Why this violates DIP:
- <code>NotificationService</code> (high-level) depends directly on <code>EmailSender</code> (low-level).
- To switch from email to SMS, you must modify <code>NotificationService</code>.
- Testing <code>NotificationService</code> is harder because you cannot mock <code>EmailSender</code>.
- <code>NotificationService</code> is coupled to the constructor of <code>EmailSender</code> — if <code>EmailSender</code> starts requiring configuration, <code>NotificationService</code> must change.
DIP-compliant version
// Abstraction (interface)
public interface MessageSender {
void send(String message);
}
// Low-level detail 1
public class EmailSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending email: " + message);
}
}
// Low-level detail 2
public class SmsSender implements MessageSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// High-level module depends on abstraction
public class NotificationService {
private final MessageSender messageSender;
// Dependency is injected — NotificationService does not create it
public NotificationService(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void notify(String message) {
messageSender.send(message);
}
}Now:
- <code>NotificationService</code> depends on the <code>MessageSender</code> interface, not on a concrete class.
- You can switch between <code>EmailSender</code>, <code>SmsSender</code>, or any future sender without changing <code>NotificationService</code>.
- Testing is easy: inject a mock <code>MessageSender</code>.
Testing before and after
Before DIP:
// Cannot unit test NotificationService in isolation.
// It will always use the real EmailSender.
public class NotificationServiceTest {
@Test
void testNotify() {
NotificationService service = new NotificationService();
service.notify("Hello");
// How do you verify? You have to check console output or a real email queue.
}
}After DIP:
// Can inject a mock or stub
public class NotificationServiceTest {
@Test
void testNotify() {
MessageSender mockSender = mock(MessageSender.class);
NotificationService service = new NotificationService(mockSender);
service.notify("Hello");
verify(mockSender).send("Hello");
}
}DIP with Spring
In Spring Boot, DIP is often implemented through constructor injection:
@Service
public class NotificationService {
private final MessageSender messageSender;
// Spring automatically injects the correct bean
public NotificationService(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void notify(String message) {
messageSender.send(message);
}
}The concrete <code>MessageSender</code> is wired in configuration:
@Configuration
public class AppConfig {
@Bean
@Profile("default")
public MessageSender emailSender() {
return new EmailSender();
}
@Bean
@Profile("production")
public MessageSender smsSender() {
return new SmsSender();
}
}Different injection patterns
Spring supports three injection patterns. Constructor injection (preferred):
@Service
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway gateway;
public OrderService(OrderRepository repository, PaymentGateway gateway) {
this.repository = repository;
this.gateway = gateway;
}
}Setter injection (optional dependencies):
@Service
public class ReportService {
private MetricsCollector metrics;
@Autowired(required = false)
public void setMetrics(MetricsCollector metrics) {
this.metrics = metrics;
}
}Field injection (not recommended — hard to test):
@Service
public class UserService {
@Autowired
private UserRepository repository; // Avoid this
}When DIP matters most
DIP is most important in modules that change frequently or need to be tested in isolation. For stable, leaf-level utility classes (like <code>StringUtils</code>, <code>MathHelper</code>), direct dependencies are usually fine. For business logic and infrastructure code, DIP almost always pays off. ---
Client project: E-commerce Order Processing with SOLID (Spring Boot)
The best way to understand SOLID is to see all five principles working together in a realistic project. Below is a simplified <strong>e-commerce order processing</strong> system built with Spring Boot.
Project overview
The system handles:
- Placing an order (validates stock, calculates total, processes payment)
- Notifying the customer (email or SMS)
- Generating an invoice
- Logging the order for analytics
Domain model
public class Order {
private Long id;
private String customerEmail;
private List<OrderItem> items;
private double totalAmount;
private OrderStatus status;
// constructor, getters
}
public class OrderItem {
private String productCode;
private int quantity;
private double price;
}
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, CANCELLED
}S — Single Responsibility
Each class has one clear responsibility:
// OrderCalculator: only calculates order totals and taxes
@Component
public class OrderCalculator {
private static final double TAX_RATE = 0.18;
public double calculateTotal(List<OrderItem> items) {
double subtotal = items.stream()
.mapToDouble(item -> item.getPrice() * item.getQuantity())
.sum();
return subtotal * (1 + TAX_RATE);
}
}
// OrderValidator: only validates order rules
@Component
public class OrderValidator {
public void validate(Order order) {
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
if (order.getCustomerEmail() == null || !order.getCustomerEmail().contains("@")) {
throw new IllegalArgumentException("Invalid customer email");
}
}
}
// OrderRepository: only handles persistence
@Component
public class OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);
public Order save(Order order) {
if (order.getId() == null) {
// Reflection-based ID assignment (simplified)
// In real code, this would be JPA or JDBC
}
store.put(order.getId(), order);
return order;
}
}Notice: <code>OrderCalculator</code>, <code>OrderValidator</code>, and <code>OrderRepository</code> each have exactly one reason to change.
O — Open/Closed
Payment methods are defined via an interface so new methods can be added without modifying the order service:
public interface PaymentGateway {
PaymentResult charge(Order order, PaymentDetails details);
}
@Component
public class CreditCardGateway implements PaymentGateway {
@Override
public PaymentResult charge(Order order, PaymentDetails details) {
// Connect to Stripe / Razorpay / etc.
System.out.println("Charging credit card for order " + order.getId());
return new PaymentResult(true, "txn_credit_" + order.getId());
}
}
@Component
public class UpiGateway implements PaymentGateway {
@Override
public PaymentResult charge(Order order, PaymentDetails details) {
// Connect to UPI gateway
System.out.println("Processing UPI payment for order " + order.getId());
return new PaymentResult(true, "txn_upi_" + order.getId());
}
}Adding <code>CryptoGateway</code> or <code>NetBankingGateway</code> later requires zero changes to existing code.
L — Liskov Substitution
Notification senders must be interchangeable. Any <code>Notifier</code> implementation should work without breaking the caller:
public interface Notifier {
void send(String recipient, String message);
}
@Component
public class EmailNotifier implements Notifier {
@Override
public void send(String recipient, String message) {
// Send email via SMTP
System.out.println("Email to " + recipient + ": " + message);
}
}
@Component
public class SmsNotifier implements Notifier {
@Override
public void send(String recipient, String message) {
// Send SMS via Twilio
System.out.println("SMS to " + recipient + ": " + message);
}
}Both implementations:
- Accept the same parameter types
- Do not throw unexpected exceptions
- Do not have side effects beyond sending the notification
- Can be substituted without changing the caller
I — Interface Segregation
Instead of a single <code>OrderService</code> interface with every possible operation, the system defines focused interfaces:
// Client A: Checkout service only needs to place orders
public interface OrderPlacement {
Order placeOrder(List<OrderItem> items, String customerEmail, PaymentDetails payment);
}
// Client B: Admin service only needs to manage order lifecycle
public interface OrderManagement {
void cancelOrder(Long orderId);
void shipOrder(Long orderId);
Order getOrderStatus(Long orderId);
}
// Client C: Reporting only needs read access
public interface OrderReporting {
List<Order> getOrdersBetween(LocalDate start, LocalDate end);
double getRevenueForPeriod(LocalDate start, LocalDate end);
}The concrete implementation implements all three, but clients depend only on the interface they need:
@Service
public class OrderServiceImpl implements OrderPlacement, OrderManagement, OrderReporting {
// All implementations here
}
// Checkout controller only depends on OrderPlacement
@RestController
public class CheckoutController {
private final OrderPlacement orderPlacement;
public CheckoutController(OrderPlacement orderPlacement) {
this.orderPlacement = orderPlacement;
}
@PostMapping("/checkout")
public Order checkout(@RequestBody CheckoutRequest request) {
return orderPlacement.placeOrder(
request.getItems(),
request.getCustomerEmail(),
request.getPayment()
);
}
}D — Dependency Inversion
The central <code>OrderProcessingService</code> depends on abstractions, not concretions:
@Service
public class OrderProcessingService {
private final OrderValidator validator;
private final OrderCalculator calculator;
private final OrderRepository repository;
private final PaymentGateway paymentGateway;
private final Notifier notifier;
private final InvoiceGenerator invoiceGenerator;
// All dependencies are injected via constructor
public OrderProcessingService(
OrderValidator validator,
OrderCalculator calculator,
OrderRepository repository,
PaymentGateway paymentGateway,
Notifier notifier,
InvoiceGenerator invoiceGenerator) {
this.validator = validator;
this.calculator = calculator;
this.repository = repository;
this.paymentGateway = paymentGateway;
this.notifier = notifier;
this.invoiceGenerator = invoiceGenerator;
}
public Order placeOrder(List<OrderItem> items, String customerEmail, PaymentDetails payment) {
// Step 1: Calculate total
double total = calculator.calculateTotal(items);
// Step 2: Create order
Order order = new Order(customerEmail, items, total, OrderStatus.PENDING);
// Step 3: Validate
validator.validate(order);
// Step 4: Process payment
PaymentResult result = paymentGateway.charge(order, payment);
if (!result.isSuccess()) {
throw new PaymentFailedException("Payment declined");
}
// Step 5: Save order
order.setStatus(OrderStatus.CONFIRMED);
Order savedOrder = repository.save(order);
// Step 6: Send notification
notifier.send(customerEmail, "Order " + savedOrder.getId() + " confirmed!");
// Step 7: Generate invoice (async, non-blocking)
invoiceGenerator.generateAsync(savedOrder);
return savedOrder;
}
}Notice what happens when you want to change behaviour:
- <strong>Swap payment gateway:</strong> inject a different <code>PaymentGateway</code> implementation (Spring profile or config change)
- <strong>Change notification channel:</strong> inject a different <code>Notifier</code> implementation
- <strong>Add discount logic:</strong> create a <code>DiscountCalculator</code> and inject it — no changes to <code>OrderProcessingService</code>
- <strong>Test without real payment:</strong> inject a mock <code>PaymentGateway</code>
- <strong>Add audit logging:</strong> create an <code>AuditLogger</code> and inject it — single responsibility preserved
Testing the client project
@ExtendWith(MockitoExtension.class)
class OrderProcessingServiceTest {
@Mock private OrderValidator validator;
@Mock private OrderCalculator calculator;
@Mock private OrderRepository repository;
@Mock private PaymentGateway paymentGateway;
@Mock private Notifier notifier;
@Mock private InvoiceGenerator invoiceGenerator;
@InjectMocks
private OrderProcessingService service;
@Test
void shouldPlaceOrderSuccessfully() {
List<OrderItem> items = List.of(new OrderItem("PROD-1", 2, 50.0));
PaymentDetails payment = new PaymentDetails("CREDIT_CARD", "4111-1111-1111-1111");
when(calculator.calculateTotal(items)).thenReturn(118.0);
when(paymentGateway.charge(any(), any())).thenReturn(new PaymentResult(true, "txn_123"));
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order result = service.placeOrder(items, "test@example.com", payment);
assertNotNull(result);
assertEquals(OrderStatus.CONFIRMED, result.getStatus());
verify(notifier).send(eq("test@example.com"), anyString());
verify(invoiceGenerator).generateAsync(any());
}
}Every dependency is mocked. The test validates only the orchestration logic in <code>OrderProcessingService</code> — not the database, not the email server, not the payment gateway.
Project structure summary
com.example.orderprocessing/
├── OrderProcessingApplication.java
├── domain/
│ ├── Order.java
│ ├── OrderItem.java
│ └── OrderStatus.java
├── service/
│ ├── OrderProcessingService.java // DIP: depends on abstractions
│ ├── OrderCalculator.java // SRP: calculates totals
│ ├── OrderValidator.java // SRP: validates
│ ├── InvoiceGenerator.java // SRP: generates invoices
│ └── interfaces/
│ ├── OrderPlacement.java // ISP: focused interface
│ ├── OrderManagement.java // ISP: focused interface
│ └── OrderReporting.java // ISP: focused interface
├── payment/
│ ├── PaymentGateway.java // OCP: closed for modification
│ ├── CreditCardGateway.java // OCP: open for extension
│ └── UpiGateway.java // OCP: open for extension
├── notification/
│ ├── Notifier.java // LSP: substitutable
│ ├── EmailNotifier.java // LSP: behaves correctly
│ └── SmsNotifier.java // LSP: behaves correctly
├── repository/
│ └── OrderRepository.java
└── controller/
└── CheckoutController.javaSOLID applied: summary of this project
| Principle | How it appears in the project |
|---|---|
| SRP | Each class has one reason to change. <code>OrderCalculator</code> only calculates, <code>OrderValidator</code> only validates, <code>OrderRepository</code> only persists. |
| OCP | <code>PaymentGateway</code> interface allows new payment methods without modification. <code>Notifier</code> interface allows new channels without modification. |
| LSP | Any <code>Notifier</code> implementation can replace another. <code>EmailNotifier</code> and <code>SmsNotifier</code> behave consistently — same inputs, no unexpected exceptions. |
| ISP | Three focused interfaces (<code>OrderPlacement</code>, <code>OrderManagement</code>, <code>OrderReporting</code>) instead of one monolithic <code>OrderService</code> interface. |
| DIP | <code>OrderProcessingService</code> depends on interfaces, not concrete classes. Dependencies are injected, not created with <code>new</code>. |
---
Summary table
| Principle | Core idea | Key technique | Violation symptom |
|---|---|---|---|
| Single Responsibility | One reason to change | Split classes by concern | Class described with "and" |
| Open/Closed | Extend without modifying | Polymorphism (interfaces) | Long if-else chains |
| Liskov Substitution | Subtypes must behave correctly | Design by contract | <code>instanceof</code> checks, throwing overrides |
| Interface Segregation | No fat interfaces | Split interfaces by role | <code>UnsupportedOperationException</code> |
| Dependency Inversion | Depend on abstractions | Dependency injection | <code>new</code> in business logic |
Why SOLID principles work together
These principles are not independent. They reinforce each other:
- <strong>SRP</strong> creates small, focused classes. Small classes are easier to test, and their interfaces naturally stay small (ISP).
- <strong>OCP</strong> becomes achievable because DIP lets you inject new implementations without changing existing code.
- <strong>LSP</strong> ensures that the polymorphic replacements you inject via DIP actually work correctly.
- <strong>ISP</strong> ensures that when you split classes by SRP, the resulting interfaces are also focused.
A violation of one principle often signals violations of others. A class with multiple responsibilities (violating SRP) is likely to have a fat interface (violating ISP), use <code>instanceof</code> checks (violating LSP), and create its own dependencies (violating DIP).
Final thoughts
SOLID principles are not the goal. Maintainable code is the goal. These five principles are tools that help you get there. A few practical guidelines:
- <strong>Do not apply SOLID everywhere from day one.</strong> Premature abstraction creates more problems than it solves. A quick prototype or script does not need a full interface hierarchy.
- <strong>Start by noticing pain.</strong> If a class keeps changing for different reasons, introduce SRP. If adding a feature means modifying many files, introduce OCP. If testing is hard, introduce DIP.
- <strong>Readability matters more than purity.</strong> A pragmatic violation of SOLID is better than a perfectly "SOLID" codebase that nobody can navigate. A simple <code>if-else</code> with two branches is often more readable than an interface with two implementations and a factory.
- <strong>SOLID works together.</strong> These principles reinforce each other. For example, SRP leads to smaller classes, which makes ISP easier to apply. DIP enables OCP because you can swap implementations without changing callers.
- <strong>Refactor toward SOLID, do not design from it.</strong> Start with working code, feel the friction, then refactor. Trying to design a perfect SOLID system upfront leads to over-engineering.
The best way to learn SOLID is to write code, feel the pain of bad design, and then refactor using these principles. The examples in this guide are starting points — adapt them to your real projects.