Back to LLD Concepts
Design Principles

SOLID Principles

Five foundational principles that guide object-oriented design toward code that is maintainable, extensible, and testable — from beginner-friendly intuition to production-grade application.

What is SOLID?

SOLID is an acronym coined by Robert C. Martin ("Uncle Bob") that groups five object-oriented design principles. These principles are not rules to follow mechanically — they are design heuristics that help you avoid the most common structural problems in growing codebases.

Each principle addresses a specific pain point: code that is hard to change, fragile after modification, impossible to test in isolation, or tightly coupled to infrastructure. Applied together, they push your code toward loose coupling and high cohesion.

S

Single Responsibility

Solves: Classes that change for too many reasons

O

Open / Closed

Solves: Adding features breaks existing code

L

Liskov Substitution

Solves: Subclasses that surprise their callers

I

Interface Segregation

Solves: Fat interfaces with unused methods

D

Dependency Inversion

Solves: High-level code coupled to infrastructure

When to apply: SOLID principles have real costs — they add abstractions, interfaces, and indirection. Apply them proportionally: a 50-line script doesn't need SOLID. A production service with multiple contributors and changing requirements does. The goal is sustainable growth, not ceremony.

S — First Principle

Single Responsibility Principle

"A class should have only one reason to change."

— Robert C. Martin

The Intuition

"One reason to change" doesn't mean "one method" or "doing one thing" in a narrow sense. It means one actor — one person or role in your organisation — should be able to demand a change to this class. If a tax accountant, a DBA, a marketing manager, and a devops engineer could all independently request changes to the same class — that class has too many responsibilities.

Think of a Swiss Army knife: it technically does many things, but it does none of them well. A dedicated chef's knife outperforms it at its specific job every time. SRP is about giving each class the right blade for exactly one job.

🧠 Business Logic

Domain rules, calculations, validations — owned by business analysts

🗄️ Persistence

SQL, ORM, schema — owned by the DBA or infrastructure team

📧 Notifications

Templates, providers, channels — owned by the marketing team

Code Example

// ❌ VIOLATION — One class, five reasons to change
public class OrderProcessor {

    // Reason 1: business rules change
    public double calculateTotal(Order order) {
        double sub = order.getItems().stream()
                         .mapToDouble(i -> i.getPrice() * i.getQty()).sum();
        return sub + (sub * 0.18); // GST hardcoded here
    }

    // Reason 2: DB schema or ORM changes
    public void saveOrder(Order order) {
        String sql = "INSERT INTO orders (id, total) VALUES (?, ?)";
        jdbcTemplate.update(sql, order.getId(), calculateTotal(order));
    }

    // Reason 3: email provider or template changes
    public void sendConfirmation(Order order) {
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(order.getCustomerEmail());
        msg.setSubject("Order Confirmed #" + order.getId());
        msg.setText("Your order total is ₹" + calculateTotal(order));
        mailSender.send(msg);
    }

    // Reason 4: log format or destination changes
    public void logOrder(Order order) {
        String line = LocalDateTime.now() + " | ORDER | " + order.getId();
        Files.write(Paths.get("orders.log"), line.getBytes(), APPEND);
    }

    // Reason 5: report format changes
    public String generateDailyReport(List<Order> orders) {
        return orders.stream()
                     .map(o -> o.getId() + ": ₹" + calculateTotal(o))
                     .collect(Collectors.joining("\n"));
    }
}

Benefits

  • Changes are localised — editing tax logic never touches email code
  • Easier to test each class in complete isolation
  • Classes are smaller and easier to reason about
  • Teams can work on different classes without merge conflicts

Common Mistakes

  • Over-splitting into hundreds of micro-classes for trivial code
  • Confusing "one method" with "one responsibility"
  • Creating a thin "Service" wrapper that just delegates everything
  • Applying SRP to value objects that are naturally cohesive

Real-World Examples

Spring @Service / @Repository / @Controller

Spring's stereotype annotations enforce SRP by architecture: controllers handle HTTP, services handle business logic, repositories handle data access.

Express.js Router Middleware

Express middleware chains (auth, validation, rate-limiting, handler) each do one thing. The router composes them — each piece has one reason to change.

Django Models vs Forms vs Views

Django separates Model (data + constraints), Form (validation + rendering), and View (request handling) — three actors, three classes.

Unix Philosophy

"Do one thing and do it well" — cat, grep, sort, uniq are the SRP in CLI form. They compose via pipes rather than cramming everything into one command.

O — Second Principle

Open / Closed Principle

"Software entities should be open for extension, but closed for modification."

