Home › Low-Level Design › SOLID Principles
S

SOLID Principles

Five rules that keep a class from rotting into a mess nobody wants to touch. I have interviewed well over a hundred people who can recite all five names in order and still write code that violates every one. Reciting SOLID is a 30-second party trick. Designing with it under pressure, while an interviewer keeps throwing new requirements at you, is the actual skill being tested. This guide is about the second thing.

Low-Level DesignMedium11 min read

If you remember five things

The whole answer in one glance. SRP and DIP are the two habits that win rounds; the rest keep you out of trouble.

  1. Intent. Make change cheap and safe. Every principle exists because code gets edited months later by people who are not you.
  2. S splits, D points the arrows. One reason to change per class, and high-level policy depends on abstractions, never on concrete details. These two carry most of the value.
  3. O and L govern extension. Add behavior with a new class, not a new if. A subclass must keep every promise the parent made to its callers.
  4. I keeps interfaces small. Many focused interfaces beat one fat one that half your implementations only partly honor.
  5. The classic gotcha. Over-abstraction. An interface, a factory, and a strategy for a thing with one implementation is a tax, not a design. Wait for the second real case.

1The problem it solves#

Code changes. That is the entire reason these principles exist. If you wrote a class once and never touched it again, SOLID would be pointless.

The first version is almost always fine. The pain shows up later.

  • Four to six months in you need a third payment method, a new CSV export format, or MySQL swapped for Postgres.
  • One small change now forces edits across five files.
  • You re-test behavior that had nothing to do with your change, and break something unrelated.
  • That ripple is the symptom. SOLID is the set of habits that stops the ripple before it starts.

The canonical offender: one class, three jobs.

  • Reads orders from a database, calculates a discount, and emails a receipt.
  • Three reasons to change: the DBA alters the schema, finance rewrites the discount math, marketing redesigns the email.
  • Three separate teams now edit the same file and keep stepping on each other's commits.
  • I have watched a single file like this cause three merge conflicts in one sprint. That is exactly the coupling SOLID forces you to notice and split apart.
What these rules are really for

SOLID is not about clean code for its own sake. It is about the cost of the next change. Every principle below answers one question: when a new requirement lands, do you edit tested code and risk breaking it, or add new code beside it? Keep that lens and the five rules stop being trivia.

2The idea (mental model)#

SOLID is five separate ideas with one shared goal: make change cheap and safe. Here is how I hold each one in my head.

  • S, Single Responsibility. A class should have one reason to change. Not "do one thing," which is too vague. One reason to change, meaning one stakeholder or one axis of change. The DBA, finance, and marketing above are three reasons, so that is three classes.
  • O, Open/Closed. You should add behavior without editing existing, tested code. Add a new class, not a new if branch inside an old method that already passed code review six months ago.
  • L, Liskov Substitution. If B extends A, I should be able to use a B anywhere I expected an A and have nothing surprising happen. A subclass cannot quietly break the promises the parent made to its callers.
  • I, Interface Segregation. Do not force a class to implement methods it does not need. Many small, focused interfaces beat one fat one that half your implementations only partially honor.
  • D, Dependency Inversion. High-level policy code depends on abstractions, not on concrete low-level details. The business logic should not know whether the money moves through Stripe or Razorpay.

S splits classes, O and L govern inheritance and extension, I keeps interfaces small, D points your arrows at abstractions.

3Structure#

Two principles fight for attention most on a whiteboard: Single Responsibility and Dependency Inversion. Here they are on a payment flow.

Bad version, one class does everything.

  • One OrderService talks to the Stripe SDK directly, writes to MySQL directly, and sends email directly.
  • Three reasons to change, all crammed into one class.

Good version, split and depend on interfaces.

  • Responsibilities split out, OrderService depends on interfaces.
  • You can swap Stripe for Razorpay without reopening the order logic at all.
