skip to content
luminary.blog
by Oz Akan
ui images

SOLID Principles with Examples

It is better 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 injection
const database = new MySQLDatabase();
const logger = new FileLogger();
const userService = new UserService(database, logger);

How this applies DIP:

  • UserService (high-level module) depends on Database and Logger interfaces (abstractions)
  • It doesn’t depend on concrete implementations like MySQLDatabase or FileLogger
  • 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 inflexible
const 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

  1. https://www.freecodecamp.org/news/solid-principles-for-better-software-design/ 2 3

  2. https://en.wikipedia.org/wiki/SOLID 2 3 4 5 6

  3. https://contabo.com/blog/what-are-solid-principles/ 2 3 4 5 6 7

  4. https://www.digitalocean.com/community/conceptual-articles/s-o-l-i-d-the-first-five-principles-of-object-oriented-design

  5. https://www.bmc.com/blogs/solid-design-principles/ 2 3 4 5 6