SevyDevy

Guide · System design

SOLID Principles Explained: One Java Codebase, Five Rules

28 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The SOLID principles are five rules for shaping classes so that one change touches one small place. A class is a blueprint that bundles data with the code that uses it.
  • Single responsibility: a class has one reason to change, meaning one group of people who ask for its changes. Open closed: you add behaviour by adding new code, not by editing working code.
  • Liskov substitution: a subclass, a class built on a parent class, keeps every promise the parent makes. Interface segregation: each caller gets a small interface, a list of methods with no code, holding only what it uses.
  • Dependency inversion: business rules depend on interfaces they own, and details like the database plug into them. All five are tools for code that keeps changing, not laws.
  • Beginners can stop after the section on when not to use SOLID. Engineers then get tested Java: a contract test (one test every subclass must pass) where naive rupee rounding broke 88,970 of 120,000 bills. Also why Java's own List throws on add, and how sealed types, which fix the list of subclasses, trade away open closed.

What goes wrong when one class does every job in your app?

Every change lands in the same file, so a fix for one feature can quietly break another feature that shares its code. The SOLID principles are five rules that stop that spread.

Picture a common setup. A small team runs a bill-splitting app for flatmates in Pune. Flatmates add rent, groceries, the wifi bill and the cook's salary, and the app works out who owes whom each month. The setup is made up, but the problem is real.

Two years in, about 40,000 flats use it. Almost everything still lives in one Java class called ExpenseService, now about 1,400 lines long. It works out shares, saves expenses to a PostgreSQL database, sends SMS and email reminders, and builds a monthly PDF summary.

Look at the diagram below. Every arrow starts at the same box, so any change to any job means opening that one file.

The app before SOLID

Notice that every arrow leaves the same box. A change to any one job means editing and retesting the whole class.

Diagram connections:

  • Bill-splitting mobile app to ExpenseService
  • ExpenseService to Share maths
  • ExpenseService to PostgreSQL
  • ExpenseService to SMS reminders
  • ExpenseService to Email reminders
  • ExpenseService to Monthly PDF summary
  • Bill-splitting mobile app: Flatmates add expenses here
  • ExpenseService: About 1,400 lines, does everything
  • Share maths: A long switch on the split type
  • PostgreSQL: SQL written inside the same class
  • SMS reminders: Message text built in the same class
  • Email reminders: Shares helper methods with SMS
  • Monthly PDF summary: Reads the share maths directly

Then the tech lead asks for a new feature. Some flats want to split rent by percentage, because one flatmate has the bigger room and pays 40%. A developer adds one more case to the big switch statement, the block that picks code by the split type.

While in there, the developer changes a shared helper that rounds amounts, so it now returns whole rupees instead of paise. The reminder code also calls that helper and still treats the result as paise. The release goes out on Friday.

On Saturday morning, about 6,000 reminder SMS go out with every amount a hundred times too small. A rent share of Rs 8,500 reads as Rs 85. In the incident review, the tech lead asks one question: why did a change to splitting break reminders?

The honest answer is that splitting and reminders lived in the same class and shared the same helpers. Nothing failed to compile, and no test covered both.

Robert C. Martin named this decay in his 2000 paper Design Principles and Design Patterns. He listed four symptoms: rigidity (hard to change), fragility, immobility (hard to reuse) and viscosity (the proper fix is harder than a hack). The app shows fragility: it breaks in places with no real link to the change.

This guide fixes the app one principle at a time. Every Java sample is a complete file that runs with one command. We ran each one on OpenJDK 21.0.12.1, a free build of Java, and all but one also on 17.0.20.1.

Which words do you need before learning SOLID?

You need five words: class, interface, subclass, dependency and contract. Each gets a plain meaning and an example from the app.

  • Class: a blueprint for objects that bundles data with the methods that use it. An object is one actual thing built from that blueprint, such as one expense. Object-oriented code is code built from such objects. Example: ExpenseService holds the logic for adding an expense.
  • Interface: a list of method names a class promises to provide, with no working code inside. Example: a SplitRule interface says any rule must have a split method.
  • Subclass: a class that extends another class, called its parent, and inherits its methods. It can replace some of them. Example: a RoundedSplit class that extends EqualSplit. Subtype is the wider word: any class that can stand in for a parent class or an interface.
  • Dependency: a class depends on another when it uses it, so a change there can force a change here. Example: ExpenseService depends on the database code because it calls it.
  • Contract: what a method promises its callers, written down or not. Example: the shares returned for an expense always add up to the full amount.

