SOLID Principles

Five design principles applied to a real order-processing domain, why Spring's constructor injection is Dependency Inversion in practice, and when SOLID becomes over-engineering rather than good design

← Back to Index

What is SOLID?

SOLID is five design principles for keeping object-oriented code changeable without breaking things that shouldn't be affected. Each principle targets a specific, recurring way software gets harder to change over time โ€” not five arbitrary rules, but five different failure modes a codebase actually runs into.

The Five Principles
  • S โ€” Single Responsibility Principle
  • O โ€” Open/Closed Principle
  • L โ€” Liskov Substitution Principle
  • I โ€” Interface Segregation Principle
  • D โ€” Dependency Inversion Principle

S โ€” Single Responsibility Principle

"A class should have only one reason to change." A reason to change means a distinct stakeholder or concern that could independently trigger an edit โ€” the business rule for validating an order, the schema of the orders table, the wording of a confirmation email. Bundling them means a change to one can accidentally break another.

Violation

// BAD โ€” one class, four unrelated reasons to change
public class OrderManager {
    public void createOrder(Order order) {
        if (order.getItems().isEmpty()) {
            throw new ValidationException("Order must have items");
        }

        Connection conn = DriverManager.getConnection(url);
        PreparedStatement stmt = conn.prepareStatement(sql);
        stmt.executeUpdate();

        Transport.send(confirmationMessage);   // raw email sending, inline

        new FileWriter("orders.log").write("Order created: " + order.getId());
    }
}
// A change to the email template, the persistence library, or the log
// format all require editing the exact same method.

Correct Implementation

public class OrderValidator {
    public void validate(Order order) {
        if (order.getItems().isEmpty()) {
            throw new ValidationException("Order must have items");
        }
    }
}

public class OrderRepository {
    public void save(Order order) { /* persistence only */ }
}

public class NotificationService {
    public void sendOrderConfirmation(Order order) { /* email only */ }
}

public class OrderService {
    private final OrderValidator validator;
    private final OrderRepository repository;
    private final NotificationService notifications;

    public OrderService(OrderValidator validator, OrderRepository repository,
                        NotificationService notifications) {
        this.validator = validator;
        this.repository = repository;
        this.notifications = notifications;
    }

    public void createOrder(Order order) {
        validator.validate(order);
        repository.save(order);
        notifications.sendOrderConfirmation(order);
    }
}

O โ€” Open/Closed Principle

"Open for extension, closed for modification." Add new behavior by adding new code, not by editing code that already works and is already tested.

Violation

// BAD โ€” every new payment method means editing this method again
public class PaymentProcessor {
    public void processPayment(String type, BigDecimal amount) {
        if (type.equals("credit_card")) { /* ... */ }
        else if (type.equals("paypal")) { /* ... */ }
        // adding "crypto" means modifying this method, and re-testing
        // every branch above it all over again
    }
}

Correct Implementation

public interface PaymentMethod {
    boolean supports(PaymentType type);
    PaymentResult process(BigDecimal amount);
}

@Component
public class CreditCardPayment implements PaymentMethod { /* ... */ }

@Component
public class CryptoPayment implements PaymentMethod {
    // Adding this is the ENTIRE change โ€” no existing class touched
}

@Service
public class PaymentProcessor {
    private final List<PaymentMethod> methods;

    // Spring injects every bean implementing PaymentMethod automatically โ€”
    // registering a new strategy requires zero changes here
    public PaymentProcessor(List<PaymentMethod> methods) {
        this.methods = methods;
    }

    public PaymentResult process(PaymentType type, BigDecimal amount) {
        return methods.stream()
                .filter(m -> m.supports(type))
                .findFirst()
                .orElseThrow(() -> new UnsupportedPaymentTypeException(type))
                .process(amount);
    }
}

This is OCP as Spring developers actually apply it day to day โ€” not just polymorphism in the abstract, but a collection of auto-discovered strategy beans the container assembles for you.

L โ€” Liskov Substitution Principle

"Subtypes must be usable anywhere their base type is expected, without the caller needing to know the difference." More precisely (Design by Contract terms): a subtype may not strengthen preconditions or weaken postconditions the base type promised โ€” it can only ask for less and guarantee at least as much.

Violation

// BAD โ€” ShippedOrder can't honor Order's contract, it just throws instead
public class Order {
    private final List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        items.add(item);
    }
}

public class ShippedOrder extends Order {
    @Override
    public void addItem(OrderItem item) {
        throw new IllegalStateException("Cannot add items to a shipped order");
        // Any code written against Order โ€” "call addItem(), it adds an item" โ€”
        // now has to know about ShippedOrder specifically to avoid crashing.
        // The base type's contract is broken by this specific subtype.
    }
}

// This generic, perfectly reasonable code now has a hidden landmine:
void applyBackorderedItem(Order order, OrderItem item) {
    order.addItem(item);   // works for Order, throws for ShippedOrder โ€” LSP violated
}

Correct Implementation

// GOOD โ€” model the states that genuinely differ as distinct types,
// rather than one type that lies about what it can do
public interface Order {
    BigDecimal getTotal();
}

