Skip to content
DevPedia

Guide 3 of 4

SOLID

Each principle is there to keep a small change from demanding a lot of work. Applying them to the letter can add more complexity than it resolves.

Updated 16 min read

SOLID groups five principles for increasing cohesion, reducing coupling, and making it easier for a system to evolve: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.

It’s often presented as a set of rules for writing “good code”. That reading is too simplistic: they’re criteria for deciding about responsibilities, abstractions, dependencies, and behavior. Their usefulness shows up once the system starts changing: certain decisions make adding a new feature end up demanding a lot of work, or make a small change force you to modify a large part of the codebase.

They shouldn’t be applied mechanically either: more interfaces, more classes, and more abstractions isn’t automatically a better architecture. SOLID is used together with the general design principles — KISS, DRY, YAGNI, separation of concerns, and composition over inheritance — not in their place.

S — Single Responsibility Principle

A class should have only one reason to change.

This formulation is more useful than simply saying a class should “have a single responsibility”. What’s central to SRP isn’t how many methods a class has, it’s which forces of change act on it.

public class OrderService {

    public void createOrder(Order order) {
        // create order
    }

    public void sendConfirmationEmail(Order order) {
        // send email
    }

    public byte[] generateInvoice(Order order) {
        // generate PDF
    }
}

At first glance everything looks order-related, but there are three independent forces of change: the business logic can change because of a new commercial rule, sending email can change because of a new provider, and invoice generation can change because of a new document format.

Before: one class accumulates three forces of change. After: each force of change has its own class.

SRP and cohesion. A cohesive class groups behavior that belongs to the same concept and tends to change together. A class with heterogeneous responsibilities accumulates dependencies and can end up turning into a god object.

Practical question: can I identify different actors or forces of change that would modify this class for different reasons? If the answer is yes, there’s probably an opportunity to separate.

SRP doesn’t mean one class per method. Splitting OrderService into CreateOrderService, ValidateOrderService, CalculateOrderTotalService, and PersistOrderService when all of that is one cohesive concept that always evolves together can add more complexity than value. The goal isn’t to minimize class size — it’s to maximize cohesion and isolate independent reasons to change.

O — Open/Closed Principle

Software entities should be open for extension, but closed for modification.

The idea is to be able to bring in new behavior without continually modifying stable code.

public void processPayment(Payment payment) {
    if (payment.getMethod() == PaymentMethod.MERCADOPAGO) {
        // Mercado Pago
    } else if (payment.getMethod() == PaymentMethod.STRIPE) {
        // Stripe
    } else if (payment.getMethod() == PaymentMethod.PAYPAL) {
        // PayPal
    }
}

Every new provider forces you to touch this logic. If we represent the variation as an abstraction:

public interface PaymentProcessor {
    void process(Payment payment);
}
Class diagram: each concrete processor realizes the PaymentProcessor interface. Adding a new one doesn't touch the code that consumes it.

The code that coordinates the processing depends on PaymentProcessor without knowing each provider’s details. Adding a new one doesn’t require modifying the central logic.

OCP and points of variation. OCP doesn’t mean a class should never be modified — that would be impossible. The goal is to identify which parts have a reasonable chance of varying and isolate that variability there. If the system uses a single provider and there’s no evidence it’s going to change, building out a full plugin architecture is an unnecessary application of the principle.

OCP and polymorphism. It’s usually implemented with interfaces, polymorphism, Strategy, dependency injection, configuration, or event-driven architectures — but it doesn’t depend on any specific technique. What matters is that bringing in a new variant doesn’t force you to touch the stable logic.

OCP doesn’t mean “put an interface on everything”. If UserRepository has a single implementation, no expected variation, and no architectural boundary to justify it, the interface may add nothing. OCP pays off when there’s real variability or a boundary worth freezing; otherwise the interface is cost without benefit.

L — Liskov Substitution Principle

Objects of a subtype should be usable wherever the base type is expected, without breaking that type’s guarantees.

In practice, a subtype has to honor the behavioral contract of the abstraction it implements.

public interface Bird {
    void fly();
}

public class Penguin implements Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException();
    }
}

The problem isn’t that a penguin can’t fly — it’s that Bird implicitly establishes a capability Penguin can’t fulfill. A more suitable design:

