LLDbeginner

SOLID Principles

SOLID is the foundation of every good object-oriented design. These five principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — are what separate a design that is easy to extend and test from one that collapses under the weight of its own complexity. Every LLD interview question implicitly tests your understanding of them.

Reading time

18 min

OOPdesign principlesclean codeSOLIDobject oriented design

Why SOLID Matters

SOLID is not academic theory. Every principle maps directly to a pain you have felt working on real code:

  • Classes that are impossible to test → violated Single Responsibility
  • Adding a feature breaks five unrelated things → violated Open/Closed
  • A subclass that behaves unexpectedly → violated Liskov Substitution
  • Implementing methods you never use → violated Interface Segregation
  • Changing a low-level class breaks high-level code → violated Dependency Inversion

Understanding SOLID deeply means understanding *why* code becomes unmaintainable — and being able to design systems that don't.

S — Single Responsibility Principle (SRP)

Definition: A class should have one, and only one, reason to change.

"Reason to change" means "actor who can request a change." If both the finance team (who care about payment processing) and the marketing team (who care about email templates) can ask you to change the same class, that class has too many responsibilities.

Bad Example

java
class UserService {
    // Responsibility 1: Business logic
    public User createUser(String email, String name) {
        validateEmail(email);
        User user = new User(email, name);
        userRepository.save(user);
        return user;
    }

    // Responsibility 2: Persistence — belongs in a repository
    public void save(User user) {
        database.execute("INSERT INTO users ...", user);
    }

    // Responsibility 3: Email — belongs in a notification service
    public void sendWelcomeEmail(User user) {
        emailClient.send(user.getEmail(), "Welcome!", template.render(user));
    }

    // Responsibility 4: Reporting — belongs elsewhere entirely
    public List<User> getUsersRegisteredThisMonth() {
        return database.query("SELECT * FROM users WHERE ...");
    }
}

Good Example

java
class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;

    public User createUser(String email, String name) {
        validateEmail(email);
        User user = new User(email, name);
        userRepository.save(user);
        emailService.sendWelcome(user);
        return user;
    }
}

class UserRepository {
    public void save(User user) { /* persistence only */ }
    public User findById(long id) { /* persistence only */ }
}

class EmailService {
    public void sendWelcome(User user) { /* email only */ }
}

Key insight: SRP is about cohesion. A class is cohesive when all its methods work together to fulfil one clear purpose. If you struggle to name a class because it "does a bit of everything," that's an SRP violation.

In LLD Interviews

For a Parking Lot design, a single ParkingLot class should not handle ticketing, payment, and space allocation. Split into: ParkingLot (space management), TicketingService (ticket lifecycle), PaymentProcessor (payment only).

O — Open/Closed Principle (OCP)

Definition: Software entities should be open for extension but closed for modification.

When requirements change (and they always do), you should be able to add new behaviour by writing new code — not by editing existing, tested code. Editing working code risks introducing regressions.

Bad Example

java
class NotificationService {
    public void notify(User user, String type) {
        if (type.equals("EMAIL")) {
            emailClient.send(user.getEmail(), "...");
        } else if (type.equals("SMS")) {
            smsClient.send(user.getPhone(), "...");
        } else if (type.equals("PUSH")) {
            // Added later — modified existing class
            pushClient.send(user.getDeviceToken(), "...");
        }
        // Adding WhatsApp requires modifying this class again
    }
}

Good Example

java
interface NotificationChannel {
    void send(User user, String message);
}

class EmailChannel implements NotificationChannel {
    public void send(User user, String message) {
        emailClient.send(user.getEmail(), message);
    }
}

class SmsChannel implements NotificationChannel {
    public void send(User user, String message) {
        smsClient.send(user.getPhone(), message);
    }
}

// Adding WhatsApp: create a new class, touch nothing existing
class WhatsAppChannel implements NotificationChannel {
    public void send(User user, String message) {
        whatsappClient.send(user.getWhatsApp(), message);
    }
}

class NotificationService {
    private final List<NotificationChannel> channels;

    public void notify(User user, String message) {
        channels.forEach(ch -> ch.send(user, message));
    }
}

Key insight: OCP is achieved through abstractions (interfaces, abstract classes) and polymorphism. When you find yourself writing if/else chains on type strings or enums, that's almost always an OCP violation.

L — Liskov Substitution Principle (LSP)

Definition: Objects of a subclass must be substitutable for objects of the parent class without breaking the program's correctness.

Named after Barbara Liskov. In simple terms: if your code works with a Shape, it must work with any Circle or Rectangle that extends Shape — without needing to know which specific type it is.

The Classic Violation: Square extends Rectangle

java
class Rectangle {
    protected int width, height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int area() { return width * height; }
}

class Square extends Rectangle {
    // A square must keep width == height
    @Override
    public void setWidth(int w) {
        this.width = w;
        this.height = w; // Side effect! Breaks Liskov
    }

    @Override
    public void setHeight(int h) {
        this.width = h;
        this.height = h; // Side effect! Breaks Liskov
    }
}

// This code works for Rectangle but breaks for Square
void testRectangle(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20; // Fails for Square! Returns 16.
}

