System Design
1. System Prerequisites

SOLID Principles

⏱ 0s active on this topic

What is SOLID?

SOLID is a set of 5 design principles for writing clean object-oriented code. Coined by Robert C. Martin (Uncle Bob).

LetterPrincipleOne-line summary
SSingle Responsibility PrincipleOne class, one job
OOpen / Closed PrincipleOpen for extension, closed for modification
LLiskov Substitution PrincipleSubtypes must be safely replaceable by their parent
IInterface Segregation PrincipleDon't force classes to implement what they don't need
DDependency Inversion PrincipleDepend on abstractions, not concrete classes


S — Single Responsibility Principle (SRP)


Definition

"A class should have only one reason to change." — Robert C. Martin

Every class should do one thing and do it well. If a class has multiple responsibilities, a change in one responsibility can break the other.


The Core Idea

One class = one job = one reason to change

If you find yourself saying "this class handles X AND Y AND Z"
— it is doing too much.
Split it.

❌ Without SRP

class UserService {
    // Responsibility 1: User business logic
    public User getUserById(String userId) { ... }
    public void updateUser(User user) { ... }

    // Responsibility 2: Database interaction
    public User queryFromDB(String userId) {
        // raw SQL here — DB logic inside business class
    }

    // Responsibility 3: Email notifications
    public void sendWelcomeEmail(User user) {
        // SMTP connection, email templating here
    }

    // Responsibility 4: Logging
    public void logUserActivity(String userId, String action) {
        // writing to log files here
    }
}

Problems:

  • Change in email provider → touch UserService
  • Change in DB schema → touch UserService
  • Change in log format → touch UserService
  • One class, four reasons to change = fragile

✅ With SRP

// Responsibility 1: User business logic only
class UserService {
    private UserRepository userRepo;
    private EmailService emailService;
    private Logger logger;

    public User getUserById(String userId) {
        return userRepo.findById(userId);
    }

    public void registerUser(User user) {
        userRepo.save(user);
        emailService.sendWelcomeEmail(user);
        logger.log("New user registered: " + user.getId());
    }
}

// Responsibility 2: DB interaction only
class UserRepository {
    public User findById(String userId) { /* DB query */ }
    public void save(User user) { /* DB insert */ }
}

// Responsibility 3: Email only
class EmailService {
    public void sendWelcomeEmail(User user) { /* SMTP logic */ }
    public void sendPasswordReset(User user) { /* SMTP logic */ }
}

// Responsibility 4: Logging only
class Logger {
    public void log(String message) { /* file/console write */ }
}

Result: Change email provider → touch only EmailService. DB change → touch only UserRepository. Nothing else breaks.


Real-World System Design Example

❌ Monolith doing everything:
   UserService → handles auth, profile, notifications, billing, analytics

✅ Microservices with SRP:
   AuthService        → only authentication & tokens
   UserProfileService → only user data management
   NotificationService → only sending emails/SMS/push
   BillingService     → only payments & invoices
   AnalyticsService   → only event tracking

Each service has one reason to change. Scale them independently. Deploy them independently.


Interview Talking Point

"SRP means one class, one reason to change. I apply it at both code level and system level. A class that handles business logic, DB calls, and email sending has three reasons to change. I split those into UserService, UserRepository, and EmailService. At the system level, microservices are SRP applied to services — each service owns exactly one domain."


Quick Revision

  1. One class = one job = one reason to change
  2. Split classes when you use the word "AND" to describe what it does
  3. Microservices are SRP at the architecture level
  4. Easier to test, maintain, and change independently


O — Open / Closed Principle (OCP)


Definition

"Software entities should be open for extension, but closed for modification." — Bertrand Meyer, popularised by Robert C. Martin

Open for extension → you can add new behaviour Closed for modification → without changing existing, tested code


The Core Idea

Add new features by writing NEW code.
Not by editing EXISTING code.

Every time you modify existing code, you risk breaking what already works.
OCP protects working code from change.

❌ Without OCP

class DiscountCalculator {
    public double calculate(String customerType, double price) {
        if (customerType.equals("REGULAR")) {
            return price * 0.95;       // 5% off
        } else if (customerType.equals("PREMIUM")) {
            return price * 0.85;       // 15% off
        } else if (customerType.equals("VIP")) {
            return price * 0.70;       // 30% off
        }
        // Adding a new type = modify this method = risk breaking existing logic
        return price;
    }
}

