DEV Community

ugurgunes95
ugurgunes95

Posted on

SOLID Principles of Object Oriented Design and Architecture

In this article I'm going to explain SOLID principles with bad and correct examples using typescript. Let's get started!

Single Responsibility Principle(SRP)

  • Each class should have only one responsibility.🙄
  • You should be able to describe what each class does without saying "and".🤔
  • Each class should have only one reason to change.👍🏼
  • A class should contain code that changes for the same single reason.

Responsibility = reason to change
We can simply describe it as single reason to change principle.

Bad Example That Violates SRP

class UserService {
  getUser(id: number) {
    // Gets user from database
    console.log(`Fetching user with ID: ${id}`);
    return { id, name: "John Doe", email: "[email protected]" };
  }

  saveUser(user: { id: number, name: string, email: string }) {
    // Saves user to database
    console.log(`Saving user: ${JSON.stringify(user)}`);
  }

  sendWelcomeEmail(email: string) {
    // Sends welcome email
    console.log(`Sending welcome email to: ${email}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • In this example UserService has three responsibilities:

    • Saving a user's information,
    • Getting a user's information,
    • Sending welcome e-mail to new users.
  • If the logic of sending mail changes(I.E.: Using another email provider) we will have to change UserService which violates SRP.

Correct Example That Respects SRP

// Class that manages user actions
class UserRepository {
  getUser(id: number) {
    console.log(`Fetching user with ID: ${id}`);
    return { id, name: "John Doe", email: "[email protected]" };
  }

  saveUser(user: { id: number; name: string; email: string }) {
    console.log(`Saving user: ${JSON.stringify(user)}`);
  }
}

// Class that manages sending emails
class EmailService {
  sendWelcomeEmail(email: string) {
    console.log(`Sending welcome email to: ${email}`);
  }
}

// Service that manages user operations
class UserService {
  private userRepository: UserRepository;
  private emailService: EmailService;

  constructor(userRepository: UserRepository, emailService: EmailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }

  registerUser(name: string, email: string) {
    const user = { id: Date.now(), name, email };
    this.userRepository.saveUser(user);
    this.emailService.sendWelcomeEmail(email);
  }
}

// Usage
const userRepository = new UserRepository();
const emailService = new EmailService();
const userService = new UserService(userRepository, emailService);

userService.registerUser("Alice", "[email protected]");

Enter fullscreen mode Exit fullscreen mode
  • This code snippet is the same as above but takes SRP into account:
    • UserRepository manages only database operations.
    • EmailService manages only email sending operations.
    • UserServive only manages the business logic and externalizes the dependencies.
    • If the method of sending email changes, simply changing the EmailService class is sufficient.
    • The code becomes more modular, testable and extensible.

Open Closed Principle (OCP)

  • Software entities(classes, modules, functions etc.) should be open for extension, but closed for modification.
  • Depend on stable abstractions and modify system's behavior by providing different realizations
  • OCP is the principle of Polymorphism.

Bad Example That Violates OCP

class PaymentProcessor {
  processPayment(type: string, amount: number) {
    if (type === "credit_card") {
      console.log(`Processing credit card payment of $${amount}`);
    } else if (type === "paypal") {
      console.log(`Processing PayPal payment of $${amount}`);
    } else {
      throw new Error("Invalid payment method");
    }
  }
}

// Usage
const payment = new PaymentProcessor();
payment.processPayment("credit_card", 100);
payment.processPayment("paypal", 200);
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • If we want to add a new payment method (for example, Bitcoin), we need to modify the existing code.
    • If-else blocks must be added to the processPayment method for each new payment method.

Correct Example That Respects OCP

// We create an interface for the payment method
interface PaymentMethod {
  pay(amount: number): void;
}

// Credit card payment class
class CreditCardPayment implements PaymentMethod {
  pay(amount: number) {
    console.log(`Processing credit card payment of $${amount}`);
  }
}

// PayPal payment class
class PayPalPayment implements PaymentMethod {
  pay(amount: number) {
    console.log(`Processing PayPal payment of $${amount}`);
  }
}

// Bitcoin payment class (We don't need to change old code to add new features!)
class BitcoinPayment implements PaymentMethod {
  pay(amount: number) {
    console.log(`Processing Bitcoin payment of $${amount}`);
  }
}

// The main payment processor outsources the dependency
class PaymentProcessor {
  private paymentMethod: PaymentMethod;

  constructor(paymentMethod: PaymentMethod) {
    this.paymentMethod = paymentMethod;
  }

  process(amount: number) {
    this.paymentMethod.pay(amount);
  }
}

// Usage
const creditCardPayment = new PaymentProcessor(new CreditCardPayment());
creditCardPayment.process(100);

const paypalPayment = new PaymentProcessor(new PayPalPayment());
paypalPayment.process(200);

const bitcoinPayment = new PaymentProcessor(new BitcoinPayment());
bitcoinPayment.process(300);

Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • We can add new payment methods without changing the existing code.
    • The code becomes more modular and flexible.
    • Since it takes dependencies from outside, it is more testable with Dependency Injection.

Liskov Substitution Principle (LSP)

  • If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of that program(correctness, task performed, etc.).
  • Rules:
    • Method Signature Rules:
    • Contravariance of Arguments: Subtypes must not add new constraints to method parameters.
    • Covariance of Result: Subtypes must not change the return type of a method.
    • Exception Rule: Subtypes must not throw more exceptions than their supertypes.
    • Pre-Condition Rule: Subtypes must not weaken preconditions (make them less strict).
    • Post-Condition Rule: Subtypes must not strengthen postconditions (make them more strict).
    • Class Property Rules:
    • Invariant Rule: Subtypes must preserve invariants.
    • Constraint Rule: Subtypes must not add new constraints.

Bad Example That Violates LSP's Method Signature Rules

class Bird {
  fly(): string {
    return "Flying";
  }
}

class Penguin extends Bird {
  // Violeates LSP! Penguin doesn't fly.
  fly(): string {
    throw new Error("Penguins cannot fly");
  }
}

function makeBirdFly(bird: Bird) {
  console.log(bird.fly());
}

const bird = new Bird();
const penguin = new Penguin();

makeBirdFly(bird); // Output: "Flying"
makeBirdFly(penguin); // Runtime Error: Penguins cannot fly
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • Penguin should be able to replace the Bird class. However, the Penguin class breaks the fly method and throws an error.
    • This violates LSP.

Correct Example That Respects LSP's Method Signature Rules

class Bird {
  move(): string {
    return "Moving";
  }
}

class FlyingBird extends Bird {
  fly(): string {
    return "Flying";
  }
}

class Penguin extends Bird {
  swim(): string {
    return "Swimming";
  }
}

function makeBirdMove(bird: Bird) {
  console.log(bird.move());
}

function makeBirdFly(bird: FlyingBird) {
  console.log(bird.fly());
}

const eagle = new FlyingBird();
const penguin = new Penguin();

makeBirdMove(eagle); // Output: "Moving"
makeBirdMove(penguin); // Output: "Moving"
makeBirdFly(eagle); // Output: "Flying"
// makeBirdFly(penguin); // TypeScript error: Penguin does not have 'fly' method.
Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • We wrote our code without violating the Liskov Substitution Principle (LSP) by separating flight and flightless birds into different classes.

Bad Example That Violates LSP's Pre-Condition Rule

class DiscountService {
  applyDiscount(price: number): number {
    if (price < 0) {
      throw new Error("Price cannot be negative");
    }
    return price * 0.9; // 10% discount
  }
}

class VIPDiscountService extends DiscountService {
  applyDiscount(price: number): number {
    if (price < 100) {
      throw new Error("VIP discount is only applicable for prices above 100");
    }
    return price * 0.8; // 20% discount for VIP customers
  }
}

function processDiscount(service: DiscountService, price: number) {
  console.log(service.applyDiscount(price));
}

const standardService = new DiscountService();
const vipService = new VIPDiscountService();

processDiscount(standardService, 50); // Output: 45
processDiscount(vipService, 50); // Runtime Error: "VIP discount is only applicable for prices above 100"
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • VIPDiscountService requires the price to be greater than 100 in the applyDiscount method.
    • Adding a restriction in a subclass when there is no such restriction in the superclass violates the Pre-Condition Rule.
    • Because a code that uses the superclass (processDiscount function) encounters an unexpected error in the subclass.

Correct Example That Respects LSP's Pre-Condition Rule

class DiscountService {
  applyDiscount(price: number): number {
    if (price < 0) {
      throw new Error("Price cannot be negative");
    }
    return price * 0.9;
  }
}

class VIPDiscountService extends DiscountService {
  applyDiscount(price: number): number {
    if (price < 0) {
      // We did not change the rule!
      throw new Error("Price cannot be negative");
    }
    return price * 0.8;
  }
}

function processDiscount(service: DiscountService, price: number) {
  console.log(service.applyDiscount(price));
}

const standardService = new DiscountService();
const vipService = new VIPDiscountService();

processDiscount(standardService, 50); // Output: 45
processDiscount(vipService, 50); // Output: 40
Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • VIPDiscountService can seamlessly replace the parent class because it does not add any extra restrictions.
    • We made it compatible with the Pre-Condition Rule.
    • Now wherever DiscountService is used, VIPDiscountService can also be used.

Bad Example That Violates LSP's Post-Condition Rule

class Rectangle {
  protected width: number;
  protected height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  setWidth(width: number) {
    this.width = width;
  }

  setHeight(height: number) {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height; // Area calculation
  }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }

  setWidth(width: number) {
    this.width = width;
    this.height = width; // We're changing the height too
  }

  setHeight(height: number) {
    this.width = height; // We're changing the width too
    this.height = height;
  }
}

function calculateArea(rect: Rectangle) {
  rect.setWidth(10);
  rect.setHeight(5);
  console.log(rect.getArea());
}

const rect = new Rectangle(4, 6);
calculateArea(rect); // Output: 50

const square = new Square(4);
calculateArea(square); // Output: 25 ❌ (Expected 50 but result is 25!)
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • The Square class should be used instead of the Rectangle class.
    • However, Square changes the setWidth and setHeight methods, breaking the expected result.
    • The calculateArea function gets a different result than the object it considers a rectangle.
    • This violates the Post-Condition Rule because the getArea method no longer provides the output guaranteed in the superclass.

Correct Example That Respects LSP's Post-Condition Rule

interface Shape {
  getArea(): number;
}

class Rectangle implements Shape {
  protected width: number;
  protected height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  setWidth(width: number) {
    this.width = width;
  }

  setHeight(height: number) {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

class Square implements Shape {
  private side: number;

  constructor(side: number) {
    this.side = side;
  }

  setSide(side: number) {
    this.side = side;
  }

  getArea(): number {
    return this.side * this.side;
  }
}

function calculateShapeArea(shape: Shape) {
  console.log(shape.getArea());
}

const rect = new Rectangle(10, 5);
calculateShapeArea(rect); // Output: 50

const square = new Square(4);
calculateShapeArea(square); // Output: 16 ✅ (Expected behavior)
Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • Rectangle and Square are now defined as independent classes.
    • Rectangle works according to the logic of a rectangle, while Square has its own rules.
    • The Liskov Substitution Principle is preserved because the calculateShapeArea function gets the expected result for every shape.

Bad Example That Violates LSP's Class Property Rule

class Employee {
  name: string;
  salary: number;

  constructor(name: string, salary: number) {
    this.name = name;
    this.salary = salary;
  }

  getSalary(): number {
    return this.salary;
  }
}

class Manager extends Employee {
  salary: string; // ❌ We are using a different data type!

  constructor(name: string, salary: number) {
    super(name, salary);
    this.salary = `$${salary} per year`; // ❌ We are storing a string instead of a number
  }

  getSalary(): string {
    // ❌ Return type has changed
    return this.salary;
  }
}

function printEmployeeSalary(employee: Employee) {
  console.log(`Salary: ${employee.getSalary()}`);
}

const emp = new Employee("Alice", 5000);
printEmployeeSalary(emp); // Output: Salary: 5000

const manager = new Manager("Bob", 7000);
printEmployeeSalary(manager);
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • While the salary property should be a number, the Manager class changed its meaning by making it a string.
    • Changing the type or meaning of a property in a superclass violates LSP.
    • printEmployeeSalary function expects number but receives string.

Correct Example That Respects LSP's Class Property Rule

class Employee {
  name: string;
  salary: number;

  constructor(name: string, salary: number) {
    this.name = name;
    this.salary = salary;
  }

  getSalary(): number {
    return this.salary;
  }
}

class Manager extends Employee {
  bonus: number;

  constructor(name: string, salary: number, bonus: number) {
    super(name, salary);
    this.bonus = bonus;
  }

  getTotalSalary(): number {
    return this.salary + this.bonus;
  }
}

function printEmployeeSalary(employee: Employee) {
  console.log(`Salary: ${employee.getSalary()}`);
}

const emp = new Employee("Alice", 5000);
printEmployeeSalary(emp); // Output: Salary: 5000

const manager = new Manager("Bob", 7000, 2000);
printEmployeeSalary(manager); // Output: Salary: 7000 ✅ as expected
console.log(`Total Salary: ${manager.getTotalSalary()}`); // Output: Total Salary: 9000 ✅
Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • The salary property remains as number in both classes.
    • The Manager class added an extra bonus feature but did not change the meaning of the salary.
    • The printEmployeeSalary function worked as expected.

Interface Segregation Principle (ISP)

  • Clients should not be forced to depend on methods they do not use. Instead, clients should only depend on the minimum set of interfaces that are necessary for their functionality.

Bad Example That Violates ISP

interface Worker {
  work(): void;
  eat(): void;
}

class Developer implements Worker {
  work(): void {
    console.log("Writing code...");
  }

  eat(): void {
    console.log("Eating lunch...");
  }
}

class Designer implements Worker {
  work(): void {
    console.log("Designing UI...");
  }

  eat(): void {
    console.log("Eating lunch...");
  }
}

class Robot implements Worker {
  work(): void {
    console.log("Executing automated tasks...");
  }

  // ❌ Problem: Robot does not eat, but it has to implemet `eat()` method!
  eat(): void {
    throw new Error("Robots do not eat!");
  }
}
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • The Robot class has to implement the eat() method, but robots don't eat!
    • ISP is violated because all classes are forced to implement methods that they do not use.

Correct Example That Respects ISP

interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

class Developer implements Workable, Eatable {
  work(): void {
    console.log("Writing code...");
  }

  eat(): void {
    console.log("Eating lunch...");
  }
}

class Designer implements Workable, Eatable {
  work(): void {
    console.log("Designing UI...");
  }

  eat(): void {
    console.log("Eating lunch...");
  }
}

class Robot implements Workable {
  work(): void {
    console.log("Executing automated tasks...");
  }
}

// Test
const dev = new Developer();
dev.work(); // Output: "Writing code..."
dev.eat(); // Output: "Eating lunch..."

const robot = new Robot();
robot.work(); // Output: "Executing automated tasks..."
// robot.eat(); // ❌ Error: Robot does not have `eat()` method, because it doesn't required now.
Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • We created two small interfaces: Workable and Eatable.
    • The robot no longer has to implement the eat() method.
    • Each class implements only the methods it needs.

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.

Bad Example That Violates DIP

class LightBulb {
  turnOn(): void {
    console.log("LightBulb is ON");
  }

  turnOff(): void {
    console.log("LightBulb is OFF");
  }
}

class Switch {
  private lightBulb: LightBulb;

  constructor(lightBulb: LightBulb) {
    this.lightBulb = lightBulb;
  }

  operate(): void {
    this.lightBulb.turnOn();
  }
}

// Usage
const bulb = new LightBulb();
const mySwitch = new Switch(bulb);
mySwitch.operate(); // Output: "LightBulb is ON"
Enter fullscreen mode Exit fullscreen mode
  • ❌ Problems:
    • Switch is directly dependent on the LightBulb class.
    • If we want to add a LEDLight or a Fan instead of the LightBulb, we have to change the Switch class.
    • The code becomes harder to extend and flexibility is lost.

Correct Example That Respects DIP

// 1. Abstraction
interface Switchable {
  turnOn(): void;
  turnOff(): void;
}

// 2. LightBulb implements the abstraction
class LightBulb implements Switchable {
  turnOn(): void {
    console.log("LightBulb is ON");
  }

  turnOff(): void {
    console.log("LightBulb is OFF");
  }
}

// 3. Let's add another device
class LEDLight implements Switchable {
  turnOn(): void {
    console.log("LED Light is ON");
  }

  turnOff(): void {
    console.log("LED Light is OFF");
  }
}

// 4. Switch is no longer dependent on a specific class, but on the `Switchable` interface!
class Switch {
  private device: Switchable;

  constructor(device: Switchable) {
    this.device = device;
  }

  operate(): void {
    this.device.turnOn();
  }
}

// Usage
const bulb = new LightBulb();
const led = new LEDLight();

const switch1 = new Switch(bulb);
switch1.operate(); // Output: "LightBulb is ON"

const switch2 = new Switch(led);
switch2.operate(); // Output: "LED Light is ON"
Enter fullscreen mode Exit fullscreen mode
  • 🚀 Advantages:
    • Switch no longer depends directly on the LightBulb class, but instead depends on the Switchable interface.
    • No need to change the Switch code to add LEDLight or other device.
    • The code has become more flexible and extensible.

Top comments (3)

Collapse
 
xwero profile image
david duymelinck • Edited

While I liked the post overall, I have some remarks about a few of the examples.

The penguin example contains a logic error. With FlyingBird you have created a class for a group of birds, while Pengiun is still its own class?
A better way would be to have a SwimmingBird and create an instance from that for the penguin, like you did for the eagle.
A more realistic solution would be to have an array of movements, but this is not in the scope of the post.

In the discount example you fixed the principle but you removed a business rule, the minimum 100 amount. It would be better to make a comment, or add the code, in the solution where the business rule should be to have a one-on-one comparison.

Collapse
 
ugurgunes profile image
ugurgunes95

Thanks for your feedback. I'll check the whole examples considering your opinions as soon as I can.

Collapse
 
werliton profile image
Werliton Silva

Nice post!