DEV Community

Cover image for Thoughts about SOLID - The Letter "O"
Mauricio Paulino
Mauricio Paulino

Posted on • Edited on

Thoughts about SOLID - The Letter "O"

In the third part of this series of reflections, I continue following the order of the letters of the SOLID principles, reaching the letter "O". I'll be working with TypeScript in the examples.


In this article

Open/Closed Principle
Abstract example
Technical example (Front-End)
Technical example (Back-End)
Personal example
Functional example
Applicabilities
Final thoughts


Open/Closed Principle

The Open/Closed Principle determines that a class should be open for extension but closed for modification. Because of this, it's considered both open and closed at the same time. Of all the principles, it's perhaps the most straightforward to understand.

In practical terms, this principle proposes that a class should not be altered for arbitrary reasons, especially if those reasons stem from a new and very specific functionality. Instead of modifying a class that may be used by various areas of the system, it's more valuable to extend it.

You've likely seen the result of not adhering to this principle: numerous if statements for each specific scenario.


Abstract example

πŸ”΄ Incorrect Implementation

Let's continue with the theme of library, but this time focusing on the entity book. A book can have a title and an author, but I also want to identify completely different scenarios for the case of whether it has a hardcover and if it has images.

// Our simple class for books.
class Book {
  constructor(
    private title: string,
    private author: string,
    private hasHardCover: boolean,
    private hasImages: boolean
  ) {}

  // A method to view the book.
  public viewBook() {
    console.log(`Book "${this.title}", Author "${this.author}"`);

    // PRINCIPLE VIOLATION: The hardcover scenario would have a very specific flow, for example.
    // This would cause the Book class to handle specificities, not generalization.
    if (this.hasHardCover) {
      console.log("This book has a hardcover");
    }

    // PRINCIPLE VIOLATION: Same view as above.
    if (this.hasImages) {
      console.log("This book has images");
    }
  }
}

// Note in the instantiation below that the parameters for initialization also become somewhat complex.
// If we have more cases, we will have more unnecessary toggled parameters.

const journeyToTheCenterOfTheEarth = new Book(
  "Journey to the Center of the Earth",
  "Jules Verne",
  true, // Has hardcover.
  false // Doesn't have images.
);

const theTimeMachine = new Book(
  "The Time Machine",
  "Herbert George Wells",
  false, // Doesn't have hardcover.
  true // Has images.
);

journeyToTheCenterOfTheEarth.viewBook();
// OUTPUT:
// Book "Journey to the Center of the Earth", Author "Jules Verne"
// This book has a hardcover

theTimeMachine.viewBook();
// OUTPUT:
// Book "The Time Machine", Author "Herbert George Wells"
// This book has images
Enter fullscreen mode Exit fullscreen mode

🟒 Correct Implementation

// The Book class now only contains what is common among all books.
class Book {
  constructor(private title: string, private author: string) {}

  public viewBook() {
    console.log(`Book "${this.title}", Author "${this.author}"`);
  }
}

class HardCoverBook extends Book {
  // NOTE: Here we removed TypeScript's shorthand for indicating whether it's `private` or `public`,
  // so that it doesn't create the property locally.
  // If we had kept it as `private`, it would create a new property with the name `title`,
  // for example, inside HardCoverBook, which would conflict with the property of the same name in Book.
  // This shorthand could be a topic for another article!
  constructor(title: string, author: string) {
    super(title, author);
  }

  public viewBook() {
    super.viewBook();
    console.log("This book has a hardcover");
  }
}

class ImageBook extends Book {
  // NOTE: Same observation as above.
  constructor(title: string, author: string) {
    super(title, author);
  }

  public viewBook() {
    super.viewBook();
    console.log("This book has images");
  }
}

// Now, the instantiations become much simpler and focused,
// as well as the implementations.

const journeyToTheCenterOfTheEarth = new HardCoverBook(
  "Journey to the Center of the Earth",
  "Jules Verne"
);

const theTimeMachine = new ImageBook(
  "The Time Machine",
  "Herbert George Wells"
);

journeyToTheCenterOfTheEarth.viewBook();
// OUTPUT:
// Book "Journey to the Center of the Earth", Author "Jules Verne"
// This book has a hardcover

theTimeMachine.viewBook();
// OUTPUT:
// Book "The Time Machine", Author "Herbert George Wells"
// This book has images
Enter fullscreen mode Exit fullscreen mode

Technical example (Front-End)

Let's imagine a button that implements a simple functionality of pressing. However, we also have a scenario where the button will be dragged along the interface. This specific scenario should not be implemented together with the base of the button.

πŸ”΄ Incorrect Implementation

class Button {
  constructor(private label: string, private draggable: boolean) {}

  public onPress() {
    console.log("You pressed the button!", this.label);
  }

  // PRINCIPLE VIOLATION: The dragging functionality violates the principle, as
  // it specifies a very particular type of button.
  public onDrag() {
    // And, moreover, it's toggled.
    if (this.draggable) console.log("You dragged the button!");
  }
}
Enter fullscreen mode Exit fullscreen mode

🟒 Correct Implementation

class Button {
  constructor(private label: string, private draggable: boolean) {}

  public onPress() {
    console.log("You pressed the button!", this.label);
  }
}