public interface MutableOrder extends Order {
    void addItem(OrderItem item);   // only types that can genuinely do this implement it
}

public class DraftOrder implements MutableOrder { /* addItem works normally */ }

public class ShippedOrder implements Order {
    // No addItem() here at all โ€” the type system itself prevents calling
    // it on a shipped order, instead of a runtime exception discovering it
}
A method that overrides its parent only to throw UnsupportedOperationException is the classic LSP smell

Whenever an override's entire body is "throw because this subtype actually can't do this," that's a signal the inheritance itself is wrong โ€” the subtype isn't really a specialization of the parent, it's a different thing that happens to share some methods. Splitting the capability into its own interface (as above) makes the type system enforce the distinction the exception was trying to enforce at runtime, except at compile time, for every caller, automatically.

I โ€” Interface Segregation Principle

"Clients shouldn't be forced to depend on methods they don't use." A large, general-purpose interface forces every implementer to either genuinely support every method, or fake support with a stub that throws.

Violation

// BAD โ€” a read-only reporting client is forced to implement mutation methods it never calls
public interface OrderOperations {
    Order create(OrderRequest request);
    void cancel(Long orderId);
    void refund(Long orderId);
    byte[] generateInvoicePdf(Long orderId);
}

// A reporting dashboard only ever wants to READ order data,
// but this interface forces it to also implement mutation it will never use
public class ReportingOrderAdapter implements OrderOperations {
    @Override
    public void cancel(Long orderId) {
        throw new UnsupportedOperationException();   // forced stub
    }
    // ... and refund(), generateInvoicePdf() โ€” same problem
}

Correct Implementation

public interface OrderCreator  { Order create(OrderRequest request); }
public interface OrderCanceller { void cancel(Long orderId); }
public interface OrderRefunder  { void refund(Long orderId); }
public interface InvoiceGenerator { byte[] generateInvoicePdf(Long orderId); }

// The reporting adapter now only depends on what it actually uses
public class ReportingOrderAdapter implements InvoiceGenerator {
    @Override
    public byte[] generateInvoicePdf(Long orderId) { /* ... */ }
}

// The full order service composes all four โ€” it genuinely needs all of them
public class OrderService implements OrderCreator, OrderCanceller,
                                          OrderRefunder, InvoiceGenerator { /* ... */ }

D โ€” Dependency Inversion Principle

"High-level modules and low-level modules should both depend on abstractions, not on each other directly." This is the principle Spring's dependency injection exists to apply automatically โ€” the site's own convention of always using constructor injection is DIP put into practice, not a separate, unrelated style rule.

Violation

// BAD โ€” OrderService is welded to one concrete persistence choice
public class MySQLOrderRepository {
    public void save(Order order) { /* MySQL-specific JDBC code */ }
}

public class OrderService {
    private MySQLOrderRepository repository = new MySQLOrderRepository();   // tight coupling

    public void createOrder(Order order) {
        repository.save(order);
        // Can't swap the database, and can't unit test this without a real one
    }
}

Correct Implementation

public interface OrderRepository {
    void save(Order order);
}

@Repository
public class JpaOrderRepository implements OrderRepository {
    @Override
    public void save(Order order) { /* JPA implementation */ }
}

@Service
public class OrderService {
    private final OrderRepository repository;   // depends on the abstraction

