- Single Responsibility: one reason to change, meaning one actor or concern per class.
- Open/Closed: add new behaviour (a new payment method) by adding a class, not editing a
switch. - Liskov Substitution: subclasses must honour the parent's contract. No surprise exceptions, no stronger preconditions.
- Interface Segregation: small, role-based interfaces, so a cloud kitchen isn't forced to implement table bookings.
- Dependency Inversion: business logic depends on interfaces; databases and SMS gateways plug in from outside.
- Apply SOLID where change is likely. An interface for every class is over-engineering, and interviewers penalise it too.
SOLID is asked in two very different ways. In a theory round, someone asks "what does the L stand for?" and you recite a definition. In an LLD or machine coding round, nobody says the word SOLID at all. The interviewer says "now add wallet payments" or "now send WhatsApp instead of SMS" and watches how many of your files change. The second kind is where offers are decided.
So this guide does not stop at definitions. For each principle you get a realistic before-and-after in Java, the smell that tells you it is being violated, and how it shows up in interviews. The last sections cover how SOLID drives LLD answers and when applying it becomes over-engineering. If you need a refresher on the OOP basics underneath, start with the OOP concepts interview questions.
SOLID Principles at a Glance
| Principle | One-line meaning | Smell that signals a violation | Typical fix |
|---|---|---|---|
| Single Responsibility (SRP) | One reason to change | Class name ends in "Manager" and imports SMTP, JDBC and PDF libraries | Split by actor/concern |
| Open/Closed (OCP) | Extend without modifying | switch on a type that grows every sprint | Polymorphism, Strategy, registry |
| Liskov Substitution (LSP) | Subtypes are substitutable | Overrides that throw UnsupportedOperationException; instanceof checks in callers | Fix the hierarchy; split interfaces; composition |
| Interface Segregation (ISP) | No client depends on unused methods | Empty or throwing method bodies to satisfy an interface | Role interfaces |
| Dependency Inversion (DIP) | Depend on abstractions | new MySqlRepository() inside business logic; untestable without a database | Interfaces owned by the domain, constructor injection |
The principles were collected and popularised by Robert C. Martin; the acronym itself was coined later. They are guidelines for managing change, not laws. Every one of them trades some directness for flexibility at a specific point, and knowing where that trade is worth it is the actual skill.
S: Single Responsibility Principle with Example
The common phrasing is "a class should do one thing", which is too vague to use. A sharper version, from Martin himself: a module should be responsible to one, and only one, actor. An actor is a group of people who request changes: the finance team, the ops team, the marketing team. If two actors can independently demand changes to the same class, it has two responsibilities.
Before: an invoice class that serves three teams
public class InvoiceService {
public void generateAndSend(Order order) {
// 1. Tax rules (finance team changes these)
double gst = order.subtotal() * 0.18;
double total = order.subtotal() + gst;
// 2. PDF layout (design/brand team changes this)
String pdf = "<h1>Invoice " + order.id() + "</h1><p>Total: " + total + "</p>";
// 3. Delivery channel (ops team changes this)
SmtpClient smtp = new SmtpClient("smtp.company.com", 587);
smtp.send(order.customerEmail(), "Your invoice", pdf);
// 4. Persistence (platform team changes this)
Connection c = DriverManager.getConnection("jdbc:mysql://db/invoices");
// INSERT ...
}
}
Four actors, one class. A GST rule change risks breaking email delivery because they share a method and a test suite. You cannot unit-test the tax calculation without an SMTP server and a MySQL instance. (It also uses double for money, which is a separate sin.)
After: one class per reason to change
record Invoice(String orderId, String email, long subtotalPaise, long gstPaise) {
long totalPaise() { return subtotalPaise + gstPaise; }
}
final class InvoiceCalculator { // finance
private static final int GST_BASIS_POINTS = 1800; // 18%
Invoice calculate(Order order) {
long gst = order.subtotalPaise() * GST_BASIS_POINTS / 10_000;
return new Invoice(order.id(), order.customerEmail(), order.subtotalPaise(), gst);
}
}
interface InvoiceRenderer { byte[] render(Invoice invoice); } // design
interface InvoiceDelivery { void deliver(Invoice invoice, byte[] doc); } // ops
interface InvoiceRepository { void save(Invoice invoice); } // platform
final class InvoiceService { // orchestration only
private final InvoiceCalculator calculator;
private final InvoiceRenderer renderer;
private final InvoiceDelivery delivery;
private final InvoiceRepository repository;
InvoiceService(InvoiceCalculator calculator, InvoiceRenderer renderer,
InvoiceDelivery delivery, InvoiceRepository repository) {
this.calculator = calculator;
this.renderer = renderer;
this.delivery = delivery;
this.repository = repository;
}
void generateAndSend(Order order) {
Invoice invoice = calculator.calculate(order);
repository.save(invoice);
delivery.deliver(invoice, renderer.render(invoice));
}
}
Now the tax rule is a pure function you can test in milliseconds, the PDF layout can change without touching tax code, and switching from email to WhatsApp is a new InvoiceDelivery. The orchestrating service still exists, and that is fine: coordinating a workflow is itself one responsibility.
Money class with add, subtract, multiply and format methods has one responsibility: representing money. Splitting it into MoneyAdder and MoneyFormatter is fragmentation, not SRP. Group things that change together; separate things that change for different reasons.How interviewers test SRP
Usually with a code snippet: "review this class". Point to the imports first (SMTP, JDBC and a PDF library in one file is a fast tell), then name the actors who would request changes. In LLD rounds, SRP shows up as "where does the fee calculation live?" In the parking lot design, putting pricing inside Ticket or ParkingLot is the SRP mistake interviewers look for.
O: Open/Closed Principle with Example
Software entities should be open for extension but closed for modification. In practice: when a new variant arrives, you add a new class; you do not edit the class that already works and is already tested.
Before: a payment switch that grows forever
public class PaymentProcessor {
public PaymentResult pay(String method, long amountPaise, Map<String, String> details) {
switch (method) {
case "UPI":
return upiClient.collect(details.get("vpa"), amountPaise);
case "CARD":
return cardGateway.charge(details.get("token"), amountPaise);
case "NETBANKING":
return netbanking.redirect(details.get("bank"), amountPaise);
default:
throw new IllegalArgumentException("Unsupported: " + method);
}
}
}
Adding wallets, EMI or "buy now pay later" means editing this method each time, re-testing every branch, and merging conflicts between teams working on different methods. And this switch rarely lives alone: refunds, fee calculation and reconciliation each grow the same switch elsewhere.
After: one class per payment method, registered by name
interface PaymentMethod {
String code(); // "UPI", "CARD", ...
PaymentResult pay(long amountPaise, Map<String, String> details);
}
final class UpiPayment implements PaymentMethod {
private final UpiClient client;
UpiPayment(UpiClient client) { this.client = client; }
public String code() { return "UPI"; }
public PaymentResult pay(long amountPaise, Map<String, String> details) {
return client.collect(details.get("vpa"), amountPaise);
}
}
final class CardPayment implements PaymentMethod {
private final CardGateway gateway;
CardPayment(CardGateway gateway) { this.gateway = gateway; }
public String code() { return "CARD"; }
public PaymentResult pay(long amountPaise, Map<String, String> details) {
return gateway.charge(details.get("token"), amountPaise);
}
}
final class PaymentProcessor {
private final Map<String, PaymentMethod> methods = new HashMap<>();
PaymentProcessor(List<PaymentMethod> available) {
for (PaymentMethod m : available) methods.put(m.code(), m);
}
PaymentResult pay(String code, long amountPaise, Map<String, String> details) {
PaymentMethod m = methods.get(code);
if (m == null) throw new IllegalArgumentException("Unsupported: " + code);
return m.pay(amountPaise, details);
}
}
Adding a wallet is now a WalletPayment class plus one line where the list is assembled (or zero lines, if a framework like Spring injects all PaymentMethod beans automatically). PaymentProcessor never changes. This is the Strategy pattern used for OCP, and the same structure appears in the top 10 design patterns.
What "closed" really means
No real code is closed against every change. If the interviewer asks "what if payments now need a two-step authorise-then-capture flow?", the interface itself changes. OCP is about choosing which changes to make cheap. You protect against the variation you expect (new payment methods), not every conceivable one. Saying that explicitly is a mature answer.
L: Liskov Substitution Principle with Example
Formally: if S is a subtype of T, objects of type T can be replaced with objects of type S without breaking the program's correctness. Practically, a subclass must honour the parent's contract, not just its method signatures:
- It may not strengthen preconditions (demand more from callers).
- It may not weaken postconditions (promise less to callers).
- It must preserve invariants of the parent.
- It should not throw new kinds of exceptions callers cannot expect.
The classic: Square extends Rectangle
class Rectangle {
protected int width, height;
void setWidth(int w) { width = w; }
void setHeight(int h) { height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { width = w; height = w; }
@Override void setHeight(int h) { width = h; height = h; }
}
static void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // fails for Square: area is 16
}
Geometrically a square is a rectangle; behaviourally, a mutable square is not a mutable rectangle, because Rectangle's implicit contract says width and height change independently. Fixes: make shapes immutable (a Square with a side and a Rectangle with width and height, both implementing Shape.area()), or do not relate them by inheritance at all.
A realistic one: refunds and cash on delivery
interface PaymentMethod {
PaymentResult pay(long amountPaise);
void refund(String paymentId, long amountPaise);
}
final class CashOnDelivery implements PaymentMethod {
public PaymentResult pay(long amountPaise) { return PaymentResult.pendingCollection(); }
public void refund(String paymentId, long amountPaise) {
throw new UnsupportedOperationException("COD cannot be refunded online");
}
}
// Somewhere in the returns flow:
void processReturn(Order order) {
order.paymentMethod().refund(order.paymentId(), order.totalPaise()); // blows up for COD orders
}
The returns flow was written against PaymentMethod and is correct for every method except one, which breaks the contract. The tell-tale fix people reach for is if (method instanceof CashOnDelivery) in the caller, which spreads the problem. The correct fix is to model the capability honestly:
interface PaymentMethod {
PaymentResult pay(long amountPaise);
}
interface RefundablePayment extends PaymentMethod {
void refund(String paymentId, long amountPaise);
}
final class UpiPayment implements RefundablePayment { /* pay + refund to source */ }
final class CashOnDelivery implements PaymentMethod { /* pay only */ }
void processReturn(Order order, RefundRouter router) {
if (order.paymentMethod() instanceof RefundablePayment r) {
r.refund(order.paymentId(), order.totalPaise());
} else {
router.creditToWalletOrBank(order); // explicit business decision for COD
}
}
There is still one type check, but now it expresses a real business rule ("COD refunds go to wallet or bank transfer") instead of guarding against a broken promise. Notice that the fix also applied ISP: splitting the interface by capability.
LSP violations in the JDK
java.sql.Timestamp extends java.util.Date. Its Javadoc warns thatTimestamp.equalsis not symmetric withDate.equalsand that the inheritance is an implementation detail, so you should not treat a Timestamp as a Date. That is a documented substitution failure.Stack extends Vector. AStackinheritsinsertElementAtandremove(int), so callers can violate LIFO. Joshua Bloch's Effective Java cites it as a case where composition should have been used; the modern replacement isArrayDeque.- Unmodifiable collections.
List.of(...)andCollections.unmodifiableListthrow onadd. Java documents mutators as "optional operations", which makes it legal, but it is the reason code that receives aListcannot safely assume it can mutate it.
Citing one of these in an interview is memorable because it shows you have seen the principle outside textbook shapes. The Java interview questions guide covers collections behaviour in more detail.
I: Interface Segregation Principle with Example
No client should be forced to depend on methods it does not use. Fat interfaces force implementers to write empty or throwing methods, and force callers to depend on far more surface than they need.
Before: one interface for every kind of restaurant partner
interface RestaurantPartner {
void acceptOrder(Order order);
void updateMenu(Menu menu);
void markOutOfStock(String itemId);
void confirmTableBooking(Booking booking); // dine-in only
void publishDineOutOffer(Offer offer); // dine-in only
}
final class CloudKitchen implements RestaurantPartner {
public void acceptOrder(Order order) { /* ... */ }
public void updateMenu(Menu menu) { /* ... */ }
public void markOutOfStock(String itemId) { /* ... */ }
public void confirmTableBooking(Booking b) { throw new UnsupportedOperationException(); }
public void publishDineOutOffer(Offer o) { throw new UnsupportedOperationException(); }
}
A delivery-only cloud kitchen, a very common format in Indian food delivery, has no tables. It implements two methods by throwing, which is also an LSP problem. And the order-dispatch module that only calls acceptOrder now depends on booking and offer types, so a change to Booking forces it to recompile.
After: role interfaces
interface DeliveryPartner {
void acceptOrder(Order order);
}
interface MenuManaged {
void updateMenu(Menu menu);
void markOutOfStock(String itemId);
}
interface DineInPartner {
void confirmTableBooking(Booking booking);
void publishDineOutOffer(Offer offer);
}
final class CloudKitchen implements DeliveryPartner, MenuManaged { /* ... */ }
final class FullServiceRestaurant implements DeliveryPartner, MenuManaged, DineInPartner { /* ... */ }
final class OrderDispatcher {
void dispatch(Order order, DeliveryPartner partner) { partner.acceptOrder(order); }
}
Each client depends on exactly the role it uses. The dispatcher sees only DeliveryPartner. The JDK follows the same idea: Comparable, AutoCloseable, Runnable and Iterable are each one method.
ISP vs SRP, the favourite follow-up
SRP looks at the implementation: why would this class change? ISP looks at the clients: what does each caller actually need to see? A class can have one responsibility and still offer a fat interface to callers who need a slice of it; you can segregate the interface without splitting the class (a class can implement several role interfaces).
D: Dependency Inversion Principle with Example
Two parts: high-level modules should not depend on low-level modules; both should depend on abstractions, and abstractions should not depend on details; details should depend on abstractions. "Inversion" refers to ownership: the business layer defines the interface it needs, and the infrastructure layer implements it, so the source-code dependency points from infrastructure to business, against the flow of control.
Before: business logic welded to MySQL and an SMS vendor
public class OrderService {
private final MySqlOrderRepository repo = new MySqlOrderRepository("jdbc:mysql://prod-db/orders");
private final Msg91SmsClient sms = new Msg91SmsClient(System.getenv("SMS_KEY"));
public void placeOrder(Order order) {
repo.insert(order);
sms.send(order.phone(), "Order " + order.id() + " confirmed");
}
}
You cannot test placeOrder without a production-like database and a real SMS key. Switching SMS vendors, which Indian startups do regularly for pricing and delivery-rate reasons, means editing the order service.
After: the domain owns the interfaces
// Defined in the order domain package: what the business needs, in its own words
interface OrderRepository { void save(Order order); }
interface CustomerNotifier { void orderConfirmed(Order order); }
final class OrderService {
private final OrderRepository orders;
private final CustomerNotifier notifier;
OrderService(OrderRepository orders, CustomerNotifier notifier) { // constructor injection
this.orders = orders;
this.notifier = notifier;
}
void placeOrder(Order order) {
orders.save(order);
notifier.orderConfirmed(order);
}
}
// Infrastructure package: details depend on the domain's abstractions
final class MySqlOrderRepository implements OrderRepository { /* JDBC */ }
final class SmsNotifier implements CustomerNotifier { /* vendor client */ }
// Test: no database, no network
final class InMemoryOrders implements OrderRepository {
final List<Order> saved = new ArrayList<>();
public void save(Order order) { saved.add(order); }
}
Note the interface names: CustomerNotifier.orderConfirmed, not SmsSender.send. A domain-owned abstraction speaks the domain's language, so switching from SMS to WhatsApp to push notifications does not change it.
DIP vs DI vs IoC
| Term | What it is | Example |
|---|---|---|
| Dependency Inversion Principle | A design principle about which way dependencies point | OrderService depends on OrderRepository, an interface it owns |
| Dependency Injection | A technique for supplying dependencies from outside | Passing OrderRepository into the constructor |
| Inversion of Control | A broader idea: a framework calls your code and manages object lifecycles | Spring creating beans and wiring them together |
You can follow DIP with plain constructors and no framework. That distinction is a frequent interview question; the Spring Boot interview questions cover the framework side.
How SOLID Shows Up in an LLD Round
In a design round, nobody grades you on reciting principles. They grade the consequences. Here is how each principle maps to decisions in common LLD problems:
| LLD problem | Decision | Principle |
|---|---|---|
| Parking lot | Pricing behind PricingStrategy; allocation behind SpotAllocationStrategy | OCP, SRP |
| Splitwise | Each split type validates and computes itself; simplifier is pluggable | OCP, SRP |
| Library management | Notifications via listeners; fine policy separate from loans | DIP, SRP |
| Chess | Pieces substitutable through one Piece contract; game never switches on type | LSP, OCP |
| Vending machine | State interface with defaults; each state implements only legal actions | ISP-flavoured, OCP |
The interviewer's favourite technique is the change request: "now add weekend pricing", "now add a crocodile to snake and ladder", "now support WhatsApp". Before answering, say which files change and which do not. If the answer is "one new class, one line of wiring", you have demonstrated OCP and SRP without naming them. Then name them.
The Over-Engineering Trap
The opposite failure is just as common, especially among candidates who have recently studied patterns. Here is a calculator that "follows SOLID":
interface Operation { int apply(int a, int b); }
interface OperationFactory { Operation create(String symbol); }
interface CalculatorInputParser { ParsedExpression parse(String input); }
interface ResultFormatter { String format(int result); }
interface ResultFormatterFactory { ResultFormatter create(Locale locale); }
// ... plus AbstractOperation, OperationRegistry, CalculatorFacade, CalculatorBuilder
// for a program that adds two numbers
Every interface here has exactly one implementation and no foreseeable second one. Each layer adds a file to read and a hop to follow, and none of it protects against a change anyone expects. In a 90-minute machine coding round, this also means you run out of time before anything works, which the machine coding round guide identifies as the most common way to fail.
Rules of thumb for applying SOLID with judgment
- Abstract at seams of known variation. Payment methods, pricing rules, notification channels and storage backends vary in practice. A
GstCalculatorwith one formula usually does not need an interface. - Wait for the second case, or the rule of three. Write the concrete class first. When the second variant arrives, extract the interface. When a third arrives, you know the abstraction's real shape.
- Prefer configuration to classes for data differences. "Students borrow 5 books, faculty 10" is an enum field, not a class hierarchy.
- Keep the interface owned by the caller. If you cannot name what the domain needs from an abstraction in its own words, you probably do not need it yet.
- Say the trade-off out loud. "I am not adding a factory here because there is one implementation; if a second appears, extracting it is a five-minute refactor." Interviewers reward this.
SOLID Interview Questions and How to Answer Them
1. "Explain SOLID with an example from your project."
Pick one principle you genuinely applied, not all five. For example: "Our notification module started as SMS-only inside the order service. When we added email and push, I extracted a Notifier interface owned by the order domain and moved each channel into its own class. That is DIP plus OCP: adding WhatsApp later was one class and a config entry." Then mention a measurable effect if you have one. The guide to explaining projects helps structure this.
2. "Which SOLID principle does this code violate?"
Read the code for smells in this order: imports (SRP), switch on type (OCP), UnsupportedOperationException or instanceof in callers (LSP/ISP), new of infrastructure classes inside business logic (DIP). Name the principle, the consequence ("can't test without a DB"), and the smallest fix.
3. "Is it OK to violate SOLID?"
Yes, deliberately. A script, a prototype, or a class with one stable implementation does not need abstractions. Value objects and DTOs routinely have no interfaces. The principles cost indirection, so you pay that cost where change is expected.
4. "How does LSP relate to design by contract?"
LSP is behavioural subtyping: a subtype must accept at least what the parent accepts (preconditions no stronger), deliver at least what the parent promises (postconditions no weaker), and keep the parent's invariants. Design by contract makes those rules explicit; LSP says subclasses must respect them.
5. "Does Spring automatically make my code follow DIP?"
No. Spring does dependency injection. If your service injects a concrete MySqlOrderRepository class rather than an interface owned by your domain, you are using DI and still violating DIP.
6. "Which principle matters most?"
There is no official answer, but a defensible one: SRP and DIP have the biggest day-to-day impact on testability, while OCP is the one LLD interviewers probe most through change requests. Give your reasoning rather than a single word.
SOLID pairs naturally with design patterns: Strategy and Decorator are OCP tools, Observer is a DIP tool, and Adapter often fixes ISP mismatches. Continue with the top 10 design patterns for interviews, then practise applying both in the LLD interview guide.
Frequently Asked Questions
Turn Principles into Interview-Ready Designs
Prepflix's LLD course applies SOLID to real problems like parking lots, Splitwise and elevators, with reviewers pointing out exactly where your design bends or breaks.
Explore the Prepflix Course →