Problem: Every new customer type forces you to edit DiscountCalculator. This method grows indefinitely. One typo breaks all discount logic.


✅ With OCP

// Abstract the behaviour
interface DiscountStrategy {
    double apply(double price);
}

// One class per type — never touch existing ones to add new
class RegularDiscount implements DiscountStrategy {
    public double apply(double price) { return price * 0.95; }
}

class PremiumDiscount implements DiscountStrategy {
    public double apply(double price) { return price * 0.85; }
}

class VIPDiscount implements DiscountStrategy {
    public double apply(double price) { return price * 0.70; }
}

// New type needed? Just ADD a new class — nothing existing changes
class EmployeeDiscount implements DiscountStrategy {
    public double apply(double price) { return price * 0.60; }
}

// Calculator is closed for modification
class DiscountCalculator {
    public double calculate(DiscountStrategy strategy, double price) {
        return strategy.apply(price);  // delegates — never needs to change
    }
}

Result: Add 10 more discount types → zero changes to DiscountCalculator. Each strategy is isolated and independently testable.


Real-World System Design Example

Payment Gateway (OCP in architecture)

❌ Without OCP:
   PaymentService.processPayment() {
       if (provider == "razorpay") { ... }
       if (provider == "stripe") { ... }
       if (provider == "paytm") { ... }
       // adding PhonePe = modify this method
   }

✅ With OCP:
   interface PaymentGateway { charge(); refund(); }
   class RazorpayGateway implements PaymentGateway { ... }
   class StripeGateway implements PaymentGateway { ... }
   class PaytmGateway implements PaymentGateway { ... }
   // Adding PhonePe = new class only. PaymentService untouched.

Notification System (OCP in architecture)

interface NotificationChannel { send(userId, message); }
class EmailChannel implements NotificationChannel { ... }
class SMSChannel implements NotificationChannel { ... }
class PushChannel implements NotificationChannel { ... }
// Add WhatsApp channel = new class only

Interview Talking Point

"OCP means I write code that's open to new features but doesn't need to be modified to add them. When designing a payment service, instead of if/else chains for each provider, I define a PaymentGateway interface and a class per provider. Adding Razorpay tomorrow = new class, zero changes to PaymentService. The existing code stays untouched and stable."


Quick Revision

  1. Open = add new behaviour by writing new code
  2. Closed = existing working code is never touched
  3. Achieved via interfaces + strategy/factory patterns
  4. Every if/else chain checking a "type" is an OCP violation waiting to happen
  5. System-level: plugin architectures and gateway patterns follow OCP


L — Liskov Substitution Principle (LSP)


Definition

"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering the correctness of the program." — Barbara Liskov, 1987

In plain English: If class B extends class A, you should be able to use B anywhere A is used — and everything should still work correctly.


The Core Idea

Child classes must HONOUR the contract of the parent.
A child can do MORE — but must not do LESS or behave differently
in a way that breaks the caller's expectations.

❌ The Classic Violation — Rectangle & Square

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 area() { return width * height; }
}

// Square IS-A Rectangle mathematically — but this breaks LSP
class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        this.width = w;
        this.height = w;   // Square must keep sides equal — forces this override
    }

    @Override
    public void setHeight(int h) {
        this.height = h;
        this.width = h;    // Same problem
    }
}

// Caller uses Rectangle — LSP says Square should work here
void resizeRectangle(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    // Expected area: 50
    // With Square: area is 100 (because setHeight set width too)
    // Behaviour changed — LSP VIOLATED
    System.out.println(r.area());  // prints 100, not 50 — bug
}

Fix: Square and Rectangle should NOT be in an inheritance relationship. Both should implement a Shape interface separately.


✅ LSP Compliant Example

interface Shape {
    int area();
}

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

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

// Any Shape works here — substitution is safe
void printArea(Shape s) {
    System.out.println("Area: " + s.area());  // works correctly for both
}

Another Common Violation — Throwing Unexpected Exceptions

class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}

// Penguin IS-A Bird — but penguins can't fly
class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
        // Caller expected fly() to work — LSP VIOLATED
    }
}

void makeBirdFly(Bird b) {
    b.fly();  // crashes when b is a Penguin — caller had no idea
}

Fix: Split the hierarchy.

interface Bird { void eat(); }
interface FlyingBird extends Bird { void fly(); }

class Eagle implements FlyingBird {
    public void eat() { ... }
    public void fly() { ... }
}

