SOLID Principles: Writing Clean, Maintainable Code

Introduction
You've learned the fundamentals of OOP—classes, encapsulation, inheritance, and polymorphism. But knowing OOP syntax isn't enough. How do you decide when to create a new class? How should classes relate to each other? What makes code easy to change months later?
The SOLID principles answer these questions. Coined by Robert C. Martin (Uncle Bob), these five principles are the bridge between understanding OOP and writing software that's genuinely maintainable, testable, and extensible.
What You'll Learn
✅ Apply the Single Responsibility Principle to create focused classes
✅ Use the Open/Closed Principle to extend behavior without modifying code
✅ Respect the Liskov Substitution Principle for safe inheritance
✅ Design with the Interface Segregation Principle for lean contracts
✅ Implement the Dependency Inversion Principle for flexible architectures
✅ Recognize SOLID violations and refactor them step by step
Prerequisites
- Completed The Four Pillars of OOP
- Completed Encapsulation and Information Hiding
- Completed Inheritance and Composition
- Completed Polymorphism and Interfaces
Why SOLID Matters
Before diving into each principle, let's understand the problems SOLID solves:
| Problem | Without SOLID | With SOLID |
|---|---|---|
| Adding features | Modify existing classes, risk breaking things | Add new classes, existing code untouched |
| Testing | Can't test one thing without setting up everything | Each class testable in isolation |
| Understanding code | One class does 10 things | Each class has a clear purpose |
| Changing requirements | Changes ripple through the entire codebase | Changes isolated to specific modules |
| Team collaboration | Everyone edits the same files | Teams work on separate, well-defined modules |
SOLID isn't about following rules blindly—it's about managing complexity as your codebase grows.
S — Single Responsibility Principle (SRP)
A class should have only one reason to change.
The SRP says each class should do one thing and do it well. If a class has multiple responsibilities, changes to one responsibility can break the other.
Violation: The God Class
// TypeScript - SRP Violation
class UserService {
// Responsibility 1: User data management
createUser(name: string, email: string): User {
const user = { id: Date.now(), name, email };
this.saveToDatabase(user);
this.sendWelcomeEmail(user);
this.logAction(`User created: ${email}`);
return user;
}
// Responsibility 2: Database operations
private saveToDatabase(user: User): void {
// Direct SQL query
console.log(`INSERT INTO users VALUES (${user.id}, '${user.name}', '${user.email}')`);
}
// Responsibility 3: Email sending
private sendWelcomeEmail(user: User): void {
// SMTP configuration and sending
console.log(`Sending welcome email to ${user.email}`);
}
// Responsibility 4: Logging
private logAction(message: string): void {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${message}`);
}
}Why is this bad? If you change the email provider, you modify UserService. If you switch databases, you modify UserService. If you change logging format, you modify UserService. One class, four reasons to change.
Refactored: Each Class, One Job
// TypeScript - SRP Applied
interface User {
id: number;
name: string;
email: string;
}
// Responsibility: Persist users
class UserRepository {
save(user: User): void {
console.log(`Saving user ${user.email} to database`);
}
findByEmail(email: string): User | null {
console.log(`Finding user by email: ${email}`);
return null;
}
}
// Responsibility: Send emails
class EmailService {
sendWelcome(user: User): void {
console.log(`Sending welcome email to ${user.email}`);
}
sendPasswordReset(user: User): void {
console.log(`Sending password reset to ${user.email}`);
}
}
// Responsibility: Log actions
class ActionLogger {
log(message: string): void {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${message}`);
}
}
// Responsibility: Orchestrate user creation
class UserService {
constructor(
private repository: UserRepository,
private emailService: EmailService,
private logger: ActionLogger
) {}
createUser(name: string, email: string): User {
const user: User = { id: Date.now(), name, email };
this.repository.save(user);
this.emailService.sendWelcome(user);
this.logger.log(`User created: ${email}`);
return user;
}
}Now UserService has one job: orchestrating user creation. Each collaborator handles its own responsibility.
# Python - SRP Applied
from dataclasses import dataclass
from datetime import datetime
@dataclass
class User:
id: int
name: str
email: str
class UserRepository:
"""Responsibility: Persist users"""
def save(self, user: User) -> None:
print(f"Saving user {user.email} to database")
def find_by_email(self, email: str) -> User | None:
print(f"Finding user by email: {email}")
return None
class EmailService:
"""Responsibility: Send emails"""
def send_welcome(self, user: User) -> None:
print(f"Sending welcome email to {user.email}")
class ActionLogger:
"""Responsibility: Log actions"""
def log(self, message: str) -> None:
timestamp = datetime.now().isoformat()
print(f"[{timestamp}] {message}")
class UserService:
"""Responsibility: Orchestrate user creation"""
def __init__(
self,
repository: UserRepository,
email_service: EmailService,
logger: ActionLogger
):
self._repository = repository
self._email_service = email_service
self._logger = logger
def create_user(self, name: str, email: str) -> User:
user = User(id=int(datetime.now().timestamp()), name=name, email=email)
self._repository.save(user)
self._email_service.send_welcome(user)
self._logger.log(f"User created: {email}")
return user// Java - SRP Applied
public class UserRepository {
// Responsibility: Persist users
public void save(User user) {
System.out.println("Saving user " + user.getEmail() + " to database");
}
public Optional<User> findByEmail(String email) {
System.out.println("Finding user by email: " + email);
return Optional.empty();
}
}
public class EmailService {
// Responsibility: Send emails
public void sendWelcome(User user) {
System.out.println("Sending welcome email to " + user.getEmail());
}
}
public class ActionLogger {
// Responsibility: Log actions
public void log(String message) {
String timestamp = java.time.Instant.now().toString();
System.out.println("[" + timestamp + "] " + message);
}
}
public class UserService {
// Responsibility: Orchestrate user creation
private final UserRepository repository;
private final EmailService emailService;
private final ActionLogger logger;
public UserService(UserRepository repository, EmailService emailService, ActionLogger logger) {
this.repository = repository;
this.emailService = emailService;
this.logger = logger;
}
public User createUser(String name, String email) {
User user = new User(System.currentTimeMillis(), name, email);
repository.save(user);
emailService.sendWelcome(user);
logger.log("User created: " + email);
return user;
}
}How to Identify SRP Violations
Ask yourself:
- Can you describe the class with "and"? — "This class manages users and sends emails and logs actions" = violation
- Do unrelated changes force modifications? — Changing email provider shouldn't touch user logic
- Is the class hard to name? — If you need a generic name like
ManagerorHandler, it probably does too much
O — Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
You should be able to add new behavior without changing existing code. This is achieved through abstraction and polymorphism.
Violation: Switch Statements That Grow
// TypeScript - OCP Violation
class DiscountCalculator {
calculate(customerType: string, amount: number): number {
switch (customerType) {
case "regular":
return amount * 0.05;
case "premium":
return amount * 0.10;
case "vip":
return amount * 0.20;
// Every new customer type requires modifying this class!
default:
return 0;
}
}
}Why is this bad? Adding a new customer type (e.g., "enterprise") requires modifying DiscountCalculator. Every modification risks breaking existing discount logic.
Refactored: Open for Extension
// TypeScript - OCP Applied
interface DiscountStrategy {
calculate(amount: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(amount: number): number {
return amount * 0.05;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(amount: number): number {
return amount * 0.10;
}
}
class VIPDiscount implements DiscountStrategy {
calculate(amount: number): number {
return amount * 0.20;
}
}
// Adding new discount? Just create a new class!
class EnterpriseDiscount implements DiscountStrategy {
calculate(amount: number): number {
return amount * 0.25;
}
}
// Seasonal discount with custom logic
class SeasonalDiscount implements DiscountStrategy {
constructor(private baseRate: number, private seasonalBonus: number) {}
calculate(amount: number): number {
return amount * (this.baseRate + this.seasonalBonus);
}
}
// DiscountCalculator never needs to change
class DiscountCalculator {
calculate(strategy: DiscountStrategy, amount: number): number {
return strategy.calculate(amount);
}
}
// Usage
const calculator = new DiscountCalculator();
console.log(calculator.calculate(new RegularDiscount(), 100)); // 5
console.log(calculator.calculate(new VIPDiscount(), 100)); // 20
console.log(calculator.calculate(new EnterpriseDiscount(), 100)); // 25 - No changes to calculator!# Python - OCP Applied
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate(self, amount: float) -> float:
pass
class RegularDiscount(DiscountStrategy):
def calculate(self, amount: float) -> float:
return amount * 0.05
class PremiumDiscount(DiscountStrategy):
def calculate(self, amount: float) -> float:
return amount * 0.10
class VIPDiscount(DiscountStrategy):
def calculate(self, amount: float) -> float:
return amount * 0.20
# Adding new discount - no existing code modified
class EnterpriseDiscount(DiscountStrategy):
def calculate(self, amount: float) -> float:
return amount * 0.25
class SeasonalDiscount(DiscountStrategy):
def __init__(self, base_rate: float, seasonal_bonus: float):
self._base_rate = base_rate
self._seasonal_bonus = seasonal_bonus
def calculate(self, amount: float) -> float:
return amount * (self._base_rate + self._seasonal_bonus)
class DiscountCalculator:
def calculate(self, strategy: DiscountStrategy, amount: float) -> float:
return strategy.calculate(amount)
# Usage
calculator = DiscountCalculator()
print(calculator.calculate(RegularDiscount(), 100)) # 5.0
print(calculator.calculate(VIPDiscount(), 100)) # 20.0
print(calculator.calculate(EnterpriseDiscount(), 100)) # 25.0// Java - OCP Applied
public interface DiscountStrategy {
double calculate(double amount);
}
public class RegularDiscount implements DiscountStrategy {
@Override
public double calculate(double amount) {
return amount * 0.05;
}
}
public class PremiumDiscount implements DiscountStrategy {
@Override
public double calculate(double amount) {
return amount * 0.10;
}
}
public class VIPDiscount implements DiscountStrategy {
@Override
public double calculate(double amount) {
return amount * 0.20;
}
}
// New discount - zero changes to existing classes
public class EnterpriseDiscount implements DiscountStrategy {
@Override
public double calculate(double amount) {
return amount * 0.25;
}
}
public class DiscountCalculator {
public double calculate(DiscountStrategy strategy, double amount) {
return strategy.calculate(amount);
}
}OCP in Practice
The key pattern is: use abstraction (interfaces/abstract classes) as extension points. When requirements change, you create a new implementation rather than modifying existing ones.
Common OCP applications:
- Plugin architectures — new plugins without changing the core
- Payment processors — new payment methods without modifying checkout
- Export formats — new export types (PDF, CSV, XML) without changing the export engine
- Notification channels — new channels (Slack, SMS) without modifying the notification service
L — Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering program correctness.
If your code works with a parent class, it should work with any child class without surprises. We touched on this in the Polymorphism and Interfaces post—now let's go deeper.
Violation: The Classic Square-Rectangle Problem
// TypeScript - LSP Violation
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(size: number) {
super(size, size);
}
// Overriding to maintain square invariant
setWidth(width: number): void {
this.width = width;
this.height = width; // Side effect!
}
setHeight(height: number): void {
this.height = height;
this.width = height; // Side effect!
}
}
// This function expects Rectangle behavior
function resize(rect: Rectangle): void {
rect.setWidth(10);
rect.setHeight(5);
// For a Rectangle: area should be 50
// For a Square: area is 25 (height set both dimensions to 5)
console.log(`Expected: 50, Got: ${rect.getArea()}`);
}
resize(new Rectangle(1, 1)); // Expected: 50, Got: 50 ✓
resize(new Square(1)); // Expected: 50, Got: 25 ✗ — LSP violation!Why is this bad? Code that works correctly with Rectangle breaks when given a Square. The subclass changes the expected behavior of the parent.
Refactored: Redesign the Hierarchy
// TypeScript - LSP Compliant
// Shared interface for all shapes
interface Shape {
getArea(): number;
}
// Immutable value objects — no setters to violate
class Rectangle implements Shape {
constructor(
readonly width: number,
readonly height: number
) {}
getArea(): number {
return this.width * this.height;
}
// Return new instance instead of mutating
withWidth(width: number): Rectangle {
return new Rectangle(width, this.height);
}
withHeight(height: number): Rectangle {
return new Rectangle(this.width, height);
}
}
class Square implements Shape {
constructor(readonly size: number) {}
getArea(): number {
return this.size * this.size;
}
withSize(size: number): Square {
return new Square(size);
}
}
// Works with any Shape — no surprises
function printArea(shape: Shape): void {
console.log(`Area: ${shape.getArea()}`);
}
printArea(new Rectangle(10, 5)); // Area: 50
printArea(new Square(7)); // Area: 49# Python - LSP Compliant
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def get_area(self) -> float:
pass
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self._width = width
self._height = height
@property
def width(self) -> float:
return self._width
@property
def height(self) -> float:
return self._height
def get_area(self) -> float:
return self._width * self._height
def with_width(self, width: float) -> "Rectangle":
return Rectangle(width, self._height)
def with_height(self, height: float) -> "Rectangle":
return Rectangle(self._width, height)
class Square(Shape):
def __init__(self, size: float):
self._size = size
@property
def size(self) -> float:
return self._size
def get_area(self) -> float:
return self._size * self._size
def with_size(self, size: float) -> "Square":
return Square(size)
# Works with any Shape
def print_area(shape: Shape) -> None:
print(f"Area: {shape.get_area()}")
print_area(Rectangle(10, 5)) # Area: 50
print_area(Square(7)) # Area: 49// Java - LSP Compliant
public interface Shape {
double getArea();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double getArea() {
return width * height;
}
public Rectangle withWidth(double width) {
return new Rectangle(width, this.height);
}
public Rectangle withHeight(double height) {
return new Rectangle(this.width, height);
}
}
public record Square(double size) implements Shape {
@Override
public double getArea() {
return size * size;
}
public Square withSize(double size) {
return new Square(size);
}
}LSP Rules of Thumb
- Don't strengthen preconditions — A subclass shouldn't require more than the parent
- Don't weaken postconditions — A subclass should deliver at least what the parent promises
- Preserve invariants — A subclass shouldn't break constraints the parent guarantees
- No surprises — If code works with the parent, it must work with the child
Common LSP Violations
| Violation | Example | Fix |
|---|---|---|
| Throwing unexpected exceptions | ReadOnlyList.add() throws UnsupportedOperationException | Separate ReadableList and WritableList interfaces |
| Changing return semantics | Stack.pop() returns different type than Collection.remove() | Don't inherit from unrelated abstractions |
| Side effects in overrides | Square.setWidth() also changes height | Use immutable objects or remove inheritance |
| Ignoring parent behavior | Empty method overrides (eat() does nothing in RobotWorker) | Use interface segregation |
I — Interface Segregation Principle (ISP)
No client should be forced to depend on methods it doesn't use.
Create small, focused interfaces instead of large, monolithic ones. This is about designing role-based contracts.
Violation: The Fat Interface
// TypeScript - ISP Violation
interface IDevice {
print(document: string): void;
scan(): string;
fax(document: string, number: string): void;
staple(document: string): void;
copy(document: string): string;
}
// A simple home printer doesn't fax or staple!
class HomePrinter implements IDevice {
print(document: string): void {
console.log(`Printing: ${document}`);
}
scan(): string {
return "Scanned document";
}
fax(document: string, number: string): void {
throw new Error("Home printer cannot fax!"); // Forced to implement!
}
staple(document: string): void {
throw new Error("Home printer cannot staple!"); // Forced to implement!
}
copy(document: string): string {
return `Copy of: ${document}`;
}
}Why is this bad? HomePrinter is forced to implement fax() and staple() even though it can't do those things. Any code calling fax() on a HomePrinter gets a runtime error.
Refactored: Role-Based Interfaces
// TypeScript - ISP Applied
// Small, focused interfaces
interface IPrinter {
print(document: string): void;
}
interface IScanner {
scan(): string;
}
interface IFax {
fax(document: string, number: string): void;
}
interface ICopier {
copy(document: string): string;
}
interface IStapler {
staple(document: string): void;
}
// Home printer implements only what it can do
class HomePrinter implements IPrinter, IScanner, ICopier {
print(document: string): void {
console.log(`Printing: ${document}`);
}
scan(): string {
return "Scanned document";
}
copy(document: string): string {
return `Copy of: ${document}`;
}
}
// Office machine implements everything
class OfficeMachine implements IPrinter, IScanner, IFax, ICopier, IStapler {
print(document: string): void {
console.log(`Printing: ${document}`);
}
scan(): string {
return "High-res scanned document";
}
fax(document: string, number: string): void {
console.log(`Faxing to ${number}: ${document}`);
}
copy(document: string): string {
return `Office copy of: ${document}`;
}
staple(document: string): void {
console.log(`Stapling: ${document}`);
}
}
// Functions accept only what they need
function printDocument(printer: IPrinter, doc: string): void {
printer.print(doc);
}
function scanAndCopy(device: IScanner & ICopier, doc: string): void {
const scanned = device.scan();
const copied = device.copy(doc);
console.log(`Scanned: ${scanned}, Copied: ${copied}`);
}
// Both work with printDocument
printDocument(new HomePrinter(), "Report.pdf");
printDocument(new OfficeMachine(), "Report.pdf");
// Both work with scanAndCopy
scanAndCopy(new HomePrinter(), "Invoice.pdf");
scanAndCopy(new OfficeMachine(), "Invoice.pdf");# Python - ISP Applied
from abc import ABC, abstractmethod
class IPrinter(ABC):
@abstractmethod
def print_document(self, document: str) -> None:
pass
class IScanner(ABC):
@abstractmethod
def scan(self) -> str:
pass
class IFax(ABC):
@abstractmethod
def fax(self, document: str, number: str) -> None:
pass
class ICopier(ABC):
@abstractmethod
def copy(self, document: str) -> str:
pass
# Home printer - only printing, scanning, copying
class HomePrinter(IPrinter, IScanner, ICopier):
def print_document(self, document: str) -> None:
print(f"Printing: {document}")
def scan(self) -> str:
return "Scanned document"
def copy(self, document: str) -> str:
return f"Copy of: {document}"
# Office machine - everything
class OfficeMachine(IPrinter, IScanner, IFax, ICopier):
def print_document(self, document: str) -> None:
print(f"Printing: {document}")
def scan(self) -> str:
return "High-res scanned document"
def fax(self, document: str, number: str) -> None:
print(f"Faxing to {number}: {document}")
def copy(self, document: str) -> str:
return f"Office copy of: {document}"
# Functions accept only what they need
def print_doc(printer: IPrinter, doc: str) -> None:
printer.print_document(doc)
def scan_and_copy(device: IScanner, doc: str) -> None:
# In Python, we rely on duck typing or Protocol for intersection types
scanned = device.scan()
print(f"Scanned: {scanned}")
print_doc(HomePrinter(), "Report.pdf")
print_doc(OfficeMachine(), "Report.pdf")// Java - ISP Applied
public interface IPrinter {
void print(String document);
}
public interface IScanner {
String scan();
}
public interface IFax {
void fax(String document, String number);
}
public interface ICopier {
String copy(String document);
}
// Home printer - only what it can do
public class HomePrinter implements IPrinter, IScanner, ICopier {
@Override
public void print(String document) {
System.out.println("Printing: " + document);
}
@Override
public String scan() {
return "Scanned document";
}
@Override
public String copy(String document) {
return "Copy of: " + document;
}
}
// Office machine - full capabilities
public class OfficeMachine implements IPrinter, IScanner, IFax, ICopier {
@Override
public void print(String document) {
System.out.println("Printing: " + document);
}
@Override
public String scan() {
return "High-res scanned document";
}
@Override
public void fax(String document, String number) {
System.out.println("Faxing to " + number + ": " + document);
}
@Override
public String copy(String document) {
return "Office copy of: " + document;
}
}ISP Benefits
- No dead code — Classes only implement methods they actually support
- Compile-time safety — Can't accidentally call
fax()on aHomePrinterif the function only acceptsIPrinter - Easier testing — Mock only the interface you need, not 10 methods
- Clearer intent — Function signatures communicate exactly what capabilities are required
D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
This is often the most impactful SOLID principle. Instead of a UserService directly creating a PostgresDatabase, it depends on an IDatabase interface. The concrete implementation is injected from the outside.
Violation: Tight Coupling
// TypeScript - DIP Violation
class MySQLDatabase {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
query(sql: string): string[] {
console.log(`Querying MySQL: ${sql}`);
return [];
}
}
class StripePayment {
charge(amount: number): boolean {
console.log(`Charging $${amount} via Stripe`);
return true;
}
}
class SmtpEmailer {
send(to: string, subject: string, body: string): void {
console.log(`SMTP sending to ${to}: ${subject}`);
}
}
// High-level module depends directly on low-level modules
class OrderService {
private db = new MySQLDatabase(); // Hardcoded dependency
private payment = new StripePayment(); // Hardcoded dependency
private emailer = new SmtpEmailer(); // Hardcoded dependency
placeOrder(userId: string, amount: number): void {
this.payment.charge(amount);
this.db.save(`Order for ${userId}: $${amount}`);
this.emailer.send(userId, "Order Confirmation", `Your order of $${amount} is confirmed`);
}
}Why is this bad? Want to switch from MySQL to PostgreSQL? Rewrite OrderService. Want to test without Stripe? Can't. Want to add SendGrid instead of SMTP? Modify OrderService. Every infrastructure change forces business logic changes.
Refactored: Depend on Abstractions
// TypeScript - DIP Applied
// Abstractions (interfaces)
interface IDatabase {
save(data: string): void;
query(sql: string): string[];
}
interface IPaymentGateway {
charge(amount: number): boolean;
}
interface IEmailService {
send(to: string, subject: string, body: string): void;
}
// Low-level implementations
class MySQLDatabase implements IDatabase {
save(data: string): void {
console.log(`Saving to MySQL: ${data}`);
}
query(sql: string): string[] {
console.log(`Querying MySQL: ${sql}`);
return [];
}
}
class PostgresDatabase implements IDatabase {
save(data: string): void {
console.log(`Saving to PostgreSQL: ${data}`);
}
query(sql: string): string[] {
console.log(`Querying PostgreSQL: ${sql}`);
return [];
}
}
class StripePayment implements IPaymentGateway {
charge(amount: number): boolean {
console.log(`Charging $${amount} via Stripe`);
return true;
}
}
class PayPalPayment implements IPaymentGateway {
charge(amount: number): boolean {
console.log(`Charging $${amount} via PayPal`);
return true;
}
}
class SmtpEmailer implements IEmailService {
send(to: string, subject: string, body: string): void {
console.log(`SMTP sending to ${to}: ${subject}`);
}
}
class SendGridEmailer implements IEmailService {
send(to: string, subject: string, body: string): void {
console.log(`SendGrid sending to ${to}: ${subject}`);
}
}
// High-level module depends on abstractions
class OrderService {
constructor(
private db: IDatabase,
private payment: IPaymentGateway,
private emailer: IEmailService
) {}
placeOrder(userId: string, amount: number): void {
this.payment.charge(amount);
this.db.save(`Order for ${userId}: $${amount}`);
this.emailer.send(userId, "Order Confirmation", `Your order of $${amount} is confirmed`);
}
}
// Usage — swap implementations without changing OrderService
const orderService = new OrderService(
new PostgresDatabase(), // Switch DB? Change here, not in OrderService
new StripePayment(), // Switch payment? Change here
new SendGridEmailer() // Switch email? Change here
);
orderService.placeOrder("user123", 99.99);
// For testing — use mock implementations
class MockDatabase implements IDatabase {
savedData: string[] = [];
save(data: string): void { this.savedData.push(data); }
query(sql: string): string[] { return []; }
}
class MockPayment implements IPaymentGateway {
charged = false;
charge(amount: number): boolean { this.charged = true; return true; }
}
class MockEmailer implements IEmailService {
sentEmails: string[] = [];
send(to: string, subject: string, body: string): void { this.sentEmails.push(to); }
}
// Easy to test!
const testService = new OrderService(
new MockDatabase(),
new MockPayment(),
new MockEmailer()
);# Python - DIP Applied
from abc import ABC, abstractmethod
# Abstractions
class IDatabase(ABC):
@abstractmethod
def save(self, data: str) -> None:
pass
@abstractmethod
def query(self, sql: str) -> list[str]:
pass
class IPaymentGateway(ABC):
@abstractmethod
def charge(self, amount: float) -> bool:
pass
class IEmailService(ABC):
@abstractmethod
def send(self, to: str, subject: str, body: str) -> None:
pass
# Concrete implementations
class PostgresDatabase(IDatabase):
def save(self, data: str) -> None:
print(f"Saving to PostgreSQL: {data}")
def query(self, sql: str) -> list[str]:
print(f"Querying PostgreSQL: {sql}")
return []
class StripePayment(IPaymentGateway):
def charge(self, amount: float) -> bool:
print(f"Charging ${amount} via Stripe")
return True
class SendGridEmailer(IEmailService):
def send(self, to: str, subject: str, body: str) -> None:
print(f"SendGrid sending to {to}: {subject}")
# High-level module depends on abstractions
class OrderService:
def __init__(
self,
db: IDatabase,
payment: IPaymentGateway,
emailer: IEmailService
):
self._db = db
self._payment = payment
self._emailer = emailer
def place_order(self, user_id: str, amount: float) -> None:
self._payment.charge(amount)
self._db.save(f"Order for {user_id}: ${amount}")
self._emailer.send(
user_id,
"Order Confirmation",
f"Your order of ${amount} is confirmed"
)
# Swap implementations freely
service = OrderService(
db=PostgresDatabase(),
payment=StripePayment(),
emailer=SendGridEmailer()
)
service.place_order("user123", 99.99)// Java - DIP Applied
public interface IDatabase {
void save(String data);
List<String> query(String sql);
}
public interface IPaymentGateway {
boolean charge(double amount);
}
public interface IEmailService {
void send(String to, String subject, String body);
}
// Implementations
public class PostgresDatabase implements IDatabase {
@Override
public void save(String data) {
System.out.println("Saving to PostgreSQL: " + data);
}
@Override
public List<String> query(String sql) {
System.out.println("Querying PostgreSQL: " + sql);
return List.of();
}
}
public class StripePayment implements IPaymentGateway {
@Override
public boolean charge(double amount) {
System.out.println("Charging $" + amount + " via Stripe");
return true;
}
}
// High-level module depends on abstractions
public class OrderService {
private final IDatabase db;
private final IPaymentGateway payment;
private final IEmailService emailer;
public OrderService(IDatabase db, IPaymentGateway payment, IEmailService emailer) {
this.db = db;
this.payment = payment;
this.emailer = emailer;
}
public void placeOrder(String userId, double amount) {
payment.charge(amount);
db.save("Order for " + userId + ": $" + amount);
emailer.send(userId, "Order Confirmation",
"Your order of $" + amount + " is confirmed");
}
}DIP Enables
- Testability — Inject mocks/fakes for unit testing
- Flexibility — Swap implementations (MySQL → PostgreSQL) without touching business logic
- Modularity — Teams can work on different implementations independently
- Framework integration — Spring, Nest.js, and FastAPI all use DI containers built on this principle
Real-World Example: E-Commerce Order System
Let's see all five SOLID principles working together in a realistic scenario:
// TypeScript - All SOLID Principles Applied
// === Interfaces (ISP - small, focused) ===
interface IOrderValidator {
validate(order: Order): ValidationResult;
}
interface IInventoryChecker {
checkAvailability(productId: string, quantity: number): boolean;
reserve(productId: string, quantity: number): void;
}
interface IPaymentProcessor {
process(amount: number, method: PaymentMethod): PaymentResult;
}
interface IOrderRepository {
save(order: Order): void;
findById(id: string): Order | null;
}
interface INotificationSender {
send(userId: string, message: string): void;
}
// === Data Types ===
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: "pending" | "confirmed" | "shipped" | "delivered";
}
interface OrderItem {
productId: string;
quantity: number;
price: number;
}
interface PaymentMethod {
type: string;
}
interface PaymentResult {
success: boolean;
transactionId: string;
}
interface ValidationResult {
valid: boolean;
errors: string[];
}
// === Validators (OCP - add new validators without changing existing ones) ===
class MinimumAmountValidator implements IOrderValidator {
constructor(private minimumAmount: number) {}
validate(order: Order): ValidationResult {
if (order.total < this.minimumAmount) {
return { valid: false, errors: [`Minimum order amount is $${this.minimumAmount}`] };
}
return { valid: true, errors: [] };
}
}
class ItemLimitValidator implements IOrderValidator {
constructor(private maxItems: number) {}
validate(order: Order): ValidationResult {
if (order.items.length > this.maxItems) {
return { valid: false, errors: [`Maximum ${this.maxItems} items per order`] };
}
return { valid: true, errors: [] };
}
}
// New validator — no existing code modified (OCP)
class FraudCheckValidator implements IOrderValidator {
validate(order: Order): ValidationResult {
if (order.total > 10000) {
return { valid: false, errors: ["Order exceeds fraud threshold — manual review required"] };
}
return { valid: true, errors: [] };
}
}
// === Composite Validator (SRP - only validates) ===
class CompositeOrderValidator implements IOrderValidator {
constructor(private validators: IOrderValidator[]) {}
validate(order: Order): ValidationResult {
const allErrors: string[] = [];
for (const validator of this.validators) {
const result = validator.validate(order);
if (!result.valid) {
allErrors.push(...result.errors);
}
}
return {
valid: allErrors.length === 0,
errors: allErrors
};
}
}
// === Order Service (SRP - orchestration only, DIP - depends on abstractions) ===
class OrderService {
constructor(
private validator: IOrderValidator,
private inventory: IInventoryChecker,
private payment: IPaymentProcessor,
private repository: IOrderRepository,
private notification: INotificationSender
) {}
placeOrder(order: Order, paymentMethod: PaymentMethod): PaymentResult | null {
// Step 1: Validate
const validation = this.validator.validate(order);
if (!validation.valid) {
console.log(`Validation failed: ${validation.errors.join(", ")}`);
return null;
}
// Step 2: Check inventory
for (const item of order.items) {
if (!this.inventory.checkAvailability(item.productId, item.quantity)) {
console.log(`Product ${item.productId} not available`);
return null;
}
}
// Step 3: Process payment
const paymentResult = this.payment.process(order.total, paymentMethod);
if (!paymentResult.success) {
console.log("Payment failed");
return null;
}
// Step 4: Reserve inventory
for (const item of order.items) {
this.inventory.reserve(item.productId, item.quantity);
}
// Step 5: Save order
order.status = "confirmed";
this.repository.save(order);
// Step 6: Notify
this.notification.send(order.userId, `Order ${order.id} confirmed!`);
return paymentResult;
}
}
// === Usage ===
// Compose with specific implementations (DIP)
const orderService = new OrderService(
new CompositeOrderValidator([
new MinimumAmountValidator(10),
new ItemLimitValidator(50),
new FraudCheckValidator()
]),
// inventory, payment, repository, notification implementations...
// would be injected here
{} as IInventoryChecker,
{} as IPaymentProcessor,
{} as IOrderRepository,
{} as INotificationSender
);SOLID Principles in This Example
| Principle | Where Applied |
|---|---|
| SRP | Each class has one job: MinimumAmountValidator validates amounts, OrderService orchestrates, CompositeOrderValidator combines validators |
| OCP | Add FraudCheckValidator without modifying existing validators or OrderService |
| LSP | All IOrderValidator implementations are interchangeable — CompositeOrderValidator works with any mix |
| ISP | Separate interfaces for validation, inventory, payment, storage, notification — each client depends only on what it needs |
| DIP | OrderService depends on interfaces, not on PostgresDB or StripePayment directly |
How SOLID Principles Connect
The five principles aren't isolated—they reinforce each other:
- SRP creates focused classes → makes them easier to extend (OCP)
- OCP relies on polymorphism → which requires proper substitution (LSP)
- ISP creates small interfaces → which are the abstractions DIP depends on
- DIP enables swapping implementations → which works because of LSP
SOLID Quick Reference
| Principle | One-Liner | Key Question |
|---|---|---|
| S — Single Responsibility | One class, one job | "Does this class have more than one reason to change?" |
| O — Open/Closed | Add new code, don't change old code | "Can I add this feature without modifying existing classes?" |
| L — Liskov Substitution | Subclasses shouldn't surprise you | "Would replacing this parent with a child break anything?" |
| I — Interface Segregation | Small interfaces, not fat ones | "Are clients forced to implement methods they don't use?" |
| D — Dependency Inversion | Depend on abstractions | "Does this high-level module know about implementation details?" |
When NOT to Apply SOLID
SOLID principles are guidelines, not dogma. Over-applying them can lead to:
- Over-engineering — Creating interfaces for classes that will never have a second implementation
- Abstraction explosion — 50 tiny interfaces for a simple CRUD app
- Analysis paralysis — Spending more time designing than building
Rules of thumb:
- Start simple — Add abstractions when you need a second implementation, not before
- Follow the pain — If changing one thing breaks another, that's a SOLID violation worth fixing
- Consider the scope — A 200-line script doesn't need DIP; a 200,000-line system does
- Refactor toward SOLID — It's easier to extract interfaces from working code than to predict the perfect abstraction upfront
Summary and Key Takeaways
The Five Principles
✅ SRP — Each class should have only one reason to change
✅ OCP — Extend behavior through new classes, not by modifying existing ones
✅ LSP — Subtypes must honor parent type contracts
✅ ISP — Many small interfaces are better than one large interface
✅ DIP — Depend on abstractions (interfaces), not concrete implementations
SOLID Enables
- Testable code — DIP lets you inject mocks
- Extensible code — OCP lets you add features without breaking things
- Readable code — SRP gives each class a clear purpose
- Safe inheritance — LSP prevents subtle bugs
- Clean contracts — ISP ensures classes only implement what they use
SOLID Is the Foundation for Design Patterns
Every design pattern you'll learn next—Singleton, Factory, Strategy, Observer—builds on SOLID principles. Understanding SOLID makes design patterns intuitive rather than magical.
Practice Exercises
-
Refactor a Report Generator: You have a
ReportGeneratorclass that generates reports, formats them as HTML/PDF/CSV, saves to disk, and emails the result. Apply SRP to separate concerns, then OCP to make formats extensible. -
Design a Payment System: Build a payment processing system with multiple gateways (Stripe, PayPal, Bank Transfer). Apply DIP so the checkout service doesn't know which gateway is used. Apply ISP to separate refundable vs non-refundable payment interfaces.
-
Fix an Inheritance Hierarchy: You have
Birdwith afly()method, andPenguin extends Bird. Apply LSP and ISP to fix this hierarchy so penguins don't "fly" and no code breaks.
What's Next?
With SOLID principles under your belt, you're ready to learn design patterns—proven solutions to recurring design problems. In the next post, we'll start with creational patterns:
- Singleton Pattern — Ensuring a class has only one instance
- When to use it and when to avoid it
- Thread-safe implementations
- Alternatives to Singleton
Continue your OOP journey: Singleton Pattern
Additional Resources
Previous Posts in This Series
- OOP & Design Patterns Roadmap - Complete learning path
- The Four Pillars of OOP - OOP fundamentals
- Classes, Objects, and Abstraction - Building blocks of OOP
- Encapsulation and Information Hiding - Protecting data integrity
- Inheritance and Composition - Code reuse strategies
- Polymorphism and Interfaces - One interface, many forms
Related Posts
- Java Phase 2: Object-Oriented Programming - OOP in Java
- Python Phase 2: OOP & Advanced Features - OOP in Python
- TypeScript Advanced Types Deep Dive - Advanced type system features
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.