classDiagram class OrderService { -PaymentGateway gateway -OrderRepository repo -Notifier notifier +checkout(order) Receipt } class PaymentGateway { <> +charge(amount) PaymentResult } class OrderRepository { <> +save(order) void } class Notifier { <> +send(message) void } class StripeGateway { +charge(amount) PaymentResult } class RazorpayGateway { +charge(amount) PaymentResult } class SqlOrderRepository { +save(order) void } class EmailNotifier { +send(message) void } PaymentGateway <|-- StripeGateway PaymentGateway <|-- RazorpayGateway OrderRepository <|-- SqlOrderRepository Notifier <|-- EmailNotifier OrderService o-- PaymentGateway OrderService o-- OrderRepository OrderService o-- Notifier
Notice OrderService holds references to three interfaces, never to three concrete classes. The arrows point away from OrderService toward abstractions, not toward StripeGateway. Adding Razorpay means adding one class on the left. OrderService never changes a line, so Open/Closed falls out for free the moment your dependencies point the right way.

Why I teach SRP and DIP as a pair.

  • SRP decides where the seams go: one reason to change per class.
  • DIP decides which way the seams face: policy depends on abstraction.
  • Together they turn "add a provider" into "add a class," which is the whole point.

Here is the runtime flow so you can see who calls whom during a single checkout.

sequenceDiagram participant Client participant OrderService participant PaymentGateway participant OrderRepository participant Notifier Client->>OrderService: checkout(order) OrderService->>PaymentGateway: charge(amount) PaymentGateway-->>OrderService: PaymentResult OrderService->>OrderRepository: save(order) OrderService->>Notifier: send(receipt) OrderService-->>Client: Receipt
The checkout path at runtime. OrderService orchestrates three collaborators but knows none of their concrete types. Every arrow leaving it lands on an interface, which is why a fake can stand in for any of them in a test.

4Implementation in Java#

Start with the interfaces. Each one is small and focused.

  • This is Interface Segregation in practice.
  • No interface here has a method an implementation would be embarrassed to support.
interface PaymentGateway {
    PaymentResult charge(long amountCents);
}

interface OrderRepository {
    void save(Order order);
}

interface Notifier {
    void send(String to, String message);
}

Now the policy class. It depends only on abstractions, handed in through the constructor.

  • There is not a single new StripeGateway() anywhere inside it.
  • Every collaborator arrives from outside, so the class decides what to do, never what to construct.
class OrderService {
    private final PaymentGateway gateway;
    private final OrderRepository repo;
    private final Notifier notifier;

    OrderService(PaymentGateway gateway, OrderRepository repo, Notifier notifier) {
        this.gateway = gateway;
        this.repo = repo;
        this.notifier = notifier;
    }

    Receipt checkout(Order order) {
        PaymentResult result = gateway.charge(order.totalCents());
        if (!result.succeeded()) {
            throw new PaymentFailedException(result.reason());
        }
        order.markPaid(result.transactionId());
        repo.save(order);
        notifier.send(order.customerEmail(), "Thanks for your order");
        return new Receipt(order.id(), result.transactionId());
    }
}

The concrete pieces live on their own.

  • Swapping one never touches OrderService.
  • Each one is free to change for its own reason without disturbing the others.
class StripeGateway implements PaymentGateway {
    public PaymentResult charge(long amountCents) {
        // call Stripe SDK, map the response
        return PaymentResult.ok("stripe_tx_123");
    }
}

class RazorpayGateway implements PaymentGateway {
    public PaymentResult charge(long amountCents) {
        // call Razorpay SDK, map the response
        return PaymentResult.ok("rzp_tx_456");
    }
}

Wiring happens at the edge of the app, in exactly one place.

  • This is the only file that knows the concrete types exist.
OrderService service = new OrderService(
    new StripeGateway(),
    new SqlOrderRepository(dataSource),
    new EmailNotifier(mailClient)
);
Pause before you read on: what is the single clearest proof that Dependency Inversion is actually working in this code, and not just decorating your UML?
Reveal the answer
Testability. To test checkout you pass fakes for all three dependencies: no database, no network, no live Stripe account, no flaky integration setup. The test runs in milliseconds and never hits the wire. If a class is painful to unit-test, it is usually because it constructs its own dependencies, which means the inversion is only on paper.