class Penguin implements Bird {
    public void eat() { ... }
    // No fly() — correct
}

LSP in System Design

API Contract Violations

❌ LSP violation at service level:

Interface contract: UserRepository.findById() returns a User or null

MySQLUserRepository.findById() → returns User or null  ✅
CachedUserRepository.findById() → throws exception when cache is cold  ❌
// Caller expected null for missing user, got an exception instead
// Substituting CachedUserRepository for MySQLUserRepository breaks the caller

Rule: When a service or class claims to implement an interface, it must fully and correctly honor the contract — no surprises in behaviour.


Interview Talking Point

"LSP says a subclass must be safely substitutable for its parent without breaking callers. The classic example is Square extending Rectangle — setting the width changes the height too, which breaks any code that treats it as a Rectangle. At the system level, if I define a UserRepository interface, every implementation — MySQL, Redis cache, mock — must behave identically from the caller's perspective. Unexpected exceptions or changed return values are LSP violations at the service boundary."


Quick Revision

  1. Subtypes must be swappable for their parent without breaking anything
  2. Classic violation: Square extends Rectangle — unexpected side effects
  3. Bird / Penguin — don't inherit what you can't honour
  4. At system level: all implementations of an interface must honour the same contract
  5. Fix violations by splitting hierarchies at the right abstraction level


I — Interface Segregation Principle (ISP)


Definition

"Clients should not be forced to depend upon interfaces they do not use." — Robert C. Martin

Break large, fat interfaces into smaller, focused ones. No class should be forced to implement methods it doesn't need.


The Core Idea

A fat interface forces all implementors to deal with ALL methods —
even ones that don't apply to them.

Split the interface so each implementor only sees what it actually needs.

❌ Without ISP — Fat Interface

// One big interface for all workers
interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
    void submitTimesheet();
}

// Human worker — all methods make sense ✅
class HumanWorker implements Worker {
    public void work() { ... }
    public void eat() { ... }
    public void sleep() { ... }
    public void attendMeeting() { ... }
    public void submitTimesheet() { ... }
}

// Robot worker — robots don't eat, sleep, or submit timesheets ❌
class RobotWorker implements Worker {
    public void work() { ... }
    public void eat() { throw new UnsupportedOperationException(); }   // forced
    public void sleep() { throw new UnsupportedOperationException(); } // forced
    public void attendMeeting() { throw new UnsupportedOperationException(); }
    public void submitTimesheet() { throw new UnsupportedOperationException(); }
}

Problem: RobotWorker is forced to implement methods that don't apply to it. This is an LSP violation waiting to happen too.


✅ With ISP — Segregated Interfaces

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

interface MeetingAttendee {
    void attendMeeting();
}

interface TimesheetSubmitter {
    void submitTimesheet();
}

// Human implements only what applies
class HumanWorker implements Workable, Eatable, Sleepable, MeetingAttendee, TimesheetSubmitter {
    public void work() { ... }
    public void eat() { ... }
    public void sleep() { ... }
    public void attendMeeting() { ... }
    public void submitTimesheet() { ... }
}

// Robot implements only what applies — clean
class RobotWorker implements Workable {
    public void work() { ... }
    // Nothing else forced — correct
}

Practical Example — File Operations

// ❌ Fat interface — not all implementations support all operations
interface FileManager {
    void read(String path);
    void write(String path, String content);
    void delete(String path);
    void compress(String path);
    void encrypt(String path);
}

// ReadOnlyFileManager has to implement write(), delete() — makes no sense
class ReadOnlyFileManager implements FileManager {
    public void read(String path) { ... }
    public void write(String path, String content) {
        throw new UnsupportedOperationException();  // forced — wrong
    }
    // ...
}

// ✅ Segregated
interface FileReader { void read(String path); }
interface FileWriter { void write(String path, String content); }
interface FileDeleter { void delete(String path); }
interface FileCompressor { void compress(String path); }

class ReadOnlyFileManager implements FileReader {
    public void read(String path) { ... }  // only what it needs
}

class FullFileManager implements FileReader, FileWriter, FileDeleter {
    // implements only relevant capabilities
}

Real-World System Design Example

Microservice API Contracts (ISP at scale)

❌ Fat service interface:
   UserService API:
   - getUser()
   - updateUser()
   - deleteUser()
   - getUserOrders()       ← should be in OrderService
   - getUserPayments()     ← should be in PaymentService
   - getUserNotifications() ← should be in NotificationService

