Low Level Design: SOLID Principles in System Design

SOLID is an acronym for five object-oriented design principles introduced by Robert C. Martin (Uncle Bob). Originally articulated for class-level OOP design, these principles apply equally to module, service, and system-level design. They are not rules — they are heuristics for writing code that is maintainable, extensible, and testable over time.

Why SOLID Matters

Software entropy — the tendency of codebases to become increasingly difficult to change — is the primary cost driver in long-lived systems. Code that violates SOLID principles tends to exhibit: large classes that are hard to test, cascading changes where modifying one feature breaks unrelated features, brittle tests that break on every refactor, and difficulty onboarding new engineers.

SOLID principles address these symptoms. They are applicable in any language: Java, Python, Go, TypeScript, or even at the service level in microservice architectures.

Single Responsibility Principle (SRP)

Definition: A class (or module, or service) should have one, and only one, reason to change.

"Reason to change" is the key phrase. It means: a class should serve one stakeholder or one concern. If your UserService handles user CRUD operations, password hashing, email sending, and audit logging, it has four reasons to change: the user data model changes, the email provider changes, the hashing algorithm changes, or the audit format changes.

// Violation: UserService does too many things
class UserService {
    createUser(data) { /* DB write */ }
    sendWelcomeEmail(user) { /* SMTP call */ }  // separate concern
    logAuditEvent(action) { /* audit log write */ }  // separate concern
}

// Better: separate responsibilities
class UserService {
    createUser(data) { /* DB write only */ }
}
class NotificationService {
    sendWelcomeEmail(user) { /* SMTP call */ }
}
class AuditService {
    logEvent(action) { /* audit log write */ }
}

Violation symptom: A class with many private helper methods, or a class whose name contains "And" (UserAndNotificationService). Large classes with dozens of methods usually violate SRP. When a class is hard to name — "what do I even call this thing?" — it’s often because it does too many things.

Open/Closed Principle (OCP)

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

New behavior should be added by writing new code, not by editing existing, tested code. The classic implementation is the Strategy pattern: define an interface for a behavior, implement it in separate classes, and inject the implementation at runtime.

// Violation: adding a new payment method requires modifying Checkout
class Checkout {
    processPayment(method, amount) {
        if (method === 'stripe') { /* stripe logic */ }
        else if (method === 'paypal') { /* paypal logic */ }
        // Every new payment method requires editing this class
    }
}

// OCP-compliant: add new payment method by adding a new class
interface PaymentProcessor {
    charge(amount: number): void;
}
class StripeProcessor implements PaymentProcessor { charge(amount) { /* */ } }
class PayPalProcessor implements PaymentProcessor { charge(amount) { /* */ } }
class Checkout {
    constructor(private processor: PaymentProcessor) {}
    processPayment(amount) { this.processor.charge(amount); }
}

Violation symptom: Long switch/if-else chains that grow every time a new variant is added. If adding a new feature requires you to search-and-edit multiple files, OCP is being violated.

Liskov Substitution Principle (LSP)

Definition: Objects of a subclass must be substitutable for objects of the superclass without breaking the correctness of the program.

LSP is about behavioral subtyping — not just satisfying the interface signature, but satisfying the semantic contracts. The canonical violation is the Rectangle/Square example:

class Rectangle {
    setWidth(w) { this.width = w; }
    setHeight(h) { this.height = h; }
    area() { return this.width * this.height; }
}

class Square extends Rectangle {
    setWidth(w) { this.width = w; this.height = w; }  // LSP violation!
    setHeight(h) { this.height = h; this.width = h; } // LSP violation!
}

// This breaks when Square is substituted for Rectangle:
function testArea(rect) {
    rect.setWidth(5);
    rect.setHeight(4);
    // Expected: 20. With Square: 16. Caller is broken.
    assert(rect.area() === 20);
}

The fix is not to force Square to extend Rectangle. Model them as separate shapes implementing a common interface. LSP requires that preconditions cannot be strengthened in a subtype and postconditions cannot be weakened. Throwing NotImplementedException from an inherited method is a clear LSP violation.