A quick word on Liskov, since it is the one people fumble most in the direct question.

  • The classic trap is Square extends Rectangle. A rectangle lets you set width and height independently.
  • A square cannot honor that contract without secretly changing the other dimension.
  • So any code that trusted Rectangle's promise ("setting width leaves height alone") breaks the moment you pass it a Square.
  • The fix is not clever overriding. The fix is to not make Square a subtype of Rectangle at all.
  • If the subclass cannot keep the parent's promises, it was never an is-a relationship, no matter what the geometry textbook says.

5A real-world example#

Logging frameworks are the cleanest case I know.

  • SLF4J gives you a Logger interface, and Logback, Log4j2, or a plain console logger implement it.
  • Your application code depends only on Logger.
  • You pick the implementation at startup by what is on the classpath.
  • That is Dependency Inversion shipped to millions of projects, and it is why you swap logging backends without touching a line of business code.

Payment integrations are the one I have lived.

  • In one system we routed Indian cards to one provider and international cards to another.
  • The order code only ever knew about a PaymentGateway interface.
  • Adding the second provider was a new class plus one line in a factory that chose the gateway by currency.
  • The checkout logic, the part that carried the most financial risk and the most tests, never got reopened. That is the payoff you are selling the interviewer on.

Single Responsibility shows up as layers in any decent backend.

  • Controller, Service, Repository.
  • The controller handles HTTP (status codes, request parsing), the service holds business rules, the repository handles persistence.
  • Three reasons to change, three layers.
  • When a junior stuffs a raw SQL query into a controller, that is the SRP violation flagged in the first round of code review every time.

6When to use it, and when not to#

Use SRP and DIP almost always.

  • They cost little and pay off constantly.
  • Splitting responsibilities and depending on interfaces are the two habits I most want to see in an interview.
  • They are also the two that most reliably predict whether someone's code survives a year of edits.

Be careful with Open/Closed and heavy abstraction. This is the classic misuse.

  • An interface, a factory, and a strategy for something with exactly one implementation and no sign of a second is three layers of indirection buying flexibility nobody asked for.
  • I call this the pattern tax.
  • My rule of thumb: the first time, write it inline. The second time you need a variant, that is your signal to extract the abstraction. Not before.
Abstract now
Two real cases exist
Signal
A second payment provider, export format, or storage backend is actually here.
Move
Extract the interface, point policy at it, add classes beside it.
Payoff
Open/Closed falls out; tested code stays shut.
Cost
One interface, justified by a real variant.
Wait
One implementation, hypothetical future
Signal
"We might need another one someday" with nothing concrete.
Move
Write it inline. Keep it simple and deletable.
Payoff
No indirection to read through, no fake flexibility to maintain.
Cost
You refactor later, which is cheap when the seam is obvious.

For throwaway code, skip most of this.

  • A tiny script, a one-off migration, a throwaway prototype: SOLID is overkill.
  • SOLID earns its keep in code that lives for years and gets edited by many hands.
  • A fifty-line tool that runs once and gets deleted does not need an interface, and wrapping it in one just tells your reviewer you cannot judge scale.
Red flag that fails you here

Reaching for three layers of abstraction on a problem with one implementation. A candidate who builds a factory and a strategy for a single gateway worries me exactly as much as one who writes a 300-line god class. Over-engineering is not the safe direction. It signals you cannot read the size of the problem in front of you.

7Common mistakes#

Confusing "one responsibility" with "one method."

  • SRP is about reasons to change, not line count.
  • A class with ten methods that all serve one coherent job is fine.
  • A class with two methods serving two different stakeholders is not.
  • Count stakeholders, not functions.

Open/Closed by adding an if.

  • Someone adds a new payment type by editing a switch (type) block inside an existing method.
  • That is exactly the tested, shipped code you were supposed to leave alone.
  • The OCP-friendly move is a new class behind the interface, with zero edits to the old branch.

Fat interfaces.

  • One Repository interface with twenty methods, and half your implementations throw UnsupportedOperationException for the ones they do not support.
  • That is the textbook Interface Segregation violation, and every one of those exceptions is a landmine for a caller.
  • Split the interface by what clients actually use.

Liskov by exception.

  • A subclass overrides a method and throws "not supported" instead of doing the work.
  • Now callers cannot trust the base type, so they start writing if (x instanceof SpecialThing) to route around it.
  • The type hierarchy is lying, and the instanceof checks are the smell that proves it.