— Bertrand Meyer (1988), popularised by Robert C. Martin

The Intuition

Every time you modify existing, tested code to add a new feature, you risk introducing regressions. OCP says: design your code so that adding a new feature = writing a new class/function, never editing an old one.

Think of a USB port: the laptop is "closed" — you don't open it up to add new peripheral support. But it's "open" — you just plug in a new device. The port (abstraction) is the contract; new peripherals (extensions) implement the protocol without touching the laptop.

In code, this usually means identifying the axis of variation (what's likely to change: payment types, export formats, discount rules) and hiding it behind an abstraction. New variants are added as new implementations — existing code never changes.

OCP and Prediction: You can't hide everything behind abstractions — that leads to over-engineering. Apply OCP to the parts of your code that have already changed once or that domain knowledge tells you will change. Don't abstract for imaginary future requirements.

Code Example

// ❌ VIOLATION — Adding a new payment type means editing this class

public class PaymentProcessor {

    public void processPayment(Payment payment) {
        if (payment.getType().equals("CREDIT_CARD")) {
            // credit card logic
            System.out.println("Charging card ending in " + payment.getLast4());
        } else if (payment.getType().equals("UPI")) {
            // UPI logic
            System.out.println("Sending UPI request to " + payment.getUpiId());
        } else if (payment.getType().equals("NET_BANKING")) {
            // net banking logic
            System.out.println("Redirecting to bank: " + payment.getBankCode());
        }
        // ← next engineer adds "CRYPTO" here, touching existing code
        // Every new payment type = modification = risk of breaking existing types
    }
}

Benefits

  • New features never risk breaking existing, tested behaviour
  • The core logic is stable — only the extension points change
  • Easier to maintain — each extension is a self-contained class
  • Enables plugin architectures and runtime extensibility

Common Mistakes

  • Abstracting every method "just in case" — premature OCP
  • Adding interfaces to code that only ever has one implementation
  • Forgetting that the registration/wiring point still changes
  • Hiding the axis of variation incorrectly — wrong abstraction

Real-World Examples

Java Comparator / Comparable

Collections.sort() is closed — it never changes. You extend it by providing a new Comparator. The sorting algorithm is stable; the comparison strategy is the extension point.

Webpack / Vite Plugins

The bundler core is closed. You add capabilities (TypeScript, CSS modules, image compression) by writing new plugins. The bundler never changes to support new file types.

Express / Koa Middleware

The HTTP pipeline is closed. You extend it by composing middleware — auth, logging, CORS, rate-limit. Each new concern is a new function, not a change to the framework.

Python's sort() with key=

Python's Timsort is closed. You extend its behaviour via key= or functools.cmp_to_key(). The sort algorithm is untouched; custom ordering is the extension.

L — Third Principle

Liskov Substitution Principle

"Objects of a subtype must be substitutable for objects of the supertype without altering the correctness of the program."

— Barbara Liskov, 1987 (Turing Award winner)

The Intuition

LSP is the correctness contract for inheritance. If class B extends class A, then anywhere your code uses an A, you must be able to silently swap in a B and observe no difference in correct behaviour — no new exceptions, no broken invariants, no surprise outputs.

A practical test: if you find yourself writing if (obj instanceof SubClass) in client code, or a subclass method throws UnsupportedOperationException, LSP is almost certainly violated.

The classic textbook counterexample is Square extends Rectangle. Geometrically, every square is a rectangle. But behaviourally, a Square cannot honour the Rectangle contract (setting width and height independently). LSP cares about behavioural substitutability, not geometric taxonomy.

The Contract Rules

Preconditions

A subtype cannot strengthen preconditions. If the parent accepts any positive integer, the child cannot additionally require it to be even.

Violation sign: Subclass throws where parent did not

Postconditions

A subtype cannot weaken postconditions. If the parent guarantees a non-null return, the child cannot return null.

Violation sign: Subclass returns unexpected values or types

Invariants

A subtype must preserve all invariants of the supertype. If the parent guarantees balance ≥ 0, the child must too.

Violation sign: Subclass leaves object in invalid state

Code Example

// ❌ VIOLATION — Square breaks the contract of Rectangle

public class Rectangle {
    protected int width;
    protected int height;

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

// Square IS-A Rectangle... geometrically. But behaviourally?
public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        this.width  = w;
        this.height = w;  // ← forces equal sides
    }
    @Override
    public void setHeight(int h) {
        this.width  = h;  // ← forces equal sides
        this.height = h;
    }
}