✅ Segregated service contracts:
   UserService     → getUser(), updateUser(), deleteUser()
   OrderService    → getOrdersByUser()
   PaymentService  → getPaymentsByUser()

Each service exposes only what it owns. Consumers depend only on what they need.

Repository Interfaces

// Not every service needs all operations
interface ReadableUserRepo { User findById(String id); }
interface WritableUserRepo { void save(User user); void delete(String id); }

// Analytics service — only needs read access
class AnalyticsService {
    private ReadableUserRepo userRepo;  // doesn't see write methods
}

// Admin service — needs full access
class AdminService {
    private ReadableUserRepo readRepo;
    private WritableUserRepo writeRepo;
}

Interview Talking Point

"ISP says don't force a class to implement methods it doesn't use. If I have a Worker interface with work(), eat(), and sleep(), a RobotWorker is forced to implement eat() and sleep() with fake or error responses — that's wrong. I split the interface: Workable, Eatable, Sleepable — each class picks what applies. At the system level, a microservice's API should only expose endpoints relevant to its own domain, not bleed into other services' responsibilities."


Quick Revision

  1. Fat interfaces force irrelevant methods on implementors — split them
  2. Clients depend only on the interface methods they actually use
  3. Robot/Human Worker — classic ISP example
  4. At system level: each microservice exposes only its own domain operations
  5. ISP violations are often caught when you see throw new UnsupportedOperationException()


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." — Robert C. Martin

In plain English: Your business logic should depend on interfaces, not on specific database classes, email libraries, or frameworks.


The Core Idea

High-level  =  business logic  (OrderService, PaymentService)
Low-level   =  infrastructure  (MySQL, Redis, Razorpay, SMTP)

Without DIP:  OrderService → MySQL (directly)
With DIP:     OrderService → Repository interface ← MySQL implements it

The arrow of dependency points to the ABSTRACTION, not the concrete class.

❌ Without DIP — Tight Coupling to Infrastructure

class OrderService {
    // Directly depends on concrete MySQL class — low-level detail
    private MySQLOrderRepository repo = new MySQLOrderRepository();
    private SMTPEmailService emailService = new SMTPEmailService();
    private RazorpayPaymentGateway paymentGateway = new RazorpayPaymentGateway();

    public void placeOrder(Order order) {
        repo.save(order);                        // coupled to MySQL
        emailService.sendConfirmation(order);    // coupled to SMTP
        paymentGateway.charge(order);            // coupled to Razorpay
    }
}

Problems:

  • Want to switch MySQL → MongoDB? Rewrite OrderService
  • Want to switch Razorpay → Stripe? Rewrite OrderService
  • Want to test OrderService? Need a real DB, real SMTP, real Razorpay running
  • Business logic is polluted with infrastructure concerns

✅ With DIP — Depends on Abstractions

// Abstractions (interfaces) — defined in the business layer
interface OrderRepository {
    void save(Order order);
    Order findById(String orderId);
}

interface EmailService {
    void sendConfirmation(Order order);
}

interface PaymentGateway {
    void charge(Order order);
}

// High-level module depends ONLY on interfaces
class OrderService {
    private OrderRepository repo;
    private EmailService emailService;
    private PaymentGateway paymentGateway;

    // Dependencies injected from outside (Dependency Injection)
    public OrderService(OrderRepository repo,
                        EmailService emailService,
                        PaymentGateway paymentGateway) {
        this.repo = repo;
        this.emailService = emailService;
        this.paymentGateway = paymentGateway;
    }

    public void placeOrder(Order order) {
        repo.save(order);
        emailService.sendConfirmation(order);
        paymentGateway.charge(order);
        // OrderService has NO idea about MySQL, SMTP, or Razorpay
    }
}

// Low-level modules implement the interfaces
class MySQLOrderRepository implements OrderRepository { ... }
class MongoOrderRepository implements OrderRepository { ... }

class SMTPEmailService implements EmailService { ... }
class SendGridEmailService implements EmailService { ... }

class RazorpayPaymentGateway implements PaymentGateway { ... }
class StripePaymentGateway implements PaymentGateway { ... }

Wiring (done once at startup — in main or IoC container):

OrderService service = new OrderService(
    new MySQLOrderRepository(),
    new SMTPEmailService(),
    new RazorpayPaymentGateway()
);
// To switch: swap the concrete class here only — OrderService untouched