Class diagram: separating the ability to fly from being a bird, so no subtype inherits an operation it can't fulfill.
public interface Bird {
    void eat();
}

public interface FlyingBird extends Bird {
    void fly();
}

Now the abstraction represents capabilities that all of its implementers can fulfill.

LSP is about contracts. An LSP violation doesn’t necessarily mean the implementation is wrong — it can mean the abstraction is wrong. If Repository.findById is part of the contract and one implementation throws UnsupportedOperationException, that implementation can’t correctly substitute for the others.

The four concrete conditions. LSP isn’t an intuition about hierarchies: Barbara Liskov and Jeannette Wing formulated it as a set of checkable conditions on the contract. A subtype has to meet all four:

ConditionWhat it meansHow it breaks
Preconditions no strongerThe subtype can’t demand more than the base typesetQuantity(int) accepts 1-100 in the base and only 1-5 in the subtype
Postconditions no weakerThe subtype has to guarantee at least the sameThe base guarantees save leaves the object persisted; the subtype queues it and can lose it
Invariants preservedWhat always holds in the base keeps holdingThe base guarantees total >= 0; the subtype allows negative totals
History constraintThe subtype doesn’t introduce mutations the base didn’t allowThe base is immutable and the subtype adds a setter

Only the first and the last are visible in the type. The other two live in the behavior, which is why LSP isn’t something the compiler catches.

Practical question: can the code that uses the base type use the subtype without knowing it’s working with a subtype? If not, there’s a possible LSP violation.

I — Interface Segregation Principle

Clients should not be forced to depend on methods they do not use.

public interface Employee {
    void work();
    void eat();
    void manage();
    void generateReports();
    void approveExpenses();
}

A regular employee doesn’t need manage(), generateReports(), or approveExpenses(). A single interface forces every implementation to define methods it may never use.

Class diagram: interfaces segregated by capability. An employee with no reports implements only Worker.
public interface Worker {
    void work();
}

public interface Manager {
    void manage();
    void approveExpenses();
}

public interface ReportGenerator {
    void generateReports();
}

The problem with large interfaces. If approveExpenses() changes, every implementation of the interface can be affected — including the ones that have nothing to do with approving expenses. That usually produces implementations throwing UnsupportedOperationException, a sign that the abstraction groups responsibilities that should be separate.

ISP and clients. An OrderRepository with findById, findAll, save, delete, generateReport, and exportToCsv forces a service that only needs to query orders to depend on the whole interface. If we split it into OrderReader and OrderWriter, each consumer depends only on the capability it needs.

ISP doesn’t mean every interface has to be small. A ten-method interface isn’t automatically a violation. The question is whether those methods represent a cohesive capability from the consumers’ point of view — the segregation has to answer real client needs, not an obsession with reducing the method count.

D — Dependency Inversion Principle

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.

public class OrderService {
    private final MySqlOrderRepository repository;

    public OrderService() {
        this.repository = new MySqlOrderRepository();
    }
}

OrderService is high-level logic; MySqlOrderRepository is an infrastructure detail. The direct dependency makes the service know about a specific technology decision. If we introduce an abstraction:

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

public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

public class MySqlOrderRepository implements OrderRepository {
    // MySQL-specific implementation
}
Class diagram: OrderService depends on the OrderRepository abstraction, and so does the MySQL implementation. The dependency toward infrastructure is inverted.

The high-level policy doesn’t depend on the mechanism that implements it.

Why invert the dependency? It separates policy from mechanism: the business rule isn’t tied to how things are persisted. You can swap MySqlOrderRepository for PostgresOrderRepository, MongoOrderRepository, or InMemoryOrderRepository without touching OrderService. It’s especially useful when the low-level component is volatile, external, expensive to replace, hard to test, or an infrastructure decision that shouldn’t contaminate the domain.

Many of the patterns in the GoF catalog give these principles a concrete structure: Strategy is Open/Closed by composition, Adapter wraps an incompatible type behind the contract the caller already expects, and almost all of them support Dependency Inversion. The patterns topic walks through that catalog one by one.

How to review a software design turns these principles into concrete questions about a real component.

Share this guide

Search by concept, pattern or practice.