Learn the five SOLID principles by building a real order-processing system in Java, with before and after code for every principle.
25 min read2026-04-07easysolidoopdesign-principleslld
SOLID is a set of five principles that keep object-oriented code from turning into an unmaintainable mess as it grows. Every principle addresses a specific way classes tend to rot. Learn them once, apply them forever.
Letter
Principle
The one-line version
S
Single Responsibility
One class, one job
O
Open/Closed
Extend, don't modify
L
Liskov Substitution
Subclasses must honour parent contracts
I
Interface Segregation
Thin interfaces beat fat ones
D
Dependency Inversion
Depend on abstractions, not concretions
The running example below is an order-processing system for an e-commerce platform. All five principles appear in the same codebase, so you can see how they work together rather than in isolation.
In an interview, the examiner wants to see you apply these principles to your design choices, not just recite the acronym. I use SOLID as a checklist: every class I draw on the whiteboard should pass at least three of these five.
This diagram shows how the order-processing system applies all five SOLID principles. Notice that every arrow points toward an interface, never toward a concrete class.
Comments
S lives in the class boundaries: OrderService orchestrates, EmailNotificationService sends, InvoiceService generates. O lives in Discount: add SeasonalDiscount without editing PricingService. D lives in every dashed arrow pointing at an interface.
A class should have one and only one reason to change.
If a single class handles saving orders, sending confirmation emails, and generating PDF invoices, it has three reasons to change. When the email provider switches, your order-saving logic goes in for a review. That's the smell.
Split along "reasons to change", not just along methods.
Here is what changes when you apply SRP:
The bad version put all three behaviours in one OrderService. After the split, changing your PDF library touches only PdfInvoiceService. Your email provider migration touches only EmailNotificationService. OrderService is never opened.
In an interview, phrase it like this: "I split OrderService into three classes because each has a different reason to change. Email template changes shouldn't require retesting order logic."
Software entities should be open for extension but closed for modification.
You will always need new discount types. If every new discount requires you to crack open OrderService and add another if branch, you are violating OCP. The fix is a strategy interface: define how a discount works, then add new types by adding new classes, not new conditionals.
Here is what changes when you apply OCP:
The pattern here is called the Strategy pattern. OCP and Strategy go hand in hand. When you find yourself with a growing switch or if-else tree based on a "type" field, that's almost always an OCP violation waiting to be refactored.
In an interview, when you draw a service that accepts a strategy interface, say explicitly: "This satisfies Open/Closed because adding a new discount type means adding a class, not modifying PricingService."
Subtypes must be substitutable for their base types without breaking the program's correctness.
LSP is the principle most developers violate accidentally. The classic example is Square extends Rectangle. It compiles. It runs. And then a test that was passing for rectangles silently fails for squares.
Here is what changes when you apply LSP correctly:
The test for LSP: write a function that accepts the base type. It should work correctly when you pass a subtype without knowing the concrete class. If the subtype breaks anything, you have an LSP violation.
My rule of thumb: if you are overriding a method in a way that changes what the method means rather than how it does it, stop and rethink the hierarchy.
Clients should not be forced to depend on interfaces they do not use.
Fat interfaces are contagious. One Repository with 10 methods means every class that needs read-only access must implement (or stub) the write methods too. Split by capability.
Here is what changes when you apply ISP:
The rule of thumb: if a class implementing your interface has to write throw new UnsupportedOperationException() for any method, you have an ISP violation. That method belongs in a different interface.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
DIP is what makes all the other principles actionable. Without it, OrderService would directly instantiate new SqlOrderRepository() and your whole service is glued to one database. With it, you swap databases by swapping the concrete class at composition time.
Here is what changes when you apply DIP:
The composition root pattern is key: one place wires everything together. Every other file depends only on interfaces. This makes the code trivially testable and the database swappable without touching business logic.
This sequence shows what happens when a client calls placeOrder(). Notice that no concrete class name appears in the interaction between OrderService and its dependencies. Every call targets an interface.
Client calls placeOrder() on OrderService. It has no idea which repository or notification service is wired underneath.
OrderService delegates to interfaces. At runtime, OrderRepository might be SqlOrderRepository in production or InMemoryOrderRepository in tests. Same code path, different backends.
Each collaborator has one job. If the email provider changes, only EmailNotificationService is touched. OrderService is never reopened.
This is DIP in action: the arrows in the sequence diagram point at interfaces, not concrete classes.
The same codebase demonstrates all five principles cooperating:
S: OrderService, EmailNotificationService, PdfInvoiceService each have one job
O: New discount types (Discount implementations) never require editing existing classes
L: PremiumMember honours the Member contract completely
I: NotificationService, InvoiceService, PaymentGateway are each focused
D: OrderService depends on OrderRepository, not SqlOrderRepository
In an LLD interview, when you name-drop SOLID, you need to point to concrete choices in your design that reflect each principle. Just knowing the acronym is not enough. The examiner wants to hear "I gave OrderService an OrderRepository dependency rather than instantiating the DB directly, that's DIP. It also makes the class trivially testable."
You encounter SOLID violations (and fixes) every day in production frameworks. Recognizing them in existing code builds interview confidence.
Spring Framework is built on DIP. Every @Autowired dependency is injected through an interface. You can swap @Profile("test") beans without touching business logic. When you say "I use constructor injection" in an interview, you are describing DIP.
Java's java.util.Collections demonstrates ISP. Instead of one giant Collection interface, the JDK separates List, Set, Queue, and Map. A method that only needs iteration accepts Iterable, not List.
Java Streams API respects LSP. Every intermediate operation (filter(), map(), sorted()) returns a Stream that behaves identically to the input stream. You can chain them without worrying about contract violations.
Servlet Filters follow OCP. You extend request handling by adding new filters to the chain, not by modifying existing servlet code. Each filter has one job (authentication, logging, compression) and the chain is open for extension.
Your codebase has 5+ classes that collaborate (not a 50-line script)
You anticipate change in specific dimensions (new payment providers, new notification channels)
You need testability through dependency injection
Team size is 3+ engineers working on the same module
Skip or relax when:
You are writing a prototype, CLI tool, or throwaway script
The class has exactly one implementation and no foreseeable variation
Applying the principle adds more indirection than the problem warrants (one-method interfaces wrapping one-line calls)
If your class has more than two reasons to change, split it (SRP). If your tests require a running database, inject an interface (DIP). If your interface has methods that some implementors stub with throw new UnsupportedOperationException(), split the interface (ISP).
Reciting definitions without application. Saying "SRP means one responsibility" earns zero points. Show it: "I split OrderService from NotificationService because email template changes shouldn't force a redeploy of order logic." Always connect the principle to a concrete design choice.
Confusing OCP with "never modify code." OCP means the behavior is extensible without modifying existing source. Configuration changes, bug fixes, and refactors are fine. Candidates who say "you should never change a class" misunderstand the principle.
Over-applying ISP. Creating one interface per method leads to interface explosion. Group by role or capability, not by method count. IOrderReader with findById() and findByCustomer() is one cohesive role, not two interfaces.
Ignoring LSP until it breaks in production. The Square/Rectangle trap compiles and passes basic tests. Candidates who can explain why it breaks (the subtype changes the meaning of setWidth, not just the implementation) show depth.
Thinking DIP means "use interfaces everywhere." DIP is about the direction of dependency, not the existence of interfaces. If your interface has exactly one implementation that will never change, the interface adds ceremony without value. Apply DIP where variation or testability demands it.
SRP: Split classes by reason to change, not by method count. If changing email templates forces redeployment of order logic, you have an SRP violation.
OCP: When you see a growing if-else or switch block that changes every sprint, extract an interface and let new types be new classes.
LSP: If overriding a method changes what it means (not just how it works), the subtype is not substitutable. Rethink the hierarchy.
ISP: If an implementor stubs methods with "not supported", the interface is too fat. Split by role or capability.
DIP: Point your dependencies at interfaces, not concrete classes. The composition root is the one place that knows which concrete class to use.
Together: SOLID principles reinforce each other. SRP creates small classes, OCP makes them extensible, DIP makes them testable, ISP keeps interfaces focused, and LSP keeps hierarchies honest.
In interviews: Don't just name the principle. Point to a specific class in your design and explain which principle it follows and why that matters for the system.
S: Split by responsibility
// β One job: orchestrate the order lifecycle.// This class has exactly ONE reason to change: order business logic.public class OrderService { private final OrderRepository orders; private final NotificationService notifications; private final InvoiceService invoices; public OrderService(OrderRepository orders, NotificationService notifications, InvoiceService invoices) { this.orders = orders; this.notifications = notifications; this.invoices = invoices; } public void placeOrder(Order order) { order.setStatus("confirmed"); orders.save(order); notifications.notifyOrderPlaced(order); invoices.generate(order); } public void cancelOrder(String orderId) { Order order = orders.findById(orderId) .orElseThrow(() -> new IllegalArgumentException("Order not found: " + orderId)); order.setStatus("cancelled"); orders.save(order); notifications.notifyOrderCancelled(order); }}
JAVA31 linesservice/OrderService.java
O: Extend discount types without modifying PricingService
// The abstraction. This never changes.// New discount types implement this interface. PricingService never needs editing.public interface Discount { /** Returns the discounted total for the given order amount. */ double apply(double originalAmount); /** Human-readable description shown on the invoice. */ String describe();}
JAVA10 linesdiscount/Discount.java
L: LSP-compliant hierarchy
// Base class. Contract: any Member can place orders up to their credit limit.public class Member { private final String id; private final String name; protected double creditLimit; public Member(String id, String name, double creditLimit) { this.id = id; this.name = name; this.creditLimit = creditLimit; } public boolean canPlaceOrder(double amount) { return amount <= getCreditLimit(); } public double getCreditLimit() { return creditLimit; } public String getId() { return id; } public String getName() { return name; }}
JAVA24 linesmodel/Member.java
I: Thin, focused interfaces
import java.util.List;import java.util.Optional;// β Full read+write contract for the repository layer.// Split into OrderReader + OrderWriter if you need read-only views.public interface OrderRepository { void save(Order order); Optional<Order> findById(String id); List<Order> findByCustomer(String customerId); void delete(String id);}
JAVA11 linesrepository/OrderRepository.java
D: Depend on abstractions, inject at composition root
import java.util.List;import java.util.Optional;// Low-level module. Implements the abstraction (OrderRepository).public class SqlOrderRepository implements OrderRepository { @Override public void save(Order order) { System.out.println("[SQL] Upsert order " + order.getId()); // db.query("INSERT INTO orders ... ON CONFLICT DO UPDATE ...") } @Override public Optional<Order> findById(String id) { System.out.println("[SQL] SELECT * FROM orders WHERE id = '" + id + "'"); return Optional.empty(); // placeholder } @Override public List<Order> findByCustomer(String customerId) { System.out.println("[SQL] SELECT * FROM orders WHERE customer_id = '" + customerId + "'"); return List.of(); } @Override public void delete(String id) { System.out.println("[SQL] DELETE FROM orders WHERE id = '" + id + "'"); }}