DIP + Dependency Injection (DI) — How They Relate

DIP is the principle (depend on abstractions). Dependency Injection is the technique that implements DIP (inject the dependency from outside).

Without DI:  class creates its own dependencies  →  new MySQLRepo() inside class
With DI:     dependencies are passed in          →  constructor / setter injection
// Constructor Injection (preferred)
class UserService {
    private UserRepository repo;
    public UserService(UserRepository repo) { this.repo = repo; }
}

// Setter Injection
class UserService {
    private UserRepository repo;
    public void setRepo(UserRepository repo) { this.repo = repo; }
}

Frameworks like Spring, NestJS, ASP.NET Core handle DI automatically via IoC containers.


Real-World System Design Examples

1. Repository Pattern (DIP in data layer)

Business Layer:          UserService → UserRepository (interface)
                                               ↑
Infrastructure Layer:    MySQLUserRepository implements UserRepository
                         RedisUserRepository implements UserRepository (for caching)
                         MockUserRepository implements UserRepository (for tests)

2. Notification System

OrderService → NotificationService (interface)
                      ↑
         EmailNotificationService
         SMSNotificationService
         PushNotificationService

3. Testing Benefit (the real payoff of DIP)

// In tests — inject a mock, no real DB needed
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById("u1")).thenReturn(new User("u1", "Alice"));

UserService service = new UserService(mockRepo);
service.getUserById("u1");  // tests business logic only — no DB, no network

Interview Talking Point

"DIP means business logic depends on abstractions, not concrete infrastructure. OrderService shouldn't import MySQLOrderRepository directly — it should depend on an OrderRepository interface. The MySQL implementation is injected at startup. This means I can swap MySQL for MongoDB by changing one line in my config, mock the repository in tests without a real database, and keep my business logic completely independent of infrastructure choices. Spring's dependency injection is the most common implementation of this principle."


Quick Revision

  1. High-level (business logic) must NOT depend directly on low-level (DB, email, payments)
  2. Both depend on interfaces — direction of dependency is inverted toward abstraction
  3. DIP is the principle; Dependency Injection is the technique to implement it
  4. Real payoff: swap infrastructure freely + test business logic with mocks
  5. Repository pattern, gateway pattern, strategy pattern all implement DIP


How This Connects

  • Abstraction / Polymorphism / Dependency Injection — the three tools you use to actually implement SOLID. O and D are basically "put an interface here and inject it".
  • Cohesion & Coupling — S is high cohesion by another name; D and I are low coupling by another name.
  • Composition vs Inheritance — "favour composition" is how you keep L (Liskov) from breaking: don't build inheritance chains you can't honour.
  • Design Patterns — Strategy/Factory implement O; Repository/Gateway implement D; splitting fat interfaces implements I.
  • Module 3 — HLD — microservices are S at the service level; "no shared database" and API contracts are D and I at architecture scale.

SOLID — Complete Summary Table

PrincipleRuleViolation SignFix
SOne class, one reason to changeClass does X AND Y AND ZSplit into separate classes
OExtend without modifyingif/else or switch on "type" everywhereInterfaces + strategy pattern
LSubtypes replaceable for parentChild throws UnsupportedOperationExceptionRedesign hierarchy or use composition
INo forced unused methodsImplementing methods just to throw exceptionsSplit fat interface into focused ones
DDepend on abstractions, not concretionsnew MySQLRepo() inside business classInject via interface + DI

Combined Interview Answer (If Asked "Explain SOLID")

"SOLID is five principles for writing maintainable OO code.

S — each class has one job. UserService handles user logic; a separate EmailService handles email — change in email provider doesn't touch UserService.

O — add features by writing new code, not editing existing code. New payment provider = new class implementing PaymentGateway interface, not a new if block in PaymentService.

L — subclasses must behave correctly when used as their parent. Square extending Rectangle violates this — setting width changes height unexpectedly.

I — don't force classes to implement unused methods. A RobotWorker shouldn't be forced to implement eat() just because a fat Worker interface demands it.

D — business logic depends on interfaces, not concrete classes. OrderService depends on OrderRepository interface — MySQL, MongoDB, or a mock can plug in freely.

Together they produce code that's easy to test, change, and extend without cascading breakages."

Quick-revision quiz

Question 1 of 17
Name all five SOLID principles with a one-line meaning each.