SOLID Principles: Violations and Fixes
Every SOLID principle explained through a real violation and its refactor — SRP, OCP, LSP, ISP, and DIP — plus the trade-offs of over-applying each one.
SOLID Principles
SOLID is five principles for keeping object-oriented systems easy to change. Reciting the acronym is easy; recognizing a violation in a diff and knowing the minimal refactor is the actual interview and production skill. Each section here follows the same structure: what the principle says, a realistic violation, the fix, and where the principle can be over-applied.
1. Single Responsibility Principle (SRP)
"A class should have one, and only one, reason to change." Not "one method" — one axis of change. A class handling both business logic and how that logic is persisted has two reasons to change: a new business rule, or a new storage technology.
// VIOLATION: Invoice has four unrelated reasons to change
class Invoice {
List<LineItem> items;
double calculateTotal() { /* pricing logic */ return 0; }
void printToPdf() { /* PDF rendering logic — changes if layout changes */ }
void saveToDatabase() { /* SQL — changes if schema/DB changes */ }
void emailToCustomer() { /* SMTP — changes if email provider changes */ }
}// FIXED: each class owns exactly one axis of change
class Invoice {
List<LineItem> items;
double calculateTotal() { /* pricing logic only */ return 0; }
}
class InvoicePdfRenderer {
byte[] render(Invoice invoice) { /* ... */ return new byte[0]; }
}
class InvoiceRepository {
void save(Invoice invoice) { /* ... */ }
}
class InvoiceMailer {
void send(Invoice invoice, String recipient) { /* ... */ }
}Over-applying SRP: splitting a class per method instead of per reason to change produces a maze of tiny classes with no cohesion gain — just navigation overhead. If two pieces of logic always change together for the same business reason, they can stay in one class.
2. Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification." Adding a new case should mean adding code, not editing an existing, tested method's conditional chain.
// VIOLATION: every new discount type means editing this method
class DiscountCalculator {
double apply(String customerType, double amount) {
if (customerType.equals("REGULAR")) return amount;
if (customerType.equals("PREMIUM")) return amount * 0.9;
if (customerType.equals("VIP")) return amount * 0.8;
// adding "STUDENT" means modifying this tested method again
throw new IllegalArgumentException("Unknown type");
}
}// FIXED: Strategy pattern — new discount = new class, zero edits to existing code
interface DiscountStrategy {
double apply(double amount);
}
class RegularDiscount implements DiscountStrategy {
public double apply(double amount) { return amount; }
}
class PremiumDiscount implements DiscountStrategy {
public double apply(double amount) { return amount * 0.9; }
}
class VipDiscount implements DiscountStrategy {
public double apply(double amount) { return amount * 0.8; }
}
// Adding StudentDiscount later touches ZERO existing, tested classes.
class DiscountCalculator {
double apply(DiscountStrategy strategy, double amount) {
return strategy.apply(amount);
}
}OCP is what Strategy, Decorator, and Observer are for — this is why "SOLID" and "Gang of Four patterns" are taught together. When you see an if/else or switch chain keyed off a type that keeps growing, that's the OCP violation signal.
Over-applying OCP: introducing a Strategy interface for a set of cases that is genuinely fixed and unlikely to grow (e.g. the 7 days of the week) is premature abstraction. OCP pays off when new variants are a realistic, recurring future requirement — not for closed, stable enumerations.
3. Liskov Substitution Principle (LSP)
"Subtypes must be substitutable for their base types without altering program correctness." The canonical violation is the Square/Rectangle problem, but the pattern generalizes: a subclass that throws on a method the superclass promises, or that narrows/widens accepted inputs, breaks LSP.
// VIOLATION: classic Square-extends-Rectangle
class Rectangle {
protected int width, height;
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { width = w; height = w; } // silently changes height too!
@Override void setHeight(int h) { width = h; height = h; }
}
// Client code that works for any Rectangle breaks for Square:
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.area() == 50; // PASSES for Rectangle, FAILS for Square (area=100)
}// FIXED: don't force an is-a relationship that isn't behaviorally true.
// Square and Rectangle are both Shapes, but Square is not a "substitutable Rectangle".
interface Shape { int area(); }
final class Rectangle implements Shape {
private final int width, height;
Rectangle(int width, int height) { this.width = width; this.height = height; }
public int area() { return width * height; }
}
final class Square implements Shape {
private final int side;
Square(int side) { this.side = side; }
public int area() { return side * side; }
}A subtler, more common LSP violation seen in real backend code:
// VIOLATION: subclass narrows the contract by throwing on valid superclass input
class ReadWriteRepository<T> {
void save(T item) { /* ... */ }
}
class ReadOnlyRepository<T> extends ReadWriteRepository<T> {
@Override
void save(T item) {
throw new UnsupportedOperationException("read-only"); // breaks LSP
}
}
// Any code that accepts a ReadWriteRepository and calls save() now
// crashes at runtime for a ReadOnlyRepository — a violation invisible at compile time.Fix: split the capability instead of inheriting and un-implementing it. Define separate Readable<T> and Writable<T> interfaces; ReadOnlyRepository implements only Readable<T>. No method is ever present just to throw.
4. Interface Segregation Principle (ISP)
"Clients should not be forced to depend on methods they do not use." A "fat" interface with many unrelated methods forces every implementer to provide (or stub out) methods irrelevant to it.
// VIOLATION: fat interface forces irrelevant implementations
interface Worker {
void code();
void designUI();
void writeDocs();
void deployInfra();
}
class BackendEngineer implements Worker {
public void code() { /* ... */ }
public void designUI() { throw new UnsupportedOperationException(); } // forced stub
public void writeDocs() { /* ... */ }
public void deployInfra() { throw new UnsupportedOperationException(); } // forced stub
}// FIXED: segregated, role-specific interfaces — implement only what applies
interface Coder { void code(); }
interface Designer { void designUI(); }
interface TechnicalWriter { void writeDocs(); }
interface InfraEngineer { void deployInfra(); }
class BackendEngineer implements Coder, TechnicalWriter {
public void code() { /* ... */ }
public void writeDocs() { /* ... */ }
// no forced stubs — this class simply doesn't implement Designer or InfraEngineer
}| Symptom | ISP violation signal |
|---|---|
throw new UnsupportedOperationException() in an override | Interface has a method this implementer doesn't need |
| Empty method bodies just to satisfy an interface | Same signal, silent version |
| An interface with 10+ methods, implemented by many unrelated classes | Interface is bundling multiple roles |
ISP is SRP applied to interfaces instead of classes: a fat interface has multiple reasons for its implementers to change. Segregating by client need, not by "logical grouping in my head," is the deciding factor.
5. Dependency Inversion Principle (DIP)
"Depend on abstractions, not concretions. High-level modules should not depend on low-level modules — both should depend on abstractions." This is the principle that makes constructor injection and testable code possible.
// VIOLATION: OrderService instantiates its own dependency — tightly coupled
class OrderService {
private final MySqlOrderRepository repository = new MySqlOrderRepository(); // concrete!
void placeOrder(Order order) {
repository.save(order);
}
}
// Testing OrderService requires a real MySQL connection. Swapping databases
// means editing OrderService itself.// FIXED: depend on an interface, inject the implementation
interface OrderRepository {
void save(Order order);
}
class MySqlOrderRepository implements OrderRepository {
public void save(Order order) { /* JDBC/JPA code */ }
}
class OrderService {
private final OrderRepository repository; // abstraction, injected
OrderService(OrderRepository repository) { // constructor injection
this.repository = repository;
}
void placeOrder(Order order) {
repository.save(order);
}
}
// Production: OrderService prodService = new OrderService(new MySqlOrderRepository());
// Test: OrderService testService = new OrderService(new InMemoryOrderRepository());"Dependency Inversion" and "Dependency Injection" are related but distinct: DIP is the design principle (depend on abstractions); DI is the mechanism (constructor/setter/framework injects the concrete implementation). Spring's @Autowired is DI in service of DIP.
Over-applying DIP: wrapping every single class in an interface "just in case," including value objects and classes with exactly one implementation and no test-double need, adds a layer of navigation with no benefit. Reserve interfaces for real seams — I/O boundaries, things that vary by environment (prod vs test), or genuine multiple implementations.
SOLID at a glance
| Principle | One-line rule | Typical fix pattern |
|---|---|---|
| SRP | One reason to change | Extract class per responsibility |
| OCP | Extend without modifying | Strategy / Decorator / polymorphism instead of if/switch on type |
| LSP | Subtypes must be substitutable | Split capabilities into separate interfaces instead of inheriting-and-un-implementing |
| ISP | No forced unused methods | Segregate fat interfaces by client role |
| DIP | Depend on abstractions | Constructor injection against an interface |
Interview Questions
- Give an example of a class that violates SRP, and explain the two distinct reasons it might need to change.
- How does the Strategy pattern satisfy the Open/Closed Principle? What signals in code tell you OCP is being violated?
- Explain the Square/Rectangle LSP violation. Why does making
Square extends Rectanglebreak substitutability even though a square is, mathematically, a rectangle? - What's the difference between an interface having "too many methods" and violating ISP? Can a 3-method interface still violate ISP?
- How does Dependency Inversion enable unit testing without a real database or network call?
- What's the difference between Dependency Inversion (the principle) and Dependency Injection (the mechanism)?
- Describe a case where strictly applying SOLID would be over-engineering. What would you do instead?