The acronym SOLID stands for five principles of class design formulated by Robert Martin. They are often explained with examples about geometric shapes, and they can feel like theory for theory's sake. In practice the principles are answers to concrete pains: "why is this code so hard to change?", "why is one class edited by several teams at once?", "why does swapping a library break half the system?".
Let's go through each principle from scratch: what the problem is, how it shows up in code, and how to fix it.
SRP — single responsibility
A class should have one reason to change.
Imagine a service that has grown into a "junk drawer" over time:
public class OrderService {
public OrderDto createOrder(CreateOrderRequest request) { /* 120 lines */ }
public OrderDto cancelOrder(OrderId id) { /* 80 lines */ }
public Page<OrderDto> searchOrders(OrderFilter filter) { /* 60 lines */ }
public byte[] exportOrders(OrderFilter filter) { /* 90 lines */ }
public void recalculateStatistics() { /* 70 lines */ }
}
When the order creation rule changes — we edit this class. When the export format changes — this class again. When the statistics algorithm changes — it again. The class has several unrelated reasons to change, and any edit risks touching the rest.
SRP says: a class should have one reason to change. In practice this means one class solves one task.
public class CreateOrderHandler {
private final OrderRepository orders;
private final PricingPolicy pricing;
public OrderDto handle(CreateOrderCommand cmd) {
var order = Order.create(cmd.customerId(), cmd.lines(), pricing);
orders.save(order);
return OrderDto.from(order);
}
}
This class changes only when the order creation rule changes. Sending an email to the customer is a different responsibility, and it lives in a separate event handler.
A good sign of an SRP violation is when a class contains the word "and" in its spoken description: "it creates an order and sends an email and recalculates statistics".
OCP — open for extension, closed for modification
System behavior is extended by adding code, not by editing existing code.
A typical picture of a violation is a switch or a chain of if that you have to add a branch to every time a new variant appears:
public BigDecimal discount(Order order) {
return switch (order.customer().type()) {
case VIP -> order.total().multiply(new BigDecimal("0.10"));
case EMPLOYEE -> order.total().multiply(new BigDecimal("0.20"));
case REGULAR -> BigDecimal.ZERO;
};
}
A new customer type means editing this method. And all the other switch statements over the same attribute elsewhere in the code.
The solution is to declare an extension point (an interface) and add new behavior with a new class, without touching the existing one:
public interface DiscountPolicy {
boolean supports(Customer customer);
BigDecimal discount(Order order);
}
public class DiscountCalculator {
private final List<DiscountPolicy> policies;
public BigDecimal discount(Order order) {
return policies.stream()
.filter(p -> p.supports(order.customer()))
.findFirst()
.map(p -> p.discount(order))
.orElse(BigDecimal.ZERO);
}
}
A new discount type means a new class implementing DiscountPolicy. The existing code is not touched.
A caveat: OCP does not mean "create interfaces everywhere just in case". If there are knowingly only two variants and no new ones are expected — a switch is more honest. The principle applies where extension is genuinely expected.
LSP — Liskov substitution principle
An implementation can be replaced by any other implementation of the same contract — and the calling code won't notice the difference.
A violation usually looks like inheritance for the sake of code reuse, where the subclass breaks the parent's expectations:
public class CachedProductRepository extends JpaProductRepository {
private final Map<ProductId, Product> cache = new ConcurrentHashMap<>();
@Override
public Optional<Product> findById(ProductId id) {
return Optional.ofNullable(cache.computeIfAbsent(id,
key -> super.findById(key).orElse(null)));
}
@Override
public void delete(ProductId id) {
throw new UnsupportedOperationException("cache does not support deletion");
}
}
The class calls itself a repository, but delete throws an exception. Code that worked with the base class breaks with the "specialized" one — that is exactly the LSP violation: the subclass narrowed the contract.
The correct form is not inheritance but composition. The new class implements the same interface and honestly fulfills its entire contract:
public class CachingProductRepository implements ProductRepository {
private final ProductRepository delegate;
private final Cache cache;
@Override
public Optional<Product> findById(ProductId id) {
return Optional.ofNullable(cache.get(id, () -> delegate.findById(id).orElse(null)));
}
@Override
public void delete(ProductId id) {
delegate.delete(id); // deletion is performed
cache.evict(id); // and the cache is invalidated
}
}
Now CachingProductRepository can be substituted for any other ProductRepository without surprises.
A rule of thumb: if you see UnsupportedOperationException in an overridden method — LSP is almost certainly violated.
ISP — interface segregation
A client should not depend on methods it does not use.
Interfaces have a tendency to grow: first there was save and findById, then findForListing was added, then exportOrders, then archiveOlderThan:
public interface OrderStorage {
void save(Order order);
Optional<Order> findById(OrderId id);
Page<OrderListRow> findForListing(OrderFilter filter, Pageable pageable);
List<OrderExportRow> findForExport(LocalDate from, LocalDate to);
void archiveOlderThan(LocalDate date);
}
A command handler uses two methods out of five but depends on all of them. Changing the signature of the export method forces it to be recompiled too. In tests you have to stub out all five methods, even though only two are needed.
The solution is to split by consumers, not by table:
// for commands — only what is needed
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
}
// for reads and reports — separately
public interface OrderViewRepository {
Page<OrderListRow> findForListing(OrderFilter filter, Pageable pageable);
List<OrderExportRow> findForExport(LocalDate from, LocalDate to);
}
A single implementation can implement both interfaces — that's fine. What matters is that each consumer depends only on the methods it actually uses.
DIP — dependency inversion
High-level modules do not depend on low-level modules. Both depend on abstractions.
In plain words: business logic should not directly know about concrete tools (a database, a mail server, an external API). Otherwise, when you swap the tool, you have to touch the business logic.
A typical violation is a domain model that knows about a concrete notification transport:
public class Order {
public void cancel(SmtpMailSender mailSender) {
this.status = Status.CANCELLED;
mailSender.send(customer.email(), "Order cancelled");
}
}
The domain model depends on SmtpMailSender — a concrete infrastructure detail. Want to switch to push notifications — you'll have to change Order. Want to test cancellation without a real SMTP server — it's hard.
Inversion: the domain declares what it needs (an interface), and the infrastructure decides how to implement it:
// the interface is declared next to the domain and speaks the domain's language
public interface NotificationPort {
void orderCancelled(Order order);
}
// the implementation lives in the infrastructure layer
public class SmtpNotificationAdapter implements NotificationPort {
private final JavaMailSender mailSender;
@Override
public void orderCancelled(Order order) {
mailSender.send(buildMessage(order));
}
}
Order no longer knows anything about SMTP. In a test NotificationPort is easily replaced by a stub. Changing the transport means a new adapter, and the domain is not touched.
Note: the dependency goes from SmtpNotificationAdapter to NotificationPort (which lives next to the domain), not the other way around. The direction of the dependency is inverted relative to the direction of the call — hence the name "inversion".
In short
- SRP: a class has one reason to change. If a class does "both this and that" — it's time to split it.
- OCP: new behavior is added with a new class, not by editing the existing one. An interface as the extension point.
- LSP: a subclass or implementation replaces the original without surprises.
UnsupportedOperationExceptionin an overridden method is a red flag. - ISP: an interface contains only what a particular consumer needs. A big interface is cut into several narrow ones.
- DIP: business logic depends on interfaces, not on concrete classes. The direction of dependencies is toward the domain, not away from it.
The principles work together: a class with a single responsibility (SRP) depends on a narrow interface (ISP) declared in the domain (DIP), whose implementations are interchangeable (LSP), and new behavior variants are added with new classes (OCP).
What to read next
- GoF patterns — concrete techniques that implement the ideas of SOLID in practice.
- GRASP by example — principles of distributing responsibility between classes.
- Hexagonal architecture — DIP and ISP taken all the way to the module structure.