// Client code written against Rectangle contract
public void resizeAndCheck(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    // Developer expects: 5 × 10 = 50
    assert r.getArea() == 50; // ✓ passes for Rectangle
                               // ✗ FAILS for Square — area is 100!
}
// Square cannot be substituted for Rectangle without breaking behaviour.
// This violates the Liskov Substitution Principle.

Benefits

  • Polymorphism works correctly — swapping subtypes never breaks callers
  • Hierarchies become genuinely reusable, not just nominally so
  • Code that programs to interfaces stays stable under extension
  • Fewer defensive instanceof checks in client code

Warning Signs

  • Subclass method throws UnsupportedOperationException
  • Client code does instanceof checks before method calls
  • Overriding a method to do nothing (empty override)
  • Geometric "is-a" used to justify behavioural inheritance

Real-World Examples

Java List / ArrayList / LinkedList

ArrayList and LinkedList both implement List. Code written against List works correctly with either. LinkedList doesn't throw for operations ArrayList handles — it just has different performance characteristics.

Python's io.IOBase hierarchy

TextIOWrapper, BytesIO, StringIO all substitute for IOBase. Functions accepting a file-like object work with any of them. No surprises, no broken contracts.

React Component Props

In React, a specialised Button component (PrimaryButton, IconButton) should accept all props a base Button accepts and add more. Removing expected props violates LSP.

HTTP Status Codes

A 201 Created response is a subtype of 2xx Success. Any code that handles 2xx responses must correctly handle 201. HTTP itself is designed around LSP.

I — Fourth Principle

Interface Segregation Principle

"Clients should not be forced to depend on interfaces they do not use."

— Robert C. Martin

The Intuition

ISP is SRP applied to interfaces. A fat interface forces every implementor to provide methods they don't need — leading to empty methods, throw new UnsupportedOperationException() stubs, and misleading contracts. Worse, a change to any method in the fat interface forces a recompile of every implementor, even those that don't use it.

The remedy is to split fat interfaces into small, cohesive role interfaces — each representing one capability. Implementors then pick only the roles they can genuinely fulfil. Clients program to the minimal interface they actually need.

Think of a restaurant menu: a vegetarian diner doesn't want a menu that forces them to order meat. You give them a vegetarian menu. ISP is about giving each client the exact menu of capabilities they need — no more, no less.

ISP vs SRP

SRP is about classes — a class should have one reason to change. It governs how you split implementation code.

ISP is about interfaces — clients should not see methods they don't use. It governs how you design contracts. A single class can implement multiple narrow interfaces without violating SRP.

Code Example

// ❌ VIOLATION — Fat interface forces irrelevant method implementations

public interface Worker {
    void work();
    void eat();       // ← robots don't eat
    void sleep();     // ← robots don't sleep
    void attendMeeting(); // ← contractors might not
    void submitTimesheet();
}

// Robot is forced to implement biologically absurd methods
public class Robot implements Worker {
    public void work()            { System.out.println("Welding..."); }
    public void eat()             { throw new UnsupportedOperationException("Robots don't eat!"); }
    public void sleep()           { throw new UnsupportedOperationException("Robots don't sleep!"); }
    public void attendMeeting()   { System.out.println("Attending meeting..."); }
    public void submitTimesheet() { throw new UnsupportedOperationException("No timesheet for robots!"); }
}

// Freelancer forced to implement internal HR methods
public class Freelancer implements Worker {
    public void work()            { System.out.println("Working remotely..."); }
    public void eat()             { System.out.println("Eating lunch..."); }
    public void sleep()           { System.out.println("Sleeping..."); }
    public void attendMeeting()   { throw new UnsupportedOperationException("Not invited!"); }
    public void submitTimesheet() { throw new UnsupportedOperationException("Not an employee!"); }
}

Benefits

  • No forced stub implementations of irrelevant methods
  • Changing a method only recompiles classes that actually use it
  • Interfaces are smaller, more readable, and easier to test against
  • Enables fine-grained mocking in unit tests

Common Mistakes

  • Over-segregating into single-method interfaces for everything
  • Combining methods that are actually always used together
  • Creating interface hierarchies that mirror class hierarchies
  • Ignoring ISP for internal classes never exposed to other modules

Real-World Examples

Java java.io interfaces

Java separates Readable, Writable, Closeable, Flushable, AutoCloseable into distinct interfaces. A Scanner only implements Readable + Closeable — it doesn't need Writable.

Python Protocols (PEP 544)

Python's structural subtyping via Protocol lets you define minimal interfaces. A function accepting Sized only needs __len__ — not Iterable, not Reversible.

