Low Level System Design, Design Patterns & SOLID Principles
Course content
OOPS - Classes, Objects, Interfaces, Inheritance & Polymorphism
Access Modifers
Unified Modelling Language (UML)
SOLID Principles
SOLID Principles
Single Responsibility Principle (SRP)
Open Close Principle (OCP)
Liskov Substiution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
Behavioural Design Patterns - Part I
Introduction to Design Patterns
Introduction to Behavioural Design Patterns
Memento Pattern Quiz
Undo/Redo Problem Statement
Solution using Memento Pattern
Memento Pattern Summary
Graphic Editor Application Exercise
Observer Pattern
Problem - Publisher Subscriber Model
Solution - Observer Pattern
Implementation - Observer Pattern
Observer Pattern Benefits & Use Cases
Stock Price Monitoring System Application Exercise
Strategy Pattern
Strategy Pattern - Problem
Strategy Pattern - Solution
FlexiText Formatter Application Exercise
Command Pattern
Command Pattern - Problem Statement
Command Pattern - Solution
Command Pattern - Benefits & Use Case
Remote Control System Application Exercise
Template Method Pattern
Template Method Pattern - Problem Statement
Template Method Pattern - Solution
Report Generator Application Exercise
Iterator Pattern
Iterator Pattern - Problem Statement
Iterator Pattern - Solution
Iterator Pattern - Benefits & Java Iterables
State Pattern
State Pattern - Problem
State Pattern - Implementation & Benefits
Mediator Pattern
Mediator Pattern - Problem Statement
Mediator Pattern - Implementation
Mediator Pattern - Benefits & Use Cases
System Design Refreshers
Designing a Robust Distributed Logging System for Modern Applications
20 System Design Concepts Every Developer Should Know - Part - I
SOLID Principles
The SOLID principles were introduced by Robert C. Martin in his 2000 paper “Design Principles and Design Patterns.” These concepts were later built upon by Michael Feathers, who introduced us to the SOLID acronym. And in the last 20 years, these five principles have revolutionized the world of object-oriented programming, changing the way that we write software.
Single Responsibility
Open/Closed
Liskov Substitution
Interface Segregation
Dependency Inversion
1. Single Responsibility Principle (SRP): One Job, One Class
“A class should have only one reason to change.”
Imagine a chef responsible for cooking, serving, and washing dishes. If the menu changes, or a new dishwashing machine is installed, or the serving style is updated, the same chef (class) needs to adapt to all changes. This makes the chef’s role complex and brittle.
SRP suggests that each class should have a single, well-defined responsibility.
Bad Example (SRP Violation):
class UserSettings {
private String userName;
public void changeUsername(String newName) {
this.userName = newName;
System.out.println(”Username changed to “ + newName);
}
// This class is also saving to DB (another responsibility)
public void saveToDatabase() {
System.out.println(”User settings saved to database.”);
}
// And also logging (yet another responsibility)
public void logAction(String action) {
System.out.println(”LOG: “ + action);
}
}Here, UserSettings is doing three things: managing user data, saving to a database, and logging.
Good Example (SRP Adherence):
We split the responsibilities into focused classes:
class UserProfile { // Only responsible for user data
private String userName;
public void setUserName(String newName) {
this.userName = newName;
System.out.println(”User profile updated: “ + newName);
}
public String getUserName() { return userName; }
}
class UserPersistence { // Only responsible for saving/loading user data
public void save(UserProfile user) {
System.out.println(”User persistence: Saving user profile.”);
// Logic to save user to DB
}
}
class SystemLogger { // Only responsible for logging
public void log(String message) {
System.out.println(”SYSTEM LOG: “ + message);
}
}2. Open/Closed Principle (OCP): Extend, Don’t Modify
“Software entities should be open for extension, but closed for modification.”
This principle means that you should be able to add new functionality to your code without changing existing, working code. It often relies on polymorphism, interfaces, and abstract classes.
Bad Example (OCP Violation):
class Rectangle {
public double width, height;
public Rectangle(double w, double h) { this.width = w; this.height = h; }
}
class Circle {
public double radius;
public Circle(double r) { this.radius = r; }
}
class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
return rect.width * rect.height;
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius;
}
// If we add a new shape (e.g., Triangle), we MUST MODIFY this method.
return 0;
}
}
Adding a new shape means AreaCalculator needs to be modified, violating OCP.
Good Example (OCP Adherence):
interface Shape { // Open for extension (new shapes can implement this)
double calculateArea();
}
class Rectangle implements Shape {
private double width, height;
public Rectangle(double w, double h) { this.width = w; this.height = h; }
@Override
public double calculateArea() { return width * height; }
}
class Circle implements Shape {
private double radius;
public Circle(double r) { this.radius = r; }
@Override
public double calculateArea() { return Math.PI * radius * radius; }
}
class AreaCalculator { // Closed for modification
public double calculateTotalArea(Shape[] shapes) {
double totalArea = 0;
for (Shape shape : shapes) {
totalArea += shape.calculateArea(); // Works for ANY Shape implementation
}
return totalArea;
}
}
Now, adding a Triangle means creating a new Triangle class that implements Shape. AreaCalculator remains untouched.
3. Liskov Substitution Principle (LSP): Substitutability Matters
“Objects of a superclass should be replaceable with objects of a subclass without breaking the application.”
This principle ensures that subclasses don’t alter the expected behavior defined by their superclass. If a method expects a Bird and you give it a Penguin, it shouldn’t suddenly crash because penguins can’t fly.
Bad Example (LSP Violation):
class Bird {
public void fly() {
System.out.println(”The bird is flying.”);
}
}
class Penguin extends Bird {
@Override
public void fly() {
// Penguins can’t fly, this breaks the contract of Bird.fly()
throw new UnsupportedOperationException(”Penguins cannot fly!”);
}
}
A Penguin cannot truly “substitute” for a Bird in scenarios where fly() is expected.
Good Example (LSP Adherence):
We rethink the hierarchy to better reflect real-world capabilities.
interface Bird { /* Common Bird properties */ }
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
class Eagle implements Bird, Flyable {
@Override
public void fly() {
System.out.println(”Eagle soaring high.”);
}
}
class Penguin implements Bird, Swimmable {
@Override
public void swim() {
System.out.println(”Penguin diving gracefully.”);
}
}
// Now, a method that expects a Flyable can correctly handle an Eagle.
// A method expecting a Swimmable can correctly handle a Penguin.
By using smaller, more specific interfaces, we ensure that concrete classes only implement behaviors they actually support, upholding LSP.
4. Interface Segregation Principle (ISP): Small, Client-Specific Interfaces
“Clients should not be forced to depend on interfaces they do not use.”
ISP is about keeping interfaces lean and focused. Instead of one large, “fat” interface with many methods, create several smaller, role-specific interfaces. This prevents classes from being forced to implement methods they don’t need, often resulting in empty implementations or UnsupportedOperationExceptions.
Bad Example (ISP Violation):
interface MultiFunctionDevice {
void print();
void scan();
void fax(); // Not all devices can fax
void staple(); // Not all devices can staple
}
class SimplePrinter implements MultiFunctionDevice {
@Override public void print() { /* ... */ }
@Override public void scan() { /* ... */ }
// Forced to implement these, even if not supported
@Override public void fax() {
throw new UnsupportedOperationException(”Fax not supported”);
}
@Override public void staple() { /* Does nothing */ }
}
SimplePrinter is burdened by methods it cannot perform.
Good Example (ISP Adherence):
interface Printer {
void print();
}
interface Scanner {
void scan();
}
interface Faxer {
void fax();
}
class BasicPrinter implements Printer { // Only implements what it needs
@Override
public void print() { System.out.println(”Printing document.”); }
}
class AllInOneDevice implements Printer, Scanner, Faxer { // Implements all supported functions
@Override public void print() { System.out.println(”AIO: Printing.”); }
@Override public void scan() { System.out.println(”AIO: Scanning.”); }
@Override public void fax() { System.out.println(”AIO: Faxing.”); }
}
Clients (classes) now only depend on the specific interfaces relevant to their needs, making the design more flexible.
5. Dependency Inversion Principle (DIP): Depend on Abstractions
“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.”
This principle encourages decoupling between high-level policy-making modules and low-level detail modules. Instead of a high-level component directly creating or relying on a concrete low-level component, both should depend on an abstraction (an interface or abstract class). This is the cornerstone of Dependency Injection.
Bad Example (DIP Violation):
// Low-level module (concrete detail)
class HardDrive {
public void readData() {
System.out.println(”Reading data from Hard Drive.”);
}
}
// High-level module (application logic)
class Computer {
private HardDrive drive; // Direct dependency on a concrete low-level class
public Computer() {
this.drive = new HardDrive(); // Computer creates its own dependency
}
public void start() {
drive.readData();
System.out.println(”Computer booting up.”);
}
}
If we want to use an SSD instead of a HardDrive, we’d have to modify the Computer class.
Good Example (DIP Adherence):
// Abstraction (interface)
interface StorageDevice {
void readData();
}
// Low-level details depend on the abstraction
class HardDrive implements StorageDevice {
@Override
public void readData() {
System.out.println(”Reading data from Hard Drive.”);
}
}
class SSD implements StorageDevice {
@Override
public void readData() {
System.out.println(”Reading data from SSD.”);
}
}
// High-level module depends on the abstraction (via Dependency Injection)
class Computer {
private StorageDevice storage; // Depends on the abstraction, not the detail
// Dependency is injected through the constructor
public Computer(StorageDevice storageDevice) {
this.storage = storageDevice;
}
public void start() {
storage.readData();
System.out.println(”Computer booting up.”);
}
}
class Main {
public static void main(String[] args) {
// The client configures the dependencies
Computer myPcWithHdd = new Computer(new HardDrive());
myPcWithHdd.start();
Computer myPcWithSsd = new Computer(new SSD());
myPcWithSsd.start();
}
}
Now, Computer doesn’t care what kind of StorageDevice it gets, as long as it implements the StorageDevice interface. This makes Computer highly flexible and testable.
In Next Part I will explain Behavioural Design Patterns - Part I , Till than stray tuned .
Like and Share this Post .