// Separating the draggable button, creating a more elegant and scalable implementation.
class DraggableButton extends Button {
  public onDrag() {
    console.log("You dragged the button!");
  }
}
Enter fullscreen mode Exit fullscreen mode

Technical example (Back-End)

Bringing a Database perspective, imagine we have a generic class for connecting to the database, but we can have more than one instance because each domain has its own persistence. In this case, we shouldn't alter a single class.

πŸ”΄ Incorrect Implementation

class Database {
  constructor() {}

  // A reusable, internal function for connecting to any database.
  private connect(dbName: string) {
    console.log(`Connected to the ${dbName} database!`);
  }

  // PRINCIPLE VIOLATION: The generic Database class now has the specificity
  // of the user database.
  public connectToUserDb() {
    this.connect("user");
  }

  // PRINCIPLE VIOLATION: Same comment as above.
  public connectToEventsDb() {
    this.connect("events");
  }
}
Enter fullscreen mode Exit fullscreen mode

🟒 Correct Implementation

class Database {
  constructor() {}

  // The function becomes public so that subclasses can access it.
  public connect(dbName: string) {
    console.log(`Connected to the ${dbName} database!`);
  }
}

// With this structure, we separate responsibilities and ensure the generalization of Database.
class UserDatabase extends Database {
  public connect() {
    super.connect("user");
  }
}

// Same for the events database.
class EventsDatabase extends Database {
  public connect() {
    super.connect("events");
  }
}

// It's worth noting that there are other ways to achieve this; the idea here is to be educational.
Enter fullscreen mode Exit fullscreen mode

Personal example

In Banjo-Kazooie, you need to open some doors to access new areas of the hub world using notes collected in the stages, as well as puzzle pieces (called Jiggies) to unlock new worlds.

That is, in this scenario, we have two types of roadblocks. Ideally, each one should have its own specific implementation, but they share some ideals.

πŸ”΄ Incorrect Implementation

// Let's imagine a class that controls possible roadblocks.
class Roadblock {
  constructor(private minimumNotes: number, private minimumJiggies: number) {}

  // PRINCIPLE VIOLATION: The `allow` method is handling both notes and
  // Jiggies, creating a lack of generalization. Both are roadblocks, but
  // with specific behaviors.
  public allow(type: string, total: number) {
    if (type === "notes") {
      if (total >= this.minimumNotes) {
        console.log("You can pass!");
        return true;
      }

      console.log("Oops, you still need to collect more notes...");
      return false;
    }

    if (type === "jiggies") {
      if (total >= this.minimumJiggies) {
        console.log("You can enter!");
        return true;
      }

      console.log("Oops, you still need to collect more Jiggies...");
      return false;
    }

    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

🟒 Correct Implementation

class Roadblock {
  constructor(private minimum: number) {}

  // Here, we simplify the method and unify what is generic.
  public allow(total: number) {
    return total >= this.minimum;
  }
}

// Below, we extend Roadblock to implement specificities.
// Again, it's important to understand that the implementations are didactic.

class NoteRoadblock extends Roadblock {
  public openDoor(total: number) {
    const isAllowed = this.allow(total);
    if (isAllowed) {
      console.log("You can pass!");
    }
  }
}

class JiggiesRoadblock extends Roadblock {
  public openWorld(total: number) {
    const isAllowed = this.allow(total);
    if (isAllowed) {
      console.log("You can enter!");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Functional example

How can we observe this principle with Functional Programming if we don't have explicit class extension? Well, I bring a simple example involving, once again, file reading, and the basic concept of Currying.

πŸ”΄ Incorrect Implementation

// Here, we create a function that is not very scalable, with manual handling.
function readFile(fileName: string, isCSV: boolean, isPNG: boolean) {
  const file = fileSystem.read(fileName);

  // This collection of `if` statements below will grow more and more.

  if (isCSV) {
    return mapCSV(file);
  }

  if (isPNG) {
    return mapPNG(file);
  }

  return file;
}
Enter fullscreen mode Exit fullscreen mode

🟒 Correct Implementation

// Here, using the Currying technique, we created a function creator.
// Each creator receives its own `mapper`, creating an extensible function.
function createFileReader(mapper: (file: string) => string) {
  // Reusable function.
  const readFile = (fileName: string) => fileSystem.read(fileName);

  // The return is a new function that takes care of executing the `mapper`.
  return (fileName: string) => {
    const file = readFile(fileName);

    return mapper(file);
  };
}

const imgReader = createFileReader((file) => imgMapper(file));
const csvReader = createFileReader((file) => csvMapper(file));
Enter fullscreen mode Exit fullscreen mode

Applicabilities

In my opinion, this is one of the most easily identified principles during development. While others may require more analysis, it's a bit more practical to notice when a class or entity is undergoing excessive modifications, executing various deviations for specific scenarios.

The main suggestion is to critically examine the generalization and specialization of these entities. Even though I may have similar entities that seem to belong to the same scope, it might make more sense to take the time to abstract a new subclass with specificities.


Final thoughts

I reiterate the caution regarding over-engineering. Not everything needs to become a subclass, not everything needs to be generic, and not everything will be solved simply with these segregations. However, it's important to keep this criticality on the radar and start observing possible implementation scenarios with these general contexts.

The Open/Closed Principle can be a powerful tool to increase system maintainability since everything concrete will be segregated into its own class.

Top comments (0)