Why it breaks: Square strengthens the preconditions (forces width == height) and has unexpected side effects. Code that works with a Rectangle cannot trust a Square to behave the same way.

Fix: Don't Force Inheritance

java
interface Shape { int area(); }

class Rectangle implements Shape {
    private final int width, height;
    public Rectangle(int w, int h) { width = w; height = h; }
    public int area() { return width * height; }
}

class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }
    public int area() { return side * side; }
}

Key insight: Inheritance should model "is-a" in a behavioural sense, not just a conceptual one. A square is mathematically a rectangle, but it does not behave like one when width and height can be set independently.

Behavioural Rules for LSP

A subclass must not:

  • Strengthen preconditions (require more restrictive inputs than the parent)
  • Weaken postconditions (return less reliable outputs than the parent)
  • Throw new exception types not in the parent's contract
  • Have unexpected side effects not present in the parent

I — Interface Segregation Principle (ISP)

Definition: No client should be forced to depend on methods it does not use. Prefer many small, focused interfaces over one large, general-purpose interface.

Bad Example: The Fat Interface

java
interface Vehicle {
    void startEngine();
    void stopEngine();
    void accelerate();
    void brake();
    void fly();          // Only planes can fly
    void sail();         // Only boats can sail
    void openDoor();     // Not all vehicles have doors
}

class Car implements Vehicle {
    public void startEngine() { ... }
    public void fly() {
        throw new UnsupportedOperationException(); // Forced to implement
    }
    public void sail() {
        throw new UnsupportedOperationException(); // Forced to implement
    }
}

Good Example: Segregated Interfaces

java
interface Driveable {
    void startEngine();
    void stopEngine();
    void accelerate();
    void brake();
}

interface Flyable {
    void takeOff();
    void land();
    void fly();
}

interface Sailable {
    void sail();
    void anchor();
}

class Car implements Driveable { /* only what's relevant */ }
class Plane implements Driveable, Flyable { /* both make sense */ }
class Boat implements Sailable { /* only sailing */ }
class FlyingCar implements Driveable, Flyable { /* future use */ }

Key insight: Fat interfaces create coupling. When you add a method to a fat interface, every class implementing it must change — even if the new method is irrelevant to 90% of implementations. Segregated interfaces isolate change.

In LLD Interviews

For a library management system: instead of one LibraryItem interface with borrow(), reserve(), downloadDigitalCopy(), and streamOnline() — split into Borrowable, Reservable, DigitallyAccessible. Physical books implement Borrowable + Reservable. E-books implement DigitallyAccessible.

D — Dependency Inversion Principle (DIP)

Definition: 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.

Bad Example: Hard-Coded Dependencies

java
class OrderService {
    // Directly instantiates a concrete class — tightly coupled
    private MySQLOrderRepository repository = new MySQLOrderRepository();
    private SendGridEmailService emailService = new SendGridEmailService();

    public void placeOrder(Order order) {
        repository.save(order);
        emailService.sendConfirmation(order);
    }
}

// Cannot test OrderService without a real MySQL database
// Cannot switch to PostgreSQL without modifying OrderService
// Cannot use a different email provider without modifying OrderService

Good Example: Depend on Abstractions

java
interface OrderRepository {
    void save(Order order);
    Order findById(long id);
}

interface EmailService {
    void sendConfirmation(Order order);
}

class OrderService {
    private final OrderRepository repository;
    private final EmailService emailService;

    // Dependencies injected — OrderService doesn't know or care which impl
    public OrderService(OrderRepository repository, EmailService emailService) {
        this.repository = repository;
        this.emailService = emailService;
    }

    public void placeOrder(Order order) {
        repository.save(order);
        emailService.sendConfirmation(order);
    }
}

// Production wiring
OrderService service = new OrderService(
    new MySQLOrderRepository(),
    new SendGridEmailService()
);

// Test wiring — use mocks, no real DB or email needed
OrderService service = new OrderService(
    new InMemoryOrderRepository(),
    new MockEmailService()
);

Key insight: DIP is what makes unit testing possible. When OrderService depends on interfaces, you can inject test doubles (mocks, stubs, fakes) during tests. When it depends on concrete classes, you're forced to set up real infrastructure.

How the Principles Work Together

The five principles reinforce each other:

  • SRP gives each class one reason to change
  • OCP lets you add behaviour without touching existing classes
  • LSP ensures your inheritance hierarchies are reliable
  • ISP keeps interfaces lean so implementing them is never a burden
  • DIP decouples modules through abstractions, enabling testability and flexibility

A class that follows all five is: focused (SRP), extensible (OCP), reliably substitutable (LSP), unencumbered by irrelevant contracts (ISP), and independently testable (DIP).

Interview Tip

In an LLD interview for a Ride Sharing app, proactively mention: "I'm designing the PricingStrategy as an interface so that SurgeStrategy, StandardStrategy, and PromotionStrategy can each be separate implementations — this satisfies OCP because adding a new pricing model means adding a new class rather than modifying the existing pricing logic. The RideMatcher depends on the PricingStrategy interface, not any concrete implementation, which is the Dependency Inversion Principle."

Naming the principles as you design shows your thinking is systematic, not ad hoc.