What are the SOLID principles?

The SOLID principles are five design rules for object-oriented code that stop one change from spreading across many classes. Each letter of SOLID names one rule.

  • S is the single responsibility principle: one class, one reason to change.
  • O is the open closed principle: add new behaviour without editing working code.
  • L is the Liskov substitution principle: a subtype must behave the way its parent promises.
  • I is the interface segregation principle: small interfaces shaped for each caller.
  • D is the dependency inversion principle: business rules depend on interfaces, not on details.

The ideas are older than the name. Martin's 2000 paper set out four of the five: open closed, Liskov substitution, interface segregation and dependency inversion. In it he credits the open closed idea to Bertrand Meyer and the substitution idea to Barbara Liskov.

Single responsibility is not in that paper. In a 2014 post, Martin says he pulled it together from older ideas in the late 1990s. Michael Feathers is credited with arranging the letters into SOLID around 2004.

All five chase one goal. When a request comes in, you want to change one small place and leave the rest alone. In a 2020 post, Martin argued that the principles remain as relevant as they were in the 1990s.

One picture helps. Think of a carpenter's workshop that builds modular wardrobes. Each wardrobe is not one big box. It is a drawer unit, a hanging unit and a shoe unit, built separately and screwed together.

  • Each unit is a class with one job. Deeper drawers mean rebuilding only the drawer unit.
  • A row of shelf-pin holes inside a side panel is an interface. A new shelf plugs in without cutting the panel.
  • A standard hinge hole is an interface the door owns. Any hinge that fits it will do, whoever made it.

Where the picture breaks: wood shows a bad cut at once, but code that breaks a promise still compiles and runs. That gap is why the Liskov section below needs tests, not just a good eye.

The five SOLID principles on the bill-splitting app
PrincipleThe rule in plain wordsThe smell in the appThe fix
Single responsibilityOne class, one group of people asking for its changesSplitting, saving, reminders and PDFs in one classOne class per job, plus a thin coordinator
Open closedAdd new behaviour by adding codeA switch that grows with every new split typeA SplitRule interface with one class per rule
Liskov substitutionA subtype keeps every promise of its parentA rounded split whose shares stop adding upKeep the contract, and test every rule against it
Interface segregationNo class depends on methods it never usesA push sender forced to fake an SMS methodSmall sender interfaces, one per channel
Dependency inversionBusiness rules own the interfaces, details plug inExpenseService creating the PostgreSQL code itselfPass an ExpenseStore in through the constructor

What is the single responsibility principle?

The single responsibility principle says a class should have one, and only one, reason to change. A reason to change is a group of people who might ask for changes.

Martin's 2014 post on the principle puts it plainly: "This principle is about people." His example is one Employee class whose methods answer to three different executives.

In plain words, a class should answer to one business function. Then a change asked for by one group cannot break what another group needs.

The app has at least four such groups. The product side decides how shares are split, and the support team owns the reminder wording. Whoever runs the database decides how data is stored. Flats who print the summary ask for layout changes.

Back in the workshop, a customer asks for deeper drawers. The carpenter rebuilds only the drawer unit. The hanging unit and the shoe unit are not touched.

The team does the same with ExpenseService. Each job moves into its own class. ExpenseService stays, but it only coordinates: it calls the others in order and holds no details of its own.

The app after splitting by responsibility

Each box now changes for one group of people. ExpenseService only calls the others in order, so a split change stays in SplitCalculator.

