Search…
Low Level Design · Part 2

SOLID principles

In this series (15 parts)
  1. Introduction to low level design
  2. SOLID principles
  3. Design patterns: Creational
  4. Design patterns: Structural
  5. Design patterns: Behavioral
  6. Designing a parking lot
  7. Designing a library management system
  8. Designing an elevator system
  9. Designing a hotel booking system
  10. Designing a ride-sharing model
  11. Designing a rate limiter
  12. Designing a logging framework
  13. Designing a notification system
  14. API design and contract-first development
  15. Data modeling for system design

SOLID is an acronym coined by Robert C. Martin that packages five object oriented design principles into a memorable label. Each principle solves a different class of pain: fragile base classes, shotgun surgery, bloated interfaces, rigid hierarchies. Understanding them individually is useful. Understanding how violations compound across a codebase is what makes you a better designer.

The introduction to LLD showed you what LLD produces. SOLID tells you whether the result is any good.

S: Single Responsibility Principle

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

“Reason to change” means one stakeholder or one axis of requirements. If a class handles both order persistence and email notifications, a change to the email template forces you to redeploy order logic. Two responsibilities, two reasons to change, two places to introduce bugs.

Bad design: an Invoice class that calculates totals, formats PDF output, and sends emails.

Good design: Invoice calculates totals. InvoicePrinter formats PDFs. InvoiceMailer sends emails. Each class changes for exactly one reason.

The test is simple. Describe what your class does. If the description contains “and,” you likely have two responsibilities stuffed into one class.

O: Open/Closed Principle

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

When new requirements arrive, you should be able to add new code rather than editing existing, tested code. The mechanism is almost always polymorphism: define an abstraction, implement it in new classes, and inject the new behavior without touching the original.

Here is a concrete example. A payment system initially supports only credit cards. When the product team asks for PayPal support, a poorly designed system requires editing the core payment class.

classDiagram
  class PaymentService_Bad {
      +processCreditCard(amount)
      +processPayPal(amount)
      +processBitcoin(amount)
  }

  note for PaymentService_Bad "Every new method requires
modifying this class"

Violation: adding a payment method means modifying PaymentService_Bad. Each addition risks breaking existing logic.

The fix is to extract a PaymentMethod interface. New payment types implement the interface. The service delegates to whichever implementation is injected.

classDiagram
  class PaymentMethod {
      <<interface>>
      +process(amount) boolean
  }
  class CreditCardPayment {
      +process(amount) boolean
  }
  class PayPalPayment {
      +process(amount) boolean
  }
  class BitcoinPayment {
      +process(amount) boolean
  }
  class PaymentService {
      -PaymentMethod method
      +executePayment(amount) boolean
  }
  PaymentMethod <|.. CreditCardPayment
  PaymentMethod <|.. PayPalPayment
  PaymentMethod <|.. BitcoinPayment
  PaymentService --> PaymentMethod

After applying Open/Closed: PaymentService is closed for modification. New payment types extend the system by adding classes, not editing existing ones.

Adding a fourth payment method now requires zero changes to PaymentService or any existing implementation. You write one new class, register it, and ship.

L: Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program.

This is the principle most people get wrong. It is not just “subclasses should override methods.” It means a subclass must honor the contract of the parent. If Rectangle has a method setWidth(int w) that sets only the width, and Square overrides it to also set the height (because squares have equal sides), then code that works with a Rectangle reference will break when handed a Square.

The classic bad example:

Rectangle r = getShape();  // might return Square
r.setWidth(5);
r.setHeight(10);
assert r.area() == 50;  // fails if r is a Square, area is 100

The fix: do not model Square as a subtype of Rectangle if the behavioral contract differs. Use a shared Shape interface instead, or make both immutable so the setter problem disappears entirely.

Practical test: take any method that accepts a base type. Pass every subtype through it. If the method still behaves correctly, Liskov is satisfied. If any subtype causes unexpected behavior, you have a violation.

I: Interface Segregation Principle

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

A fat interface with fifteen methods forces every implementor to provide all fifteen, even if a particular implementor only cares about three. This leads to empty method stubs, throw new UnsupportedOperationException(), and confusion for anyone reading the code.

classDiagram
  class Worker_Fat {
      <<interface>>
      +work()
      +eat()
      +sleep()
      +attendMeeting()
      +writeReport()
  }
  class Robot_Bad {
      +work()
      +eat() throws UnsupportedOperationException
      +sleep() throws UnsupportedOperationException
      +attendMeeting() throws UnsupportedOperationException
      +writeReport() throws UnsupportedOperationException
  }
  Worker_Fat <|.. Robot_Bad

Violation: Robot_Bad must implement methods that make no sense for it. The interface is too broad.

Split the fat interface into focused ones. Each client depends only on the slice it actually uses.

