
SOLID Principles with Examples
/ 7 min read
Table of Contents
SOLID Principles
The SOLID principles are five foundational guidelines for object-oriented software design that help developers write code that is more maintainable, flexible, and scalable 1 2 3. Please check the citations at the end of the page if you want to read in depth about SOLID principles. Below is a very short description of each. Then we dive into the examples which are more useful than the descriptions!
-
Single Responsibility Principle (SRP):
Each class should have only one reason to change, meaning it should handle a single, well-defined responsibility 4 2 5 3. -
Open/Closed Principle (OCP):
Software entities should be open for extension but closed for modification. You should be able to add new functionality without altering existing code 1 2 5 3. -
Liskov Substitution Principle (LSP):
Subtypes must be substitutable for their base types. Objects of a derived class should be usable wherever objects of the base class are expected, without affecting program correctness 2 5 3. -
Interface Segregation Principle (ISP):
Clients should not be forced to depend on interfaces they do not use. It’s better to have many small, specific interfaces than a few large, general-purpose ones 2 5 3. -
Dependency Inversion Principle (DIP):
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 2 5 3.
Applying SOLID leads to code that is easier to understand, modify, test, and extend as software evolves 1 5 3.
SOLID Principles With Examples
1. Single Responsibility Principle (SRP)
The Good: A class handles only one responsibility-separating invoice logic, printing, and saving:
class Invoice { constructor(private amount: number) {} getTotal() { return this.amount; }}
class InvoicePrinter { print(invoice: Invoice) { console.log(`Invoice total: $${invoice.getTotal()}`); }}
class InvoiceSaver { save(invoice: Invoice) { // Save invoice to database console.log('Invoice saved.'); }}
How this applies SRP:
- The
Invoice
class has one responsibility: managing invoice data and calculations - The
InvoicePrinter
class has one responsibility: formatting and printing invoices - The
InvoiceSaver
class has one responsibility: persisting invoices to storage - Each class has only one reason to change (invoice logic, printing format, or storage mechanism)
The Bad (SRP Violation):
class Invoice { constructor(private amount: number) {}
getTotal() { return this.amount; }
// Violates SRP: Invoice shouldn't handle printing print() { console.log(`Invoice total: $${this.getTotal()}`); }
// Violates SRP: Invoice shouldn't handle persistence save() { console.log('Invoice saved to database.'); // Database saving logic here }
// Violates SRP: Invoice shouldn't handle email notifications sendEmail() { console.log('Invoice email sent.'); // Email sending logic here }}
This violates SRP because the Invoice
class has multiple reasons to change: invoice calculations, printing format, database structure, and email service changes.
2. Open/Closed Principle (OCP)
The Good: Shapes can be extended with new types without modifying existing code:
interface Shape { area(): number;}
class Rectangle implements Shape { constructor(private width: number, private height: number) {} area() { return this.width * this.height; }}
class Circle implements Shape { constructor(private radius: number) {} area() { return Math.PI * this.radius * this.radius; }}
class AreaCalculator { static totalArea(shapes: Shape[]) { return shapes.reduce((acc, shape) => acc + shape.area(), 0); }}
How this applies OCP:
- The system is open for extension: new shape types can be added by implementing the
Shape
interface - The system is closed for modification: existing code (
AreaCalculator
,Rectangle
,Circle
) doesn’t need to change when adding new shapes - The
AreaCalculator
works with any shape that implements the interface
The Bad (OCP Violation):
enum ShapeType { Rectangle = 'rectangle', Circle = 'circle', Triangle = 'triangle' // Adding this requires modifying AreaCalculator}
class Shape { constructor(public type: ShapeType, public dimensions: number[]) {}}
class AreaCalculator { static totalArea(shapes: Shape[]) { return shapes.reduce((acc, shape) => { // Violates OCP: Adding new shapes requires modifying this method switch (shape.type) { case ShapeType.Rectangle: return acc + (shape.dimensions[0] * shape.dimensions[1]); case ShapeType.Circle: return acc + (Math.PI * shape.dimensions[0] * shape.dimensions[0]); case ShapeType.Triangle: return acc + (0.5 * shape.dimensions[0] * shape.dimensions[1]); default: return acc; } }, 0); }}
This violates OCP because adding a new shape type requires modifying the AreaCalculator
class.
3. Liskov Substitution Principle (LSP)
The Good: Subclasses should work as their base type expects:
abstract class Bird { abstract move(): void;}
class FlyingBird extends Bird { move() { console.log('Flying through the air'); }
fly() { console.log('Flapping wings'); }}
class WalkingBird extends Bird { move() { console.log('Walking on the ground'); }
walk() { console.log('Moving legs'); }}
class Sparrow extends FlyingBird { move() { console.log('Sparrow flying'); }}
class Ostrich extends WalkingBird { move() { console.log('Ostrich running'); }}
function moveBird(bird: Bird) { bird.move(); // Works for all bird types}
How this applies LSP:
- All bird subclasses can be substituted for the base
Bird
class - The
moveBird
function works correctly with any bird type - Each subclass maintains the behavioral contract of the base class
The Bad (LSP Violation):
class Bird { fly() { console.log('Flying'); }}
class Sparrow extends Bird { fly() { console.log('Sparrow flying'); }}
class Ostrich extends Bird { fly() { // Violates LSP: Ostrich can't fly, breaking the expected behavior throw new Error('Ostrich cannot fly'); }}
function makeBirdFly(bird: Bird) { bird.fly(); // Expects all birds to fly}
const sparrow = new Sparrow();makeBirdFly(sparrow); // Works fine
const ostrich = new Ostrich();makeBirdFly(ostrich); // Throws error - violates LSP
This violates LSP because Ostrich
cannot be substituted for Bird
without breaking the program’s functionality.
4. Interface Segregation Principle (ISP)
The Good: Clients depend only on interfaces they use:
interface Printer { print(): void;}
interface Scanner { scan(): void;}
interface Fax { fax(): void;}
class MultiFunctionPrinter implements Printer, Scanner, Fax { print() { console.log('Printing document'); } scan() { console.log('Scanning document'); } fax() { console.log('Faxing document'); }}
class SimplePrinter implements Printer { print() { console.log('Printing document'); }}
class PhotocopierMachine implements Printer, Scanner { print() { console.log('Printing document'); } scan() { console.log('Scanning document'); }}
How this applies ISP:
- Interfaces are small and focused on specific functionality
- Clients only implement the interfaces they need
SimplePrinter
doesn’t need to implement scanning or faxing functionality- Each interface represents a cohesive set of related methods
The Bad (ISP Violation):
interface Machine { print(): void; scan(): void; fax(): void; photocopy(): void;}
class MultiFunctionPrinter implements Machine { print() { console.log('Printing document'); } scan() { console.log('Scanning document'); } fax() { console.log('Faxing document'); } photocopy() { console.log('Photocopying document'); }}
class SimplePrinter implements Machine { print() { console.log('Printing document'); }
// Violates ISP: Forced to implement methods it doesn't use scan() { throw new Error('Scan not supported'); }
fax() { throw new Error('Fax not supported'); }
photocopy() { throw new Error('Photocopy not supported'); }}
This violates ISP because SimplePrinter
is forced to implement methods it doesn’t support, leading to empty implementations or exceptions.
5. Dependency Inversion Principle (DIP)
The Good: High-level modules depend on abstractions, not concrete classes:
interface Database { save(data: string): void; find(id: string): string | null;}
interface Logger { log(message: string): void;}
class MySQLDatabase implements Database { save(data: string) { console.log(`Saving data to MySQL: ${data}`); }
find(id: string) { console.log(`Finding data in MySQL with ID: ${id}`); return "MySQL data"; }}
class FileLogger implements Logger { log(message: string) { console.log(`Logging to file: ${message}`); }}
class UserService { constructor( private database: Database, // Depends on abstraction private logger: Logger // Depends on abstraction ) {}
createUser(userData: string) { this.database.save(userData); this.logger.log('User created successfully'); }}
// Dependency injectionconst database = new MySQLDatabase();const logger = new FileLogger();const userService = new UserService(database, logger);
How this applies DIP:
UserService
(high-level module) depends onDatabase
andLogger
interfaces (abstractions)- It doesn’t depend on concrete implementations like
MySQLDatabase
orFileLogger
- Dependencies are injected, making the system flexible and testable
- New database or logging implementations can be swapped without changing
UserService
The Bad (DIP Violation):
class MySQLDatabase { save(data: string) { console.log(`Saving data to MySQL: ${data}`); }
find(id: string) { console.log(`Finding data in MySQL with ID: ${id}`); return "MySQL data"; }}
class FileLogger { log(message: string) { console.log(`Logging to file: ${message}`); }}
class UserService { private database: MySQLDatabase; // Violates DIP: depends on concrete class private logger: FileLogger; // Violates DIP: depends on concrete class
constructor() { // Violates DIP: creates dependencies internally this.database = new MySQLDatabase(); this.logger = new FileLogger(); }
createUser(userData: string) { this.database.save(userData); this.logger.log('User created successfully'); }}
// Tightly coupled - hard to test and inflexibleconst userService = new UserService();
This violates DIP because:
UserService
depends directly on concrete classes (MySQLDatabase
,FileLogger
)- It creates its own dependencies, making it tightly coupled
- It’s difficult to test (can’t mock dependencies)
- Changing database or logging implementation requires modifying
UserService
Footnotes
-
https://www.freecodecamp.org/news/solid-principles-for-better-software-design/ ↩ ↩2 ↩3
-
https://contabo.com/blog/what-are-solid-principles/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
https://www.digitalocean.com/community/conceptual-articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design ↩
-
https://www.bmc.com/blogs/solid-design-principles/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6