01-oop-foundations-solid

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.

August 11, 2026
lldsolidsrpocplspispdip

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.

java
// 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 */ }
}
java
// 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.

java
// 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");
    }
}
java
// 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.

java
// 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)
}
java
// 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:

java
// 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.

java
// 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
}
java
// 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
}
SymptomISP violation signal
throw new UnsupportedOperationException() in an overrideInterface has a method this implementer doesn't need
Empty method bodies just to satisfy an interfaceSame signal, silent version
An interface with 10+ methods, implemented by many unrelated classesInterface 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.

java
// 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.
java
// 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

PrincipleOne-line ruleTypical fix pattern
SRPOne reason to changeExtract class per responsibility
OCPExtend without modifyingStrategy / Decorator / polymorphism instead of if/switch on type
LSPSubtypes must be substitutableSplit capabilities into separate interfaces instead of inheriting-and-un-implementing
ISPNo forced unused methodsSegregate fat interfaces by client role
DIPDepend on abstractionsConstructor 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 Rectangle break 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?