classDiagram
  class Workable {
      <<interface>>
      +work()
  }
  class Feedable {
      <<interface>>
      +eat()
  }
  class Reportable {
      <<interface>>
      +writeReport()
  }
  class HumanWorker {
      +work()
      +eat()
      +writeReport()
  }
  class RobotWorker {
      +work()
  }
  Workable <|.. HumanWorker
  Feedable <|.. HumanWorker
  Reportable <|.. HumanWorker
  Workable <|.. RobotWorker

After segregation: RobotWorker implements only Workable. No dead methods, no exceptions for unsupported operations.

A good heuristic: if you find yourself writing // not applicable inside a method body, the interface is too wide.

D: Dependency Inversion Principle

High level modules should not depend on low level modules. Both should depend on abstractions.

Without this principle, your business logic directly instantiates database clients, HTTP libraries, and file system accessors. Testing becomes painful because you cannot swap those out. Changing a database library ripples through every service that touches it.

With dependency inversion, the high level module defines an interface describing what it needs. The low level module implements that interface. The dependency arrow flips: both point toward the abstraction.

// Bad: OrderService directly depends on MySQLDatabase
class OrderService {
    private MySQLDatabase db = new MySQLDatabase();
}

// Good: OrderService depends on an abstraction
class OrderService {
    private OrderRepository repo;
    OrderService(OrderRepository repo) {
        this.repo = repo;
    }
}

OrderRepository is an interface. MySQLOrderRepository implements it. OrderService neither knows nor cares which database powers the repository. Swapping to PostgreSQL means writing a new implementation and updating the dependency injection configuration. The service code stays untouched.

How violations compound

Each SOLID violation in isolation seems minor. A class with two responsibilities is not the end of the world. A fat interface with a couple of dead methods is annoying but survivable. The real damage happens when violations stack.

A class that violates SRP tends to also violate OCP, because its multiple responsibilities make it hard to extend one without risking the other. A fat interface (ISP violation) often leads to Liskov violations, because implementors throw exceptions for methods they cannot support. Dependency inversion violations make all other violations harder to fix, because concrete dependencies create a web of coupling that resists refactoring.

In a young codebase, you barely notice. Six months later, every feature request takes three times longer than it should because each change fans out across tightly coupled classes. SOLID is cheapest when applied early.

Practical prioritization

You will not achieve perfect SOLID compliance in a real codebase, nor should you try. Here is a pragmatic ordering:

  1. Start with SRP. Small, focused classes are the foundation everything else builds on. If your classes are already small, the other principles become much easier to apply.
  2. Apply DIP at boundaries. Wherever your code talks to external systems (databases, APIs, file systems), inject an abstraction. This gives you testability immediately.
  3. Use OCP when you see a pattern repeating. The second time you add an if branch for a new type, extract an interface. The first time might be fine as a simple conditional.
  4. Enforce LSP in your type hierarchies. Whenever you introduce inheritance, verify that every subtype can stand in for the parent without surprises.
  5. Refactor toward ISP when interfaces grow. If an interface has more than five or six methods, consider whether it serves multiple distinct clients.

A SOLID design walkthrough

Consider a file storage system that must support local disk, S3, and Google Cloud Storage. Applying SOLID from the start:

classDiagram
  class FileStorage {
      <<interface>>
      +upload(String path, byte[] data) String
      +download(String path) byte[]
      +delete(String path) boolean
  }
  class LocalStorage {
      -String rootDir
      +upload(String path, byte[] data) String
      +download(String path) byte[]
      +delete(String path) boolean
  }
  class S3Storage {
      -String bucketName
      -AmazonS3Client client
      +upload(String path, byte[] data) String
      +download(String path) byte[]
      +delete(String path) boolean
  }
  class GCSStorage {
      -String bucketName
      -Storage client
      +upload(String path, byte[] data) String
      +download(String path) byte[]
      +delete(String path) boolean
  }
  class DocumentService {
      -FileStorage storage
      +saveDocument(Document doc) String
      +getDocument(String id) Document
  }
  FileStorage <|.. LocalStorage
  FileStorage <|.. S3Storage
  FileStorage <|.. GCSStorage
  DocumentService --> FileStorage

File storage system applying all five principles. DocumentService depends on the FileStorage abstraction (DIP). New backends extend without modifying existing code (OCP). Each class has one job (SRP). The interface is focused (ISP). All implementations honor the same contract (LSP).

  • SRP: each storage class handles one backend. DocumentService handles document logic.
  • OCP: adding Azure Blob Storage means one new class. No existing code changes.
  • LSP: every implementation uploads, downloads, and deletes consistently.
  • ISP: the FileStorage interface contains only the methods every backend must support.
  • DIP: DocumentService depends on FileStorage, not on S3Storage directly.

What comes next

With SOLID as your compass, you are ready to learn the reusable solutions that experienced engineers reach for repeatedly. The next article on creational design patterns covers Singleton, Factory Method, Abstract Factory, Builder, and Prototype, each solving a specific object creation problem that SOLID principles alone do not address.

Start typing to search across all content
navigate Enter open Esc close