React Hook Composition

Instead of one big useApp() hook, React encourages useAuth(), useCart(), usePricing() — each component imports only the hooks it actually needs.

REST API Versioning

API consumers are given minimal contracts — a mobile client gets a slim API, an admin dashboard gets an expanded one. Neither is forced to parse fields they don't use.

D — Fifth Principle

Dependency Inversion Principle

"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."

— Robert C. Martin

The Intuition

Without DIP, your business logic directly instantiates infrastructure: new MySQLRepository(), new SendGridMailer(), new RedisCache(). The high-level business logic depends on low-level infrastructure. Change the database → change the business logic. That's backwards.

DIP inverts this: the business layer defines the interfaces it needs (e.g., UserRepository, MailService). The infrastructure layer implements those interfaces. Now the direction of dependency flows toward the business layer, not away from it.

Think of an electrical socket: your appliance (high-level) defines the plug shape it needs. Any power source (low-level) that fits the standard is usable. The appliance doesn't care if it's grid power, a generator, or a battery bank — it just uses the socket contract.

❌ Traditional Dependency Direction
OrderService (high-level)
↓ depends on
MySQLRepository (low-level)
↓ depends on
mysql2 driver (detail)

Changing the DB changes business logic

✅ Inverted Dependency Direction
OrderService (high-level)
↓ depends on
OrderRepository (abstraction)
↑ implemented by
MySQLRepository / MongoRepo

Swap DB without touching business logic

DIP vs Dependency Injection

These are often confused but are distinct concepts. DIP is a principle — a design goal about the direction of dependencies. Dependency Injection (DI) is a pattern/technique that helps you achieve DIP by passing dependencies in from the outside (constructor injection, method injection) rather than creating them internally. DI containers (Spring, Angular DI, Python's dependency-injector) automate DI but are not required — manual DI (passing dependencies via the constructor) fully satisfies DIP.

Code Example

// ❌ VIOLATION — High-level module hardwired to low-level details

public class OrderService {

    // High-level module directly instantiates low-level modules
    private MySQLOrderRepository  repository  = new MySQLOrderRepository();
    private GmailNotificationService emailer  = new GmailNotificationService();
    private FileAuditLogger         logger    = new FileAuditLogger();

    public Order placeOrder(Cart cart) {
        Order order = Order.from(cart);
        repository.save(order);           // ← tightly coupled to MySQL
        emailer.sendConfirmation(order);  // ← tightly coupled to Gmail
        logger.log("Order placed: " + order.getId()); // ← coupled to file logger
        return order;
    }
}
// To switch from MySQL → PostgreSQL:
// 1. Find every 'new MySQLOrderRepository()' across the codebase
// 2. Replace each one  — risky, error-prone
// 3. Recompile and hope nothing breaks
// OrderService knows too much about infrastructure details.

Benefits

  • Business logic is testable without any real infrastructure
  • Swap database, email provider, cache with zero changes to business code
  • Different environments (test, staging, prod) use different implementations
  • Business logic is portable — it doesn't know or care what runs it

Common Mistakes

  • Injecting concrete classes instead of abstractions (injection != inversion)
  • Defining abstractions in the infrastructure layer (direction is wrong)
  • Creating interfaces for classes that will only ever have one implementation
  • Using a DI container as a global service locator — that's still tight coupling

Real-World Examples

Spring IoC Container (Java)

Spring's entire raison d'être is DIP. You declare @Service and @Repository; Spring wires them at runtime. Your business beans depend on interfaces; Spring injects the right implementation.

Angular Dependency Injection

Angular's DI system lets components declare which services they need via constructor typing. The injector provides the right implementation — easily swapped in tests via providers.

Python's unittest.mock

Mock works because well-designed Python services accept their dependencies. You inject a Mock object in place of a real DB or API client — business logic runs in complete isolation.

Repository Pattern (Domain-Driven Design)

DDD's Repository pattern is DIP applied to data access. Domain entities depend on a Repository interface defined in the domain layer. Infrastructure layer implements it.

SOLID At a Glance

LetterPrincipleCore Question to Ask
SSingle Responsibility"How many reasons does this class have to change?"
OOpen / Closed"Do I have to edit existing code to add this feature?"
LLiskov Substitution"Can every subtype silently replace its parent?"
IInterface Segregation"Does every implementor actually use every method?"
DDependency Inversion"Does my business logic instantiate its own infrastructure?"

Apply SOLID with Design Patterns

SOLID principles tell you what to aim for. Design patterns show you how to get there. Several patterns directly implement one or more SOLID principles: