SOLID is an acronym for five OOP design principles that make software more maintainable, scalable, and testable:
- S ā Single Responsibility Principle
- O ā Open/Closed Principle
- L ā Liskov Substitution Principle
- I ā Interface Segregation Principle
- D ā Dependency Inversion Principle
Introduced by Robert C. Martin ("Uncle Bob"). Not a strict rulebook but guidelines for managing complexity.
A class should have only one reason to change ā it should have one primary responsibility.
Violation:
class UserService {
void createUser(User user) { ... } // business logic
void saveUser(User user) { ... } // data access
String generateReport(User user) { ... } // reporting
void sendEmail(User user) { ... } // notification
}
Correct:
class UserService { void createUser(User user) { ... } }
class UserRepository { void save(User user) { ... } }
class UserReportGenerator { String generate(User user) { ... } }
class EmailNotificationService { void sendWelcomeEmail(User user) { ... } }
Senior nuance: "Reason to change" = stakeholder/actor. Business rules change when product owners request it; DB schema changes when DBAs require it; report format changes when marketing asks. These are different reasons.
SRP doesn't mean "one method per class" ā it means one cohesive responsibility. A UserValidator validating user fields has one responsibility even if it has many validation methods.
Software entities should be open for extension, closed for modification.
Violation: Adding a new shape requires modifying existing code:
class AreaCalculator {
double area(Object shape) {
if (shape instanceof Circle) return ...; // modify this every time
if (shape instanceof Rectangle) return ...;
}
}
Correct: Extend by adding new implementations:
interface Shape { double area(); }
class Circle implements Shape { public double area() { return Math.PI * r * r; } }
class Rectangle implements Shape { public double area() { return w * h; } }
class AreaCalculator {
double totalArea(List<Shape> shapes) {
return shapes.stream().mapToDouble(Shape::area).sum(); // never changes
}
}
Mechanisms: Abstraction (interfaces, abstract classes), Strategy pattern, Template Method, Decorator.
Senior nuance: 100% OCP is impractical. The goal is to be strategic about which dimensions of change to "close". Use OCP for points of known variability. Getting the abstraction wrong is costly.
Objects of a subclass must be substitutable for objects of the superclass without altering program correctness.
Formally: If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.
Classic violation ā Rectangle/Square:
class Rectangle {
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { this.width = this.height = w; } // violation!
@Override void setHeight(int h) { this.width = this.height = h; }
}
void test(Rectangle r) {
r.setWidth(5); r.setHeight(4);
assert r.area() == 20; // fails for Square!
}
Rules:
- Preconditions can only be weakened in subtype (accept more)
- Postconditions can only be strengthened in subtype (promise more)
- Invariants of supertype must be maintained
- No new exceptions (or only subtypes of parent's exceptions)
LSP and design: Java Stack extends Vector violates LSP ā you can call add(0, element) on a Stack reference, breaking stack semantics.
Clients should not be forced to depend on interfaces they don't use.
Violation:
interface Worker {
void work();
void eat();
void sleep();
}
class Robot implements Worker {
void work() { ... }
void eat() { throw new UnsupportedOperationException(); } // forced!
void sleep() { throw new UnsupportedOperationException(); }
}
Correct:
interface Workable { void work(); }
interface Eatable { void eat(); }
interface Sleepable { void sleep(); }
class Human implements Workable, Eatable, Sleepable { ... }
class Robot implements Workable { ... }
Senior nuance: Don't over-segregate. Splitting into too many tiny interfaces creates interface explosion. Balance based on client needs. ISP is about designing interfaces from the client's perspective, not the implementor's.
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Violation:
class OrderService { // high-level
private MySQLOrderRepository repo = new MySQLOrderRepository(); // depends on concrete
}
Correct:
interface OrderRepository { void save(Order o); }
class MySQLOrderRepository implements OrderRepository { ... }
class MongoOrderRepository implements OrderRepository { ... }
class OrderService { // high-level
private final OrderRepository repo; // depends on abstraction
public OrderService(OrderRepository repo) { this.repo = repo; } // inject
}
DIP enables Dependency Injection (DI) ā the practice of providing dependencies from outside. Spring is essentially a DI container implementing DIP.
Inversion: Traditionally, high-level code creates low-level dependencies. DIP inverts this ā the framework/container provides them.
Inheritance: "IS-A" ā extend a class to reuse behavior. Tightly coupled to parent.
Composition: "HAS-A" ā hold a reference to another object and delegate. Loose coupling.
// Inheritance
class LoggingList extends ArrayList<String> {
@Override public boolean add(String s) { log(s); return super.add(s); }
// Problem: Also inherits addAll, which calls add repeatedly
// If super.addAll() is changed to not use add(), logging breaks silently
}
// Composition (safer)
class LoggingList<E> implements List<E> {
private final List<E> delegate = new ArrayList<>();
@Override public boolean add(E e) { log(e); return delegate.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
c.forEach(this::log); return delegate.addAll(c); // explicit control
}
// ... other delegations
}
- Fragile base class problem: Superclass changes can break subclasses silently
- Deep hierarchies: Hard to understand, test, maintain
- Single inheritance limit: Can't extend multiple classes; composition with multiple
- Testing: Easier to mock a composed dependency than override inherited behavior
- Runtime flexibility: Can change composed object at runtime; inheritance is compile-time fixed
- Encapsulation preserved: With inheritance, subclass may depend on parent's internal implementation details
GoF Gang of Four Design Patterns Principle: "Favor object composition over class inheritance."
When inheritance IS appropriate: genuine IS-A relationship, LSP is satisfied, the framework/API expects extension (Template Method pattern), shallow hierarchy.
Tight coupling: Classes are highly dependent on each other's concrete implementations. Changes to one force changes to others.
Loose coupling: Classes interact through abstractions (interfaces). Dependencies are minimized and replaceable.
// Tight - depends on concrete class
class OrderController {
private EmailService emailService = new SmtpEmailService(); // concrete!
}
// Loose - depends on abstraction
class OrderController {
private final NotificationService notificationService; // interface
public OrderController(NotificationService service) { // injected
this.notificationService = service;
}
}
Loose coupling enables:
- Testability: Inject mock/stub in tests
- Flexibility: Swap implementations without changing dependent code
- Maintainability: Changes are localized
Metrics: Afferent coupling (Ca) ā how many depend on this. Efferent coupling (Ce) ā how many this depends on. Instability = Ce / (Ca + Ce). Stable components (low instability) should be abstract.
Cohesion: How related the elements within a module/class are. High cohesion = good.
Coupling: How dependent modules/classes are on each other. Low coupling = good.
Goal: High cohesion, low coupling.
Types of cohesion (weakest to strongest):
- Coincidental (random things together ā worst)
- Logical (similar logic but unrelated)
- Temporal (used at same time)
- Procedural (sequential steps)
- Communicational (work on same data)
- Sequential (output of one feeds next)
- Functional (single well-defined purpose ā best)
Example of poor cohesion:
class UtilityBag {
void parseXML() {}
void sendEmail() {}
void calculateTax() {}
void compressImage() {}
}
These are unrelated ā poor cohesion. Split into XmlParser, EmailService, TaxCalculator, ImageCompressor.
IS-A: Expressed via inheritance or interface implementation.
class Dog extends Animal {} // Dog IS-A Animal
class Circle implements Shape {} // Circle IS-A Shape
HAS-A: Expressed via composition (field containing another object).
class Car {
private Engine engine; // Car HAS-A Engine
private List<Wheel> wheels; // Car HAS-A Wheels
}
Test for IS-A: Substitution test (LSP). Can you use a Dog wherever an Animal is expected? If yes, IS-A is valid. If the subclass breaks parent's contract ā relationship is wrong, use HAS-A instead.
A marker interface has no methods ā it "marks" a class with metadata.
public interface Serializable {} // marker
public interface Cloneable {} // marker
public interface Remote {} // marker
The JVM/frameworks check instanceof to determine behavior:
if (obj instanceof Serializable) {
// proceed with serialization
}
Modern alternative: Annotations (@Serializable, @Entity). More flexible, can carry attributes, don't pollute type hierarchy.
Marker interfaces still have one advantage: compile-time type checking. A method void serialize(Serializable s) only accepts serializable objects at compile time. An annotation-based approach requires runtime checking.
Serializable signals to the Java Object Serialization mechanism (ObjectOutputStream/ObjectInputStream) that a class can be serialized to a byte stream.
No methods needed because:
- The serialization mechanism is implemented in
ObjectOutputStreamusing reflection - It just needs to know "is this class serializable?" ā a boolean property
- No specific method contracts are needed (the serialization logic handles everything)
If a class doesn't implement Serializable but you try to serialize it: NotSerializableException at runtime.
Pitfalls:
serialVersionUIDshould be declared to control version compatibility- Transient fields are excluded from serialization
- Custom serialization: implement
writeObject()/readObject()(private methods found by reflection) - Serialization security issues: constructor bypass, deserialization gadget chains (RCE)
Modern alternatives: Jackson JSON, Protobuf, Avro ā all more flexible and safer than Java serialization.
| Aspect | Abstract Class | Interface |
|---|---|---|
| Instantiation | No | No |
| Multiple inheritance | No (single) | Yes (multiple) |
| Instance fields | Yes | No (only static final) |
| Constructors | Yes | No |
| Method implementations | Yes (can mix) | Default/static only (Java 8+) |
| Access modifiers | Any | Methods public by default |
extends / implements | extends | implements |
| State | Can have mutable state | Cannot have instance state |
Use abstract class when:
- Sharing code among closely related classes
- Non-public members needed
- Need to manage state
- Template Method pattern
Use interface when:
- Unrelated classes implement it (Comparable, Serializable)
- Multiple inheritance of type needed
- Defining a type that doesn't care about implementation hierarchy
Java 8+ blurred the lines: Default methods in interfaces allow code sharing. But interfaces still can't have instance state.
Java doesn't support multiple class inheritance but supports multiple interface inheritance.
interface Flyable { default void move() { System.out.println("flying"); } }
interface Swimmable { default void move() { System.out.println("swimming"); } }
class Duck implements Flyable, Swimmable {
@Override public void move() {
Flyable.super.move(); // must resolve ambiguity
}
}
Multiple class inheritance is avoided to prevent the Diamond Problem and complexity in method resolution.
For interfaces: if two interfaces have the same default method, implementing class MUST override it (compiler forces resolution).
Rule of thumb for multiple interface conflict resolution:
- Class/override wins over interface
- More specific interface wins
- Must explicitly resolve ambiguity
Introduced in Java 8 to allow adding methods to interfaces without breaking existing implementations.
interface Collection<E> {
// New in Java 8 - without default, all existing implementations break
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
default void forEach(Consumer<? super E> action) {
for (E e : this) action.accept(e);
}
}
Use cases:
- Evolving APIs: Add new methods without breaking existing code
- Mixin-like behavior: Provide optional implementations
- Interface adapter pattern: Interface with default no-ops; class overrides only needed
Pitfalls:
- Diamond problem must be resolved explicitly
- Default methods complicate interface design ("fat interface")
- They DO have implementations but NOT state ā can call other interface methods and parameters
Static methods in interfaces (Java 8): interface Comparator<T> { static <T> Comparator<T> naturalOrder() {...} }
Private methods in interfaces (Java 9): Share code between default methods.
The diamond problem occurs in multiple inheritance when a class inherits from two classes that share a common ancestor with a conflicting method.
A (move())
/ \
B C (both inherit/override move())
\ /
D (which move() to use?)
Java solves this for classes by not allowing multiple class inheritance.
For interfaces (Java 8+ with defaults):
interface A { default void hello() { print("A"); } }
interface B extends A { default void hello() { print("B"); } }
interface C extends A { default void hello() { print("C"); } }
class D implements B, C {
// Must override to resolve ambiguity
@Override public void hello() {
B.super.hello(); // explicit
}
}
The rule: A class override always wins over interface defaults. A more specific interface wins over less specific. Otherwise, must manually resolve.
- Template Method Pattern: Skeleton algorithm with steps for subclasses to fill in
abstract class DataProcessor {
// Template method
final void process() { readData(); processData(); writeData(); }
abstract void readData();
abstract void processData();
void writeData() { /* default impl */ }
}
- Common state and behavior: Subclasses share fields and some implementations
abstract class Vehicle {
protected String brand; // shared state
protected int year;
public abstract void fuelType();
public String describe() { // shared behavior
return brand + " (" + year + ")";
}
}
- Partial implementation: Provide some but not all methods
- When you want to control object creation:
protectedconstructors, factory methods in abstract class
- Close hierarchy: Abstract class hints "these are the related implementations"
- Defining a contract for unrelated classes (Comparable, Serializable, Runnable)
- Multiple inheritance of type needed
- Decoupling ā program to interfaces, not implementations
- Plugin/extension points:
List,Queue,Mapā code works with any implementation - Dependency injection ā inject any implementation at runtime
- Mocking in tests ā easier to mock interfaces than classes
- Functional interfaces ā for lambdas
Rule of thumb: Start with interface unless you have a clear need for abstract class (shared state, template method).
In API design: expose interfaces, hide implementations. List<T> createList() not ArrayList<T> createList().
Factory Pattern provides a way to create objects without specifying the exact concrete class.
Simple Factory (not a GoF pattern):
class ShapeFactory {
public static Shape create(String type) {
return switch (type) {
case "circle" -> new Circle();
case "rectangle" -> new Rectangle();
default -> throw new IllegalArgumentException();
};
}
}
Factory Method (GoF):
abstract class Application {
abstract Button createButton(); // factory method
void render() {
Button btn = createButton(); // uses factory method
btn.paint();
}
}
class WindowsApp extends Application {
@Override Button createButton() { return new WindowsButton(); }
}
Abstract Factory:
interface GUIFactory {
Button createButton();
Checkbox createCheckbox();
}
class WindowsFactory implements GUIFactory { ... }
class MacFactory implements GUIFactory { ... }
Use cases: Spring's BeanFactory, ConnectionFactory, JDBC DriverManager.getConnection(), Calendar.getInstance().
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) instance = new Singleton(); // NOT thread safe!
return instance;
}
}
Pitfalls:
- Thread safety: Multiple threads may create multiple instances
- Testing: Difficult to mock/replace; carries state between tests
- Global state: Implicit dependency; hard to trace
- Classloader issues: Multiple classloaders ā multiple instances
- Serialization: Deserializing creates new instance (override
readResolve()) - Reflection:
getDeclaredConstructor().setAccessible(true)can bypass private constructor
// Approach 1: Synchronized method (slow)
public static synchronized Singleton getInstance() { ... }
// Approach 2: Double-checked locking (Java 5+, needs volatile)
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
// Approach 3: Initialization-on-demand holder (best - lazy, thread-safe, no sync overhead)
public class Singleton {
private static class Holder {
static final Singleton INSTANCE = new Singleton(); // initialized when Holder is loaded
}
public static Singleton getInstance() { return Holder.INSTANCE; }
}
// Approach 4: Enum (simplest, handles serialization and reflection)
public enum Singleton {
INSTANCE;
public void doSomething() { ... }
}
Best practice: Enum singleton (Josh Bloch recommendation) or Holder idiom. In Spring: just use @Bean ā Spring manages singleton scope.
Builder separates complex object construction from its representation.
// Without builder (constructor hell)
User user = new User("Alice", "alice@email.com", 30, "New York", true, "ADMIN", null, null);
// With builder
User user = User.builder()
.name("Alice")
.email("alice@email.com")
.age(30)
.city("New York")
.active(true)
.role("ADMIN")
.build();
Use when:
- Many constructor parameters (especially optional ones)
- Immutable object with many fields
- Step-by-step construction
- Complex validation before construction
Implementation styles:
- Classic GoF: Director + ConcreteBuilder
- Fluent/chained (most common in Java):
return thisfrom setters - Lombok
@Builder: Auto-generates builder - Record with
withmethods pattern
Create new objects by copying (cloning) existing objects.
interface Prototype {
Prototype clone();
}
class ConcretePrototype implements Cloneable {
private List<String> items;
@Override protected ConcretePrototype clone() {
try {
ConcretePrototype clone = (ConcretePrototype) super.clone();
clone.items = new ArrayList<>(this.items); // deep copy mutable fields
return clone;
} catch (CloneNotSupportedException e) { throw new RuntimeException(e); }
}
}
Java's Cloneable + Object.clone() is notoriously broken:
clone()is protected in ObjectCloneableis a marker butObject.clone()must still be overridden- Default clone is shallow ā must manually deep copy mutable fields
Modern alternative: Copy constructor, BeanUtils.copyProperties(), serialization-based copy, or just new + manual copying.
Use cases: Creating objects when construction is expensive, creating snapshots/undo functionality.
Define a family of algorithms, encapsulate each, and make them interchangeable.
interface SortStrategy {
void sort(int[] data);
}
class BubbleSort implements SortStrategy {
public void sort(int[] data) { ... }
}
class QuickSort implements SortStrategy {
public void sort(int[] data) { ... }
}
class DataProcessor {
private SortStrategy strategy;
public DataProcessor(SortStrategy strategy) {
this.strategy = strategy;
}
public void setStrategy(SortStrategy s) { this.strategy = s; }
public void process(int[] data) {
strategy.sort(data);
// ... more processing
}
}
// Usage
DataProcessor processor = new DataProcessor(new QuickSort());
// Change at runtime:
processor.setStrategy(new BubbleSort());
With Java 8: Strategy = functional interface! Function, Predicate, Comparator are all strategies.
Used heavily in Spring: ResourceLoader, TransactionManager, PasswordEncoder, CacheManager.
Defines a one-to-many dependency: when one object changes state, all dependents are notified automatically.
interface Observer { void update(String event); }
class EventBus {
private List<Observer> observers = new ArrayList<>();
public void subscribe(Observer o) { observers.add(o); }
public void unsubscribe(Observer o) { observers.remove(o); }
public void publish(String event) {
observers.forEach(o -> o.update(event));
}
}
Java built-in: java.util.Observable (deprecated Java 9), java.util.EventListener.
Modern Java: PropertyChangeListener, Spring's ApplicationEventPublisher:
@Component
class OrderService {
@Autowired ApplicationEventPublisher publisher;
public void createOrder(Order order) {
// ...
publisher.publishEvent(new OrderCreatedEvent(order));
}
}
@Component
class EmailService {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) { sendEmail(event.getOrder()); }
}
Reactive: Observer pattern at scale = Reactive Streams (RxJava, Project Reactor).
Dynamically add responsibilities to objects without modifying their class.
interface Coffee { double cost(); String description(); }
class SimpleCoffee implements Coffee {
public double cost() { return 1.0; }
public String description() { return "Coffee"; }
}
abstract class CoffeeDecorator implements Coffee {
protected Coffee wrapped;
public CoffeeDecorator(Coffee c) { this.wrapped = c; }
}
class MilkDecorator extends CoffeeDecorator {
public MilkDecorator(Coffee c) { super(c); }
public double cost() { return wrapped.cost() + 0.5; }
public String description() { return wrapped.description() + ", Milk"; }
}
// Usage
Coffee coffee = new MilkDecorator(new MilkDecorator(new SimpleCoffee()));
// Cost: 2.0, Description: "Coffee, Milk, Milk"
Java IO is decorator pattern:
new BufferedInputStream(new FileInputStream("file.txt"))
new PrintWriter(new BufferedWriter(new FileWriter("out.txt")))
Spring's BeanDefinitionDecorator, cache proxies, transaction proxies.
Pass a request along a chain of handlers until one handles it.
abstract class Handler {
protected Handler next;
public Handler setNext(Handler next) { this.next = next; return next; }
public abstract void handle(Request request);
}
class AuthHandler extends Handler {
public void handle(Request request) {
if (!request.isAuthenticated()) { System.out.println("Auth failed"); return; }
if (next != null) next.handle(request);
}
}
class LoggingHandler extends Handler {
public void handle(Request request) {
log(request);
if (next != null) next.handle(request);
}
}
// Setup chain
Handler chain = new AuthHandler();
chain.setNext(new LoggingHandler()).setNext(new BusinessHandler());
chain.handle(request);
Used in: Servlet filters, Spring Security filter chain, Spring MVC interceptors, logging framework handlers.
Encapsulate a request as an object, allowing parameterization, queuing, logging, and undo.
interface Command {
void execute();
void undo();
}
class TextEditor {
StringBuilder text = new StringBuilder();
void appendText(String s) { text.append(s); }
void deleteText(int from, int to) { text.delete(from, to); }
}
class AppendCommand implements Command {
private TextEditor editor;
private String text;
public AppendCommand(TextEditor e, String t) { editor = e; text = t; }
public void execute() { editor.appendText(text); }
public void undo() { editor.deleteText(editor.text.length() - text.length(), editor.text.length()); }
}
class CommandHistory {
private Deque<Command> history = new ArrayDeque<>();
public void executeCommand(Command cmd) { cmd.execute(); history.push(cmd); }
public void undoLastCommand() { if (!history.isEmpty()) history.pop().undo(); }
}
Used in: Transaction management, Undo/Redo, Job queuing, Spring Batch steps.
- God Class: One class that does everything. Solution: decompose.
- Anemic Domain Model: Domain objects are just data bags; all logic in services. Solution: Move logic into domain objects (rich domain model).
- Singleton overuse: Using singletons where DI would be better.
- String concatenation in loops:
str += valuein loop creates O(n²) objects. UseStringBuilder.
- Catching Exception/Throwable broadly: Swallows unexpected errors. Catch specific exceptions.
- Returning null: Causes NPE. Return Optional, empty collection, or throw exception.
- Premature optimization: Micro-optimizing before profiling. Profile first.
- Magic numbers/strings:
if (status == 3)ā use constants or enums.
- Long parameter lists:
void create(String a, String b, int c, boolean d, ...)ā use builder or parameter objects.
- Dead code: Unused methods/fields. Remove or it creates confusion.
- Incorrect synchronization:
if (map != null) { synchronized(map) {} }ā checking null outside sync.
- Not closing resources: Memory/connection leaks. Use try-with-resources.
- Excessive inheritance: Deep hierarchies. Prefer composition.
- Static state abuse: Static mutable state is global, untestable, thread-unsafe.