Interface Segregation Principle (ISP)

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

Fat interfaces create unnecessary coupling. If an interface has 20 methods but a class only uses 3, any change to the other 17 methods forces a recompile and potential change to that class. The solution is to split fat interfaces into focused, cohesive ones.

// Fat interface — robots don't eat, but are forced to implement eat()
interface IWorker {
    work(): void;
    eat(): void;   // robots cannot eat — ISP violation
    sleep(): void; // robots do not sleep
}

// ISP-compliant: split into focused interfaces
interface IWorker { work(): void; }
interface IEater  { eat(): void; }
interface ISleeper { sleep(): void; }

class HumanWorker implements IWorker, IEater, ISleeper { /* all methods */ }
class RobotWorker implements IWorker { work() { /* only work */ } }

Practical guidance: Prefer many narrow interfaces over one wide one. In Go, this is idiomatic — interfaces are implicit and typically have one or two methods (io.Reader, io.Writer). In Java, avoid implementing interfaces just to satisfy a framework if you only use a fraction of the methods.

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.

DIP enables you to swap implementations without changing business logic, and it makes unit testing tractable by allowing mock/stub injection.

// Violation: OrderService directly depends on concrete SmtpEmailSender
class OrderService {
    private emailSender = new SmtpEmailSender(); // concrete dependency
    placeOrder(order) {
        // ... business logic ...
        this.emailSender.send(order.userEmail, 'Order confirmed');
    }
}

// DIP-compliant: depend on the IEmailSender abstraction
interface IEmailSender {
    send(to: string, body: string): void;
}
class OrderService {
    constructor(private emailSender: IEmailSender) {}
    placeOrder(order) {
        // ... business logic ...
        this.emailSender.send(order.userEmail, 'Order confirmed');
    }
}
// In production: inject SmtpEmailSender
// In tests: inject MockEmailSender — no SMTP server needed

Dependency injection containers (Spring in Java, Wire in Go, NestJS in TypeScript) automate DIP at scale. The principle is what makes them work.

SOLID at the System Design Level

SOLID principles map directly to system-level design decisions:

  • SRP → Microservices: Each service owns one business domain. The User service doesn’t handle billing; the Billing service doesn’t manage inventory. Each has one reason to change.
  • OCP → Plugin architecture: A system with a plugin API lets third parties add behavior without modifying the core product. Webhooks, event streams, and extension points are OCP at the system level.
  • LSP → Contract testing: In microservices, consumer-driven contract tests (Pact) verify that service substitutions (v1 → v2 of an API) don’t break consumers — behavioral subtyping at the service level.
  • ISP → API design: Design narrow, focused APIs rather than one large RPC service with 50 methods. GraphQL lets clients request only the fields they need — ISP for data fetching.
  • DIP → Storage abstraction: Define a repository interface (IUserRepository) that business logic depends on. Swap PostgreSQL for DynamoDB by writing a new implementation — business logic is unchanged.

When to Break SOLID

SOLID principles are means to an end, not ends in themselves. Blindly applying them leads to over-engineering. Watch for these signs that you’re over-applying them:

  • Premature abstraction: Creating an IEmailSender interface when there will never be a second implementation wastes time and adds indirection with no benefit. Wait until the second implementation is needed.
  • Micro-classes: Taking SRP to the extreme produces classes with one method each, making the codebase hard to navigate. A "reason to change" should be a meaningful concern, not a single line of code.
  • Interface explosion: ISP taken too far produces dozens of one-method interfaces that are hard to discover and understand as a system.

The pragmatic approach: start with simple, flat code. When a class starts accumulating multiple concerns (you feel resistance when adding a new feature, or tests become hard to set up), refactor toward SOLID. Don’t architect for SOLID from day one — let pain drive refactoring.

SOLID is a vocabulary for discussing design decisions and trade-offs. When you can articulate "this violates SRP because it has two reasons to change" or "this violates DIP because we can’t test it without a real database," you’re communicating at the level interviewers and senior engineers expect.

Scroll to Top