Diagram connections:

  • Bill-splitting mobile app to ExpenseService
  • ExpenseService to SplitCalculator (Work out shares)
  • ExpenseService to ExpenseRepository (Save expense)
  • ExpenseService to ReminderSender (Tell flatmates)
  • SummaryBuilder to ExpenseRepository (Reads month's expenses)
  • Bill-splitting mobile app: Sends a new expense
  • ExpenseService: Coordinates, owns no details
  • SplitCalculator: Changes when split rules change
  • ExpenseRepository: Changes when storage changes
  • ReminderSender: Changes when message wording changes
  • SummaryBuilder: Changes when the PDF layout changes

Now replay the Friday release. The percentage split and its rounding helper live in SplitCalculator, and ReminderSender has its own formatting code. A mistake in splitting can still give wrong shares, but it can no longer change how reminders print amounts.

What is the open closed principle?

The open closed principle says a module, meaning a class or a small group of classes, should be open for extension but closed for modification. You add new behaviour by writing new code, not by editing code that already works.

Martin's 2000 paper calls it the most important of the object-oriented design principles. The aim is to change what a module does without changing its source code.

SplitCalculator still has a weak spot. Inside it sits a switch with cases for EQUAL and PERCENT. Next month the product side wants split by shares, where one person counts twice. After that comes split by item, for grocery bills.

Every new type means editing the same tested method again. In the workshop, the answer is the row of shelf-pin holes. When a customer wants another shelf, the carpenter pushes four pins into the holes, and nobody takes a saw to the panel.

For the app, the holes are an interface. Each split type becomes its own class that implements it. Here is the interface and the first rule as one complete file.

java
import java.util.*;

public class EqualSplitDemo {
    public static void main(String[] args) {
        // Rs 1,000 is 100,000 paise, shared by three flatmates.
        Map<String, Long> shares = new EqualSplit().split(100_000, List.of("A", "B", "C"));
        long sum = shares.values().stream().mapToLong(Long::longValue).sum();
        System.out.println(shares + " sum=" + sum);
    }
}

interface SplitRule {
    // Contract: shares are in paise and always add up to totalPaise.
    Map<String, Long> split(long totalPaise, List<String> members);
}

class EqualSplit implements SplitRule {
    @Override
    public Map<String, Long> split(long totalPaise, List<String> members) {
        long base = totalPaise / members.size();
        long leftover = totalPaise % members.size();
        Map<String, Long> shares = new LinkedHashMap<>();
        for (int i = 0; i < members.size(); i++) {
            shares.put(members.get(i), base + (i < leftover ? 1 : 0));
        }
        return shares;
    }
}

Save it as EqualSplitDemo.java and run java EqualSplitDemo.java with Java 11 or newer, which runs a single source file directly. The class with main comes first, because that mode runs the first class in the file.

text
{A=33334, B=33333, C=33333} sum=100000

Three things matter here. Money is a long (a whole-number type) count of paise, never a double, Java's floating-point type, because floating-point numbers cannot store most decimal amounts exactly. In Java, 0.1 + 0.2 prints 0.30000000000000004.

The comment on the interface is the contract every rule must keep. The leftover line is how EqualSplit keeps it: Rs 1,000 split three ways is 100,000 paise. Each person gets 33,333, and the one paisa left over goes to the first person.

SplitCalculator now keeps a map (a lookup table from key to value) from split type to SplitRule and calls the right one. Adding split by shares means writing one new class and adding one map entry. Nothing that already works gets edited.

Split rules plugged into one interface

Follow the arrows: every rule points at the interface, and the calculator knows only the interface. A new split type is one new box.

Diagram connections:

  • SplitCalculator to SplitRule interface (Calls split)
  • EqualSplit to SplitRule interface (Implements)
  • PercentSplit to SplitRule interface (Implements)
  • ShareSplit to SplitRule interface (Implements)
  • ItemSplit to SplitRule interface (Implements)
  • SplitCalculator: Picks a rule by split type
  • SplitRule interface: One method: split
  • EqualSplit: Same share for everyone
  • PercentSplit: Bigger room pays 40%
  • ShareSplit: New: one flatmate counts twice
  • ItemSplit: New: each item has its own payers

There is a catch, and the shelf holes show it too. The holes only help with changes the carpenter expected. If a customer later wants a sliding door, the panel needs new cuts. An interface protects you only against the kinds of change you guessed.

What is the Liskov substitution principle?

The Liskov substitution principle says code written for a parent type must keep working, unchanged, when you hand it any subtype. A subtype may add things, but it must not break a promise the parent made.

Barbara Liskov and Jeannette Wing gave it a formal shape in a 1994 paper. Their rule says any property you can prove about objects of the parent type should also hold for objects of the subtype.

Here is how the app breaks it. One flat hates seeing paise on the screen. A developer writes RoundedSplit, a subclass of EqualSplit that rounds every share to the nearest whole rupee. The code compiles, and every method has the right signature (name, parameter types and return type).

They add up to Rs 999, so one rupee vanishes. Split the same bill six ways and each share rounds up to Rs 167, which adds up to Rs 1,002. The settle-up screen expects the shares to match the bill, so the flat's balance never reaches zero.

Nothing crashed, and that is what makes this kind of bug nasty. The Java compiler checks an override (a subclass's own version of a parent method) for parameter types, return type, access level and checked exceptions (errors a method must declare). It cannot check what the method promises.

The fix keeps the contract. RoundedSplit gives everyone whole rupees, then hands out the leftover rupees one by one, so the shares are Rs 334, Rs 333 and Rs 333. Any paise in the bill go to the first person.

The team also adds one shared contract test: a test that every SplitRule class must pass. It checks the same promises each time:

  • The shares add up to the total, to the last paisa.
  • Every member of the expense gets a share, even if it is zero.
  • No share is negative.
  • The rule accepts any group size the app allows, with no surprise exceptions.

Watch for these warning signs of a broken substitution. A subclass throws an exception for a method its parent supports. Callers check the exact class with instanceof, Java's is-this-class test, before calling. A subtype demands more from its callers, or promises them less.

What is the interface segregation principle?

The interface segregation principle says no class should be forced to depend on methods it does not use. Instead of one big interface, you give each caller a small one shaped for its needs.

Martin's 2000 paper puts it simply: many interfaces made for specific clients beat one general-purpose interface. A client here means any code that calls the interface.

The app's reminders started with one MessageChannel interface. It had four methods: sendSms, sendEmail, sendPush and scheduleReminder. Every channel class had to implement all four.

Then a developer writes PushNotifier for app notifications. It can only send push messages. For the other three methods, it throws UnsupportedOperationException, the exception Java's own libraries use to say an operation is not supported.

Two problems follow. Any code holding a MessageChannel might call sendSms on a push notifier and crash at runtime, while the program runs. And when someone adds a fifth method for a new chat channel, every class must change, even ones that will never use it. So must every test fake, a stand-in class used only in tests.

The fix is three small interfaces: SmsSender, EmailSender and PushSender, each with one method. PushNotifier implements only PushSender. The monthly reminder job asks only for the senders it uses, so it never sees a method it cannot call.

What is the dependency inversion principle?

The dependency inversion principle says high-level business rules should not depend on low-level details like databases. Both should depend on an interface, and the details must follow that interface.

Martin's 2000 paper sums it up as depend upon abstractions, not concretions. An abstraction here is an interface; a concretion is a real class such as the PostgreSQL code. In the direct form of the principle, that interface belongs to the business side, not to the database code.

Look at how ExpenseService gets its helpers. Inside the class, the developer wrote new PostgresExpenseStore() and new SmsGatewayClient(). The business rule creates the details itself, so it is welded to them.

That hurts in two places. Every unit test of ExpenseService, a small automatic test of one class, needs a live database and a fake SMS server, so tests are slow and flaky. And moving to a cheaper SMS provider means editing the business class to change one line of plumbing.

Back to the workshop. Wardrobe doors hang on hinges that sit in a round hole of a standard size. The door does not care which company made the hinge. Any hinge that fits the hole will do.

The app's standard hole is an interface called ExpenseStore, with a save method. The expense module defines it. PostgresExpenseStore implements it. ExpenseService receives an ExpenseStore through its constructor, the special method that runs when an object is created, and never names PostgreSQL.

java
import java.util.*;

// The startup class: the one place that picks real or fake details.
public class ExpenseApp {
    public static void main(String[] args) {
        SplitRule equal = (total, members) -> {
            Map<String, Long> shares = new LinkedHashMap<>();
            for (int i = 0; i < members.size(); i++) {
                long extra = i < total % members.size() ? 1 : 0;
                shares.put(members.get(i), total / members.size() + extra);
            }
            return shares;
        };
        InMemoryExpenseStore store = new InMemoryExpenseStore();
        Notifier notifier = (e, shares) -> System.out.println("notify " + shares);

        ExpenseService service = new ExpenseService(equal, store, notifier);
        service.addExpense(new Expense("wifi", 99_900, List.of("A", "B", "C")));
        System.out.println("saved " + store.saved);
    }
}

record Expense(String what, long totalPaise, List<String> members) {}

interface SplitRule {
    Map<String, Long> split(long totalPaise, List<String> members);
}

interface ExpenseStore {
    void save(Expense e, Map<String, Long> shares);
}

interface Notifier {
    void notifyMembers(Expense e, Map<String, Long> shares);
}

final class ExpenseService {
    private final SplitRule rule;
    private final ExpenseStore store;
    private final Notifier notifier;

    ExpenseService(SplitRule rule, ExpenseStore store, Notifier notifier) {
        this.rule = rule;
        this.store = store;
        this.notifier = notifier;
    }

    void addExpense(Expense e) {
        Map<String, Long> shares = rule.split(e.totalPaise(), e.members());
        store.save(e, shares);
        notifier.notifyMembers(e, shares);
    }
}

// A test double: a plain map instead of a database.
class InMemoryExpenseStore implements ExpenseStore {
    final Map<String, Map<String, Long>> saved = new LinkedHashMap<>();

    public void save(Expense e, Map<String, Long> shares) {
        saved.put(e.what(), shares);
    }
}

Run java ExpenseApp.java and you should see these two lines. The wifi bill is Rs 999, or 99,900 paise, so each flatmate owes 33,300 paise.

text
notify {A=33300, B=33300, C=33300}
saved {wifi={A=33300, B=33300, C=33300}}

What just happened? Every field of ExpenseService is an interface, and nothing inside the class uses the word new. Passing a class what it needs from outside like this is called dependency injection.

Read addExpense on its own and you see only business steps: work out shares, save, tell the flatmates. Expense is a record, Java's short form for a plain data class, so this file needs Java 16 or newer. The split rule here is a lambda, a short unnamed function. That works because SplitRule has exactly one abstract method, a method with no body, which makes it a functional interface.

ExpenseApp is the startup class, and its main method is the one place that chooses real or fake details. Here it picks an in-memory store, a plain map of the kind you would use in tests. In production it would build PostgresExpenseStore instead, and ExpenseService would not change.

Which way the dependencies point

Notice the arrow from PostgresExpenseStore points up to the interface. Before, business code pointed down at the database; that flip is the inversion in the name.

Diagram connections:

  • ExpenseService to ExpenseStore interface (Depends on)
  • PostgresExpenseStore to ExpenseStore interface (Implements)
  • InMemoryExpenseStore to ExpenseStore interface (Implements)
  • Unit tests to ExpenseService (Create it with the fake store)
  • ExpenseService: Business rules, knows no database
  • ExpenseStore interface: Owned by the expense module
  • PostgresExpenseStore: Real storage in production
  • InMemoryExpenseStore: A plain map, for demos and tests
  • Unit tests: Run with no database or network

When should you not apply the SOLID principles?

Skip a SOLID refactor when the code is small, rarely changes, or the change you are preparing for may never come. Every interface and extra class has a cost: more files to read and more jumps to follow.

The team uses a simple rule. Write the plain version first. Add an interface or split a class when the same kind of change shows up a second time, or when a test is painful to write.

These signs say you went too far:

  • An interface has had exactly one implementation for a year, and no test fake either.
  • Following one feature means opening eight files that each hold three lines.
  • New teammates cannot find where the real work happens.
  • Class names end in Manager, Helper or Handler, and none says what it does.

These signs say it is time to apply a principle:

  • A change in one feature keeps breaking another, like splitting and reminders.
  • The same switch statement grows every sprint.
  • Unit tests need a live database or network to run.
  • Two teams keep editing the same file and fighting merge conflicts.

How do you test that every subclass keeps its parent's promise?

Write the parent's contract once as a test, then run every implementation through it over many inputs. The compiler will not do this for you, as the table below shows.

This file holds the three rules from the Liskov section and a contract check. It tries every whole-rupee bill from Rs 1 to Rs 10,000, split among 1 to 12 people: 120,000 cases per rule.

java
import java.util.*;

public class ContractCheck {
    // Returns null if the rule kept every promise for this input.
    static String broken(SplitRule rule, long total, List<String> members) {
        Map<String, Long> s;
        try {
            s = rule.split(total, members);
        } catch (RuntimeException e) {
            return "threw " + e;
        }
        if (!s.keySet().equals(new HashSet<>(members))) return "a member has no share";
        if (s.values().stream().anyMatch(v -> v < 0)) return "a negative share";
        long sum = s.values().stream().mapToLong(Long::longValue).sum();
        return sum == total ? null : "shares add up to " + sum;
    }

    public static void main(String[] args) {
        List<String> three = List.of("A", "B", "C");
        System.out.println("naive " + new NaiveRoundedSplit().split(100_000, three));
        System.out.println("fixed " + new RoundedSplit().split(100_000, three));

        List<SplitRule> rules = List.of(new EqualSplit(), new NaiveRoundedSplit(), new RoundedSplit());
        for (SplitRule rule : rules) {
            int cases = 0, failures = 0;
            String first = null;
            for (int n = 1; n <= 12; n++) {
                List<String> members = new ArrayList<>();
                for (int i = 1; i <= n; i++) members.add("M" + i);
                for (long total = 100; total <= 1_000_000; total += 100) {
                    cases++;
                    String why = broken(rule, total, members);
                    if (why != null && failures++ == 0) {
                        first = total + " paise among " + n + ": " + why;
                    }
                }
            }
            System.out.println(rule.getClass().getSimpleName() + ": " + failures + " of " + cases
                    + " failed" + (first == null ? "" : ", first at " + first));
        }
    }
}

interface SplitRule {
    // Contract: shares are in paise and always add up to totalPaise.
    Map<String, Long> split(long totalPaise, List<String> members);
}

class EqualSplit implements SplitRule {
    @Override
    public Map<String, Long> split(long totalPaise, List<String> members) {
        long base = totalPaise / members.size();
        long leftover = totalPaise % members.size();
        Map<String, Long> shares = new LinkedHashMap<>();
        for (int i = 0; i < members.size(); i++) {
            shares.put(members.get(i), base + (i < leftover ? 1 : 0));
        }
        return shares;
    }
}

// The naive version: rounds every share to the nearest whole rupee.
class NaiveRoundedSplit extends EqualSplit {
    @Override
    public Map<String, Long> split(long totalPaise, List<String> members) {
        long rupees = Math.round(totalPaise / 100.0 / members.size());
        Map<String, Long> shares = new LinkedHashMap<>();
        for (String m : members) shares.put(m, rupees * 100);
        return shares;
    }
}

// The fix: whole rupees, with leftover rupees (and any paise) handed out.
class RoundedSplit extends EqualSplit {
    @Override
    public Map<String, Long> split(long totalPaise, List<String> members) {
        int n = members.size();
        long rupees = totalPaise / 100, paise = totalPaise % 100;
        Map<String, Long> shares = new LinkedHashMap<>();
        for (int i = 0; i < n; i++) {
            long r = rupees / n + (i < rupees % n ? 1 : 0);
            shares.put(members.get(i), r * 100 + (i == 0 ? paise : 0));
        }
        return shares;
    }
}

Run java ContractCheck.java. On OpenJDK 21.0.12.1 and 17.0.20.1 it printed exactly this, in a few seconds including compilation on our 2-core test machine:

text
naive {A=33300, B=33300, C=33300}
fixed {A=33400, B=33300, C=33300}
EqualSplit: 0 of 120000 failed
NaiveRoundedSplit: 88970 of 120000 failed, first at 100 paise among 2: shares add up to 200
RoundedSplit: 0 of 120000 failed

What just happened? The naive subclass broke the contract on 88,970 of 120,000 bills. In 38,365 cases the shares came up short, and in 50,605 they overshot. Even a Rs 1 bill for two people became Rs 2, because Math.round breaks a tie such as 0.5 by rounding up.

Hand-picked examples rarely catch this. Rs 300 among three people passes, so a single test of one ordinary input would pass too. A loop over many inputs is a cheap stand-in for a property-based test, where a library generates the inputs for you.

What javac checks when a subclass overrides a method (tested on javac 21.0.12.1 and 17.0.20.1)
Change in the subclassDoes it compile?What javac said
Throws a checked exception the parent does not declareNooverridden method does not throw IOException
Makes a public method package-privateNoattempting to assign weaker access privileges; was public
Returns a narrower type, Integer instead of NumberYesAllowed: a covariant return type
Returns -1 where the parent returns a totalYesNothing: behaviour is never checked

The first two rows are Liskov rules the language enforces for you. The last row is the one that bit the app, and only a test catches it.

Why does Java's own List interface throw UnsupportedOperationException?

Because Java's designers chose a few large collection interfaces with optional methods over many small ones. It is a deliberate trade against strict interface segregation, and the List documentation says so.

The Java 21 List docs mark add as an "optional operation" that may throw UnsupportedOperationException. Lists made by List.of are unmodifiable: every method that changes them always throws. Arrays.asList gives a fixed-size list, so set works but add throws.

java
import java.util.*;

public class OptionalOps {
    static void tryAdd(String name, List<String> list) {
        try {
            list.add("D");
            System.out.println(name + ": add worked " + list);
        } catch (UnsupportedOperationException e) {
            System.out.println(name + ": add threw UnsupportedOperationException");
        }
    }

    public static void main(String[] args) {
        tryAdd("ArrayList", new ArrayList<>(List.of("A", "B", "C")));
        tryAdd("List.of", List.of("A", "B", "C"));
        tryAdd("Arrays.asList", Arrays.asList("A", "B", "C"));
        List<String> fixedSize = Arrays.asList("A", "B", "C");
        fixedSize.set(0, "Z");
        System.out.println("Arrays.asList: set worked " + fixedSize);
    }
}

Run java OptionalOps.java. It printed the same four lines on Java 21 and 17:

text
ArrayList: add worked [A, B, C, D]
List.of: add threw UnsupportedOperationException
Arrays.asList: add threw UnsupportedOperationException
Arrays.asList: set worked [Z, B, C]

Oracle's Java Collections API Design FAQ explains why. It calls this "the most controversial design decision in the whole API". Giving unmodifiable, fixed-size and other variants their own types would have grown the original four core interfaces to twenty or so, plus five iterators.

The designers judged that a sound compromise: a small set of interfaces that can throw at runtime, with each limit documented. The cost is exactly the PushNotifier problem. A method that accepts a List cannot know from the type alone whether add will work.

So treat interface segregation as a trade-off, not a law. In the app, three tiny sender interfaces cost almost nothing. In the JDK's own collections, with many combinations of abilities, the designers decided fewer types were worth some runtime exceptions.

Does a sealed interface in Java 21 break the open closed principle?

Yes, on purpose. A sealed interface fixes the list of classes that may implement it, so a new split type means editing that list and every switch over it. In return, the compiler finds each pattern switch you must fix, as long as it has no default branch.

Sealed classes and interfaces became final in Java 17, through JEP 409 (a JDK Enhancement Proposal, the record of a Java change). Pattern matching for switch, which lets a switch pick a case by type, became final in Java 21 through JEP 441. With both, the compiler checks that a switch covers every permitted type.

java
import java.util.*;

public class SealedSplit {
    sealed interface Split permits Equal, Percent {}
    record Equal() implements Split {}
    record Percent(Map<String, Integer> percents) implements Split {}

    static String describe(Split s) {
        return switch (s) {
            case Equal e -> "everyone pays the same";
            case Percent p -> "pays by percent " + p.percents();
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(new Equal()));
        System.out.println(describe(new Percent(Map.of("A", 40))));
    }
}

On Java 21 it prints two lines: "everyone pays the same" and "pays by percent {A=40}". On Java 17 it does not compile, because patterns in switch were still a preview there, a trial feature that is off by default.

Now add a third type, Shares, to the permits list without a new case. Java 21 refuses to compile with: the switch expression does not cover all possible input values. That error is the point: a pattern switch with no default branch cannot let a new split type slip past unhandled. Add a default branch and the compiler stays silent, so leave it out.

The two designs make opposite things cheap. With an open SplitRule interface, a new split type is one new class. A new operation, such as describe, means editing every rule, unless one default method (a method with a body inside the interface) fits them all. With a sealed interface, a new operation is one new switch, but a new type touches every switch.

Philip Wadler named this trade-off the expression problem in the late 1990s. Choose the open interface when new split types arrive often, perhaps from other teams. Choose sealed types when the set of types is fixed and you mostly add new operations over it.

How do you explain SOLID principles in a system design interview?

SOLID usually comes up in the low-level design round, where you design classes rather than servers. Explain each principle as a fix for a pain you can show, not as a definition to recite, and use one small example throughout.

  1. Say the shared goal

    Open with one line: the five principles keep one change from spreading across unrelated code.

  2. Pick one running example

    Choose something small, like splitting a bill or booking a seat. Name its classes before you mention any principle.

  3. Show the smell first

    Point to a real problem: a growing switch, a class with five jobs, or a test that needs a database.

  4. Apply the principle that fixes it

    Draw the change, such as one interface with one class per rule. Say which future change is now cheap.

  5. Name the cost

    Admit the extra classes and indirection, meaning more jumps to follow. Say when you would not bother, such as a one-off script.

  6. Tie it to testing

    Show that injected interfaces let you test business rules with fakes, and that a contract test guards every subclass.

Questions people ask

Are the SOLID principles only for Java?
No. They came from object-oriented design, so they fit Java, C# and Kotlin most naturally. The ideas behind them still apply elsewhere: group code that changes together, keep promises, and depend on small interfaces. In Python or TypeScript you can follow them with modules, protocols and plain functions.
Which SOLID principle is the most important?
Martin's 2000 paper called the open closed principle the most important, and the dependency inversion principle its main mechanism. In daily work, single responsibility often gives the quickest win. Pulling apart a class that does five jobs removes many surprise breakages before you need any interface.
What is the difference between dependency inversion and dependency injection?
Dependency inversion is a design rule about direction: business code owns the interface and details implement it. Dependency injection is a technique for handing objects to a class, often through its constructor. Injection helps you follow inversion, but injecting a concrete class still leaves the business code tied to a detail.
Who came up with the SOLID principles?
Robert C. Martin brought them together. His 2000 paper on design principles covers four of them, and he describes the single responsibility principle in a 2014 post as his late-1990s summary of older ideas. Michael Feathers is credited with the SOLID acronym around 2004. The open closed idea is credited to Bertrand Meyer and substitution to Barbara Liskov.
How are SOLID principles different from design patterns?
SOLID principles are rules for judging a design, while design patterns are named, reusable shapes of code. Patterns often apply the principles: the Strategy pattern is a common way to follow the open closed principle, much like the SplitRule interface here. Learn the principles first, then patterns become easier to choose and to skip.
Can the single responsibility principle apply to microservices?
Yes, the same idea scales up. A service should change for one business reason and be owned by one team. If one feature change always means releasing three services together, they are really one responsibility split badly. Whether to split a system into services at all is a separate question with its own costs.
Are SOLID principles still relevant in 2026?
Yes, as long as code keeps changing. Martin argued in 2020 that they remain as relevant as they were in the 1990s. Any long-lived codebase still needs changes to stay local and tests to run without a database, whoever or whatever writes the code.

Sources and further reading

Practise this with Lala

Practise