← Back to the section

The acronym SOLID stands for five principles of class design. Three of them — SRP, ISP and DIP — were formulated by Robert Martin; open-closed by Bertrand Meyer (1988), substitution by Barbara Liskov (1987); Michael Feathers folded them into the acronym. They are often explained with examples about geometric shapes and feel like theory for theory's sake. In practice they 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?".

new customer type: PARTNER DiscountCalculator case VIP → 10% case EMPLOYEE → 20% case PARTNER → 15% editing the existing class the whole class gets retested DiscountCalculator DiscountPolicy Vip Employee Partner adding a new class existing ones untouched

One requirement arrives, and there are two ways to absorb it: write a branch into the existing class — and retest the whole class; or add a new class behind an interface — and leave the old ones alone. This is the shared mechanic of OCP and DIP.

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 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. 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 a different 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:

// don't do this
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:

live example

import java.math.BigDecimal;
import java.util.List;

public class DiscountDemo {

    interface DiscountPolicy {
        boolean supports(String customerType);
        BigDecimal discount(BigDecimal total);
    }

    record VipPolicy() implements DiscountPolicy {
        public boolean supports(String customerType) { return "VIP".equals(customerType); }
        public BigDecimal discount(BigDecimal total) { return total.multiply(new BigDecimal("0.10")); }
    }

    // a new customer type — a new class, the old ones untouched
    record PartnerPolicy() implements DiscountPolicy {
        public boolean supports(String customerType) { return "PARTNER".equals(customerType); }
        public BigDecimal discount(BigDecimal total) { return total.multiply(new BigDecimal("0.15")); }
    }

    static BigDecimal discount(List<DiscountPolicy> policies, String customerType, BigDecimal total) {
        return policies.stream()
                .filter(p -> p.supports(customerType))
                .findFirst()
                .map(p -> p.discount(total))
                .orElse(BigDecimal.ZERO);
    }

    public static void main(String[] args) {
        List<DiscountPolicy> policies = List.of(new VipPolicy(), new PartnerPolicy());
        BigDecimal total = new BigDecimal("1000");
        System.out.println("VIP:     " + discount(policies, "VIP", total));
        System.out.println("PARTNER: " + discount(policies, "PARTNER", total));
        System.out.println("REGULAR: " + discount(policies, "REGULAR", total));
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

It prints 100.00, 150.00 and 0: the partner discount arrived as a new class, while the discount method did not change.

A caveat: OCP does not mean "create interfaces just in case". If there are only two variants and no new ones expected — a switch is more honest.

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: the subclass takes a ready implementation and declares the inconvenient part of the contract unsupported. The class calls itself a repository, but delete throws an exception — and code that worked with the base class breaks with the "specialized" one.

The correct form is not inheritance but composition: the new class implements the same interface and honestly fulfills its entire contract. In the program below both variants are passed into the same removeAndReport method:

live example

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class LspDemo {

    interface ProductRepository {
        Optional<String> findById(int id);
        void delete(int id);
    }

    static class MapRepository implements ProductRepository {
        private final Map<Integer, String> rows = new HashMap<>(Map.of(1, "coffee grinder"));
        public Optional<String> findById(int id) { return Optional.ofNullable(rows.get(id)); }
        public void delete(int id) { rows.remove(id); }
    }

    // do this: composition — the cache delegates and evicts itself
    static class CachingRepository implements ProductRepository {
        private final Map<Integer, String> cache = new HashMap<>();
        private final ProductRepository delegate;
        CachingRepository(ProductRepository delegate) { this.delegate = delegate; }
        public Optional<String> findById(int id) { return Optional.ofNullable(cache.computeIfAbsent(id, key -> delegate.findById(key).orElse(null))); }
        public void delete(int id) { delegate.delete(id); cache.remove(id); }
    }

    // don't do this: the subclass narrowed the contract
    static class ReadOnlyRepository extends MapRepository {
        @Override public void delete(int id) { throw new UnsupportedOperationException("read only"); }
    }

    static void removeAndReport(String name, ProductRepository repo) {
        try {
            repo.delete(1);
            System.out.println(name + ": after delete " + repo.findById(1));
        } catch (UnsupportedOperationException e) {
            System.out.println(name + ": contract broken — " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        removeAndReport("composition", new CachingRepository(new MapRepository()));
        removeAndReport("inheritance", new ReadOnlyRepository());
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The first line is Optional.empty: the product is gone both from the storage and from the cache. The second is a message about a broken contract: the calling code is the same, only the second implementation fails. That is how LSP shows itself — not inside the offending class, but at whoever uses it.

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: findForListing, findForExport, archiveOlderThan pile onto the original save and findById:

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 cover both interfaces — that's fine. What matters is that a 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.

// don't do this
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. Switching the transport to push notifications means editing Order, and testing cancellation without a real SMTP server is 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.

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 call — hence "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. UnsupportedOperationException in an overridden method is a sure sign of a violation.
  • 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).