Dependency Inversion in name only.

  • People create an interface, then have the high-level class do new ConcreteThing() inside itself anyway.
  • If you construct the dependency internally, you did not invert anything.
  • The dependency has to come from outside, through the constructor or a setter, or the abstraction is pure theater.

Interface for everything.

  • The opposite overcorrection. Every class gets a one-to-one interface with "I" slapped on the front, permanently paired with its single implementation.
  • That is ceremony, not design, and it doubles your file count for no benefit.
From the interviewer's chair

When I say "now also support SMS alongside email," I am watching one thing: do you add an SmsNotifier class behind the existing Notifier interface, or hack in an if. The class tells me you internalized Open/Closed. The branch tells me you memorized the name but not the habit. I decide a lot about you in that ten-second move.

8Where it shows up in interviews#

Almost every LLD question is secretly a SOLID check.

  • Design a parking lot, a vending machine, a rate limiter, a notification service.
  • I am watching whether your classes have single responsibilities and whether your dependencies point at abstractions.
  • The problem statement is just the stage.

Three things I look for specifically.

  • When I add a requirement ("now also support SMS"), do you add a class or hack in an if? A new class behind the interface is the tell you understood Open/Closed.
  • Can you explain why you made something an interface? "So I can swap it and test it with a fake" is the answer I want, not "because that is the pattern."
  • Do you over-engineer? Three layers of abstraction for a one-implementation problem worries me as much as a god class.

Expect the direct question too.

  • "What is the L in SOLID, give me an example." Have the Rectangle and Square story or a payment-refund story loaded and ready to fire in 20 seconds.
  • Expect the trap where I hand you a bloated class and ask you to improve it.
  • The move is always the same three steps: name the responsibilities out loud, split them into separate classes, then point the dependencies at interfaces. Narrate it in that order and you look like someone who has done this for real.
Follow-ups the interviewer will ask (and your answers)
"What is the real difference between 'one responsibility' and 'one thing'?"
One reason to change, not one action. A class with ten methods serving a single stakeholder is fine; two methods serving finance and marketing is two classes. I count stakeholders and axes of change, not functions or lines.
"Give me a Liskov violation and the fix."
Square extends Rectangle. Rectangle promises setting width leaves height alone; a square cannot keep that without secretly changing the other side, so callers that trusted the parent break. The fix is not overriding tricks. It is refusing the inheritance: if the subtype cannot keep the parent's promises, it was never an is-a relationship.
"I say add SMS alongside email. Walk me through it."
Add an SmsNotifier that implements the existing Notifier interface and wire it at the composition root. OrderService never changes a line. If I instead reached into checkout and added an if for SMS, I would be editing tested code, which is the Open/Closed violation you are testing me for.
"When is SOLID the wrong call?"
When there is one implementation and no second in sight, or the code is a throwaway script. Adding an interface, a factory, and a strategy there is the pattern tax: indirection with no payoff. I write it inline first and extract the abstraction the second time a real variant shows up.
"How does depending on an interface actually help testing?"
The class takes its collaborators from outside, so a test passes fakes for all of them: no database, no network, no live gateway. The test runs in milliseconds. If a class is hard to unit-test, it usually constructs its own dependencies, which means Dependency Inversion is only on paper.

What you can now explain in the room

  • Why SOLID exists: the cost of the next change, not clean code for its own sake.
  • What each letter governs: S splits classes, O and L govern extension, I keeps interfaces small, D points the arrows.
  • Why SRP and DIP reinforce each other, so adding a provider becomes adding a class.
  • The clearest proof DIP is real: you can unit-test the policy class with fakes in milliseconds.
  • The Liskov trap (Square and Rectangle) and why the fix is refusing the inheritance, not overriding it.
  • The classic misuse: over-abstraction, the pattern tax, and why you wait for the second real case.

Next, try applying these to a concrete build: the Strategy and Factory patterns lean hard on Open/Closed and Dependency Inversion, and a "design a parking lot" problem is a great place to practice splitting responsibilities.

Get a prep plan built around your SOLID Principles interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your SOLID Principles prep plan is on the way.