    // Constructor injection โ€” Spring wires in whichever OrderRepository
    // bean exists, and this class never knows or cares which one
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

// Testing โ€” swap in a mock with zero changes to OrderService itself
OrderRepository mockRepo = mock(OrderRepository.class);
OrderService testService = new OrderService(mockRepo);
Field injection (@Autowired on a field) quietly undermines DIP's actual benefit

DIP's real payoff is being able to construct OrderService with any implementation you want, including a mock, without Spring's container involved at all โ€” which is exactly what constructor injection enables and field injection doesn't: a field-injected dependency can only be set by reflection, meaning true unit tests need a full Spring context or reflection hacks just to substitute a fake. This is the concrete, mechanical reason the constructor-injection-always convention isn't a style preference โ€” it's what makes DIP's testability benefit real instead of theoretical.

When Applying SOLID Becomes the Problem It Was Meant to Solve

Every principle above has a real cost if applied where the underlying problem doesn't actually exist yet.

Premature abstraction: an interface with exactly one implementation, forever

Creating an OrderRepository interface for DIP makes sense when a second implementation is a real, foreseeable possibility, or when the interface exists to make unit testing possible. An interface wrapping a single concrete class that will only ever have that one implementation adds a layer of indirection โ€” a reader now has to jump from the interface to the implementation to see what actually happens โ€” for a flexibility the codebase will never use. YAGNI (You Aren't Gonna Need It) is the useful counterweight here: introduce the interface when a second implementation or a genuine test-substitution need actually shows up, not preemptively for every single class.

Similarly, Interface Segregation taken to an extreme produces an interface per method, forcing callers to depend on five tiny interfaces to do one coherent thing โ€” segregate along real client boundaries (the reporting adapter genuinely only needs invoicing), not reflexively per method.

SOLID Quick Reference

PrincipleKey ideaBenefit
SRPOne reason to changeEasier, safer maintenance
OCPExtend via new code, don't modify existing codeSafer additions, no re-testing old paths
LSPSubtypes honor the base type's contractReliable, surprise-free polymorphism
ISPSmall, client-shaped interfacesNo forced stub implementations
DIPDepend on abstractions, inject via constructorReal testability, swappable implementations

Best Practices and Common Pitfalls

โœ… Do

  • Split a class the moment two unrelated stakeholders would need to request changes to it independently
  • Use Spring's List<Interface> injection for genuinely open-ended strategy sets (payment methods, notification channels)
  • Treat an override whose entire body is "throw UnsupportedOperationException" as a sign the type hierarchy itself is wrong
  • Introduce an interface for DIP when a second implementation or a real test-substitution need exists โ€” not preemptively
  • Always inject via constructor, never a field โ€” it's what makes DIP's testability benefit real

โŒ Don't

  • Don't create an interface with exactly one implementation "just in case" โ€” that's YAGNI territory, not DIP
  • Don't segregate an interface down to one method each reflexively โ€” segregate along real client boundaries instead
  • Don't model a business state as a subclass that overrides a parent method just to throw โ€” model the state as its own type instead
  • Don't use field injection (@Autowired on a field) โ€” it silently defeats the point of depending on an abstraction

Interview Questions

๐ŸŽ“ Junior level

Q: What does "one reason to change" actually mean for the Single Responsibility Principle?
It means a class should serve one stakeholder or concern โ€” validation rules, persistence, or notification logic, for example โ€” so that a change requested for one of those reasons doesn't require touching, and risking, code that serves an unrelated concern.

Q: Why is a method override that only throws UnsupportedOperationException usually a sign of a design problem?
It means the subtype can't actually honor the contract the base type promises, which violates the Liskov Substitution Principle โ€” any code written generically against the base type can't safely assume this method works, defeating the point of polymorphism.

Q: How does constructor injection relate to the Dependency Inversion Principle?
DIP says high-level code should depend on abstractions, not concrete implementations. Constructor injection is the mechanical way that's achieved in practice โ€” the class declares a dependency on an interface type in its constructor, and whoever constructs it (Spring, or a test) decides which concrete implementation to provide.

๐Ÿ”ฅ Senior level

Q: A codebase has an interface for every single class, including several with only one implementation that will never realistically change. Is this good adherence to DIP?
No โ€” this misapplies DIP by treating "introduce an interface" as the goal rather than the means to an actual end. DIP's real value is decoupling a high-level module from a specific low-level implementation detail it might need to vary or substitute โ€” for a second real implementation, or for test doubles. An interface with exactly one implementation, and no realistic prospect of a second, adds a layer of indirection a reader must navigate through for a flexibility the codebase structurally can't exercise: nobody is ever going to construct the class with a different implementation, because none exists. This is a premature abstraction cost with none of DIP's actual benefit โ€” the interface exists in service of "following the principle" rather than in service of any concrete need the principle is meant to address, which is precisely the over-engineering YAGNI warns against.

Q: Explain concretely why field injection (@Autowired on a field) undermines the testability benefit that Dependency Inversion is supposed to provide, even though the class still technically depends on an interface type.
Depending on an interface type is necessary for DIP but not sufficient for its practical benefit โ€” the benefit is realized specifically through how that dependency gets supplied. With field injection, the field has no public setter and no constructor parameter; the only way to populate it outside of Spring's reflection-based injection is more reflection, or standing up a full Spring test context just to substitute a mock. This reintroduces exactly the friction DIP is meant to remove: a genuine unit test โ€” one that constructs the object directly with a hand-built test double, no container involved โ€” becomes awkward or impossible. Constructor injection keeps the dependency swap a first-class, reflection-free operation: new OrderService(mockRepo) is the entire test setup. The type is inverted either way; only constructor injection keeps the practical payoff of that inversion intact.

Q: A team models ShippedOrder extends Order, where addItem() is overridden to throw an exception once the order ships. A colleague argues this correctly represents the business rule that shipped orders are immutable. Where's the actual design flaw, separate from whether the business rule itself is correct?
The business rule โ€” shipped orders can't be modified โ€” is entirely correct; the flaw is encoding it as a subtype that silently fails a contract its supertype promises, discoverable only at runtime by whichever caller happens to invoke addItem() on the wrong instance. Any code written generically against Order โ€” and polymorphism's entire purpose is enabling exactly that kind of generic code โ€” now carries a hidden precondition ("only call addItem() if this isn't secretly a ShippedOrder") that the type signature gives no indication of. The fix isn't abandoning the business rule; it's expressing it in the type system instead of in a runtime exception โ€” an interface like MutableOrder that only mutable states implement, so that a caller holding a plain Order reference to a shipped order simply has no addItem() method to call in the first place, and the compiler โ€” not a production stack trace โ€” is what tells a developer their assumption was wrong.