DEV Community

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

Posted on

Thoughts about SOLID - The Letter "I"

In this fifth part of my reflections, we continue with the letter "I," following the order proposed by the SOLID acronym. I will use TypeScript in the examples.


In this article

Interface Segregation Principle
Abstract example
Technical example (Front-End)
Technical example (Back-End)
Personal example
Functional example
Applicabilities
Final thoughts


Interface Segregation Principle

A very important starting point is the fact that this principle is the only principle focused on interfaces, not classes. It is expected that the reader understands this difference, but a very practical summary is: interfaces define what a class should implement.

The Interface Segregation Principle proposes that a class should not depend on methods it does not need, and that one should prefer multiple interfaces over a single interface with multiple responsibilities.

The most interesting aspect is how this principle fits with the theme of the first principle, Single Responsibility. Both promote the idea of segregation of responsibilities and guide the development of the system towards ensuring class scalability. A class or interface with many responsibilities is naturally more complicated to manage, as an improper change can cause many undesirable side effects.

Let's understand, with examples, how we can identify and apply this principle.


Abstract example

Let's continue with our library example. This time, imagine that the library has not only books but also DVDs and Blu-Rays of movies and series. Well, in this scenario:

  • Each library item should be mapped;
  • We would like to know the name of each item through a method;
  • For books, we would like to know the number of pages;
  • For movies and series, we would like to know the duration;

🔴 Incorrect Implementation

// Let's imagine an interface that requires the implementation of three methods.
interface LibraryItem {
  getName(): string; // For both books and series/movies, we want to know the item's name.
  getPages(): number; // Only for books, how many pages it has.
  getDuration(): number; // Only for series and movies, what the duration is in minutes.
}

// Now, let's create our Book class, implementing the LibraryItem interface.
// This will force us to comply with what the LibraryItem contains. Let's follow along.
class Book implements LibraryItem {
  constructor(private title: string, private pages: number) {}

  // No problems with this method.
  getName() {
    return this.title;
  }

  // No problems with this method.
  getPages(): number {
    return this.pages;
  }

  getDuration(): number {
    // PRINCIPLE VIOLATION: The getDuration method, although it belongs to library items, 
    // doesn't make sense in the context of books and will not be implemented. 
    // Therefore, books are forced to depend on and implement a method they don't use.
    throw new Error("Books do not have a duration in minutes");
  }
}

class DVD implements LibraryItem {
  constructor(private title: string, private duration: number) {}

  // No problems with this method.
  getName(): string {
    return this.title;
  }

  // PRINCIPLE VIOLATION: Same observation as the above item, but now from the perspective 
    // of movies and series, which don't have a number of pages but are forced to implement.
  getPages(): number {
    throw new Error("DVDs do not have a number of pages");
  }

  // No problems with this method.
  getDuration(): number {
    return this.duration;
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Correct Implementation

// It is preferable to segregate the interfaces. It is better to have multiple interfaces, each with its own responsibility,
// than a single one that forces classes to implement methods they don't need.
interface LibraryItem {
  getName(): string; // Common method for all.
}

interface BookItem {
  getPages(): number; // Specific method for books.
}

interface DVDItem {
  getDuration(): number; // Specific method for DVDs.
}

// Now, each class implements only what it uses.

class Book implements LibraryItem, BookItem {
  constructor(private title: string, private pages: number) {}

  getName() {
    return this.title;
  }

  getPages(): number {
    return this.pages;
  }
}

class DVD implements LibraryItem, DVDItem {
  constructor(private title: string, private duration: number) {}

  getName(): string {
    return this.title;
  }

  getDuration(): number {
    return this.duration;
  }
}
Enter fullscreen mode Exit fullscreen mode

Technical example (Front-End)

Let's suppose we have 3 types of butons in our application: PrimaryButton, IconButton and ToggleButton. If all of them depend on a single master interface, we can start seeing problems.

🔴 Incorrect Implementation

// Interface too generic for the various types of buttons that exist.
interface Button {
  render(): void; // Method to render the button.
  setLabel(label: string): void; // Method to set the button's label.
  setIcon(icon: string): void; // Method to associate an icon with the button.
  toggle(): void; // Method for toggle buttons, to switch on/off.
}

class PrimaryButton implements Button {
  constructor(private label: string) {}

  render(): void {
    console.log("Rendering button...", this.label);
  }

  setLabel(label: string): void {
    this.label = label;
  }

  // PRINCIPLE VIOLATION: PrimaryButton doesn't support icons but is forced to implement the method.
  setIcon(icon: string): void {
    throw new Error("This button does not support icons");
  }

  // PRINCIPLE VIOLATION: Same observation as above.
  toggle(): void {
    throw new Error("This button does not support toggle");
  }
}

// PRINCIPLE VIOLATION: Below, we have two more classes, IconButton and ToggleButton, which exemplify the opposite of PrimaryButton.
// Each implements its respective method, setIcon and toggle, but is also forced to implement methods they
// don't use.

class IconButton implements Button {
  constructor(private label: string, private icon: string) {}

  render(): void {
    console.log("Rendering button...", this.label, this.icon);
  }

  setLabel(label: string): void {
    this.label = label;
  }

  setIcon(icon: string): void {
    this.icon = icon;
  }

  toggle(): void {
    throw new Error("This button does not support toggle");
  }
}

class ToggleButton implements Button {
  constructor(private label: string, private state: boolean) {}

  render(): void {
    console.log("Rendering button...", this.label, this.state);
  }

  setLabel(label: string): void {
    this.label = label;
  }

  setIcon(icon: string): void {
    throw new Error("This button does not support icons");
  }

  toggle(): void {
    this.state = !this.state;
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Correct Implementation

// The simplicity and elegance of the solution lie in segregating into multiple interfaces, unifying in the
// Button interface what is truly generic.

interface Button {
  render(): void;
  setLabel(label: string): void;
}

interface WithIcon {
  setIcon(icon: string): void;
}

interface WithToggle {
  toggle(): void;
}

// Classes now only implement the interfaces they need.

class PrimaryButton implements Button {
  constructor(private label: string) {}

  render(): void {
    console.log("Rendering button...", this.label);
  }

  setLabel(label: string): void {
    this.label = label;
  }
}

class IconButton implements Button, WithIcon {
  constructor(private label: string, private icon: string) {}

  render(): void {
    console.log("Rendering button...", this.label, this.icon);
  }

  setLabel(label: string): void {
    this.label = label;
  }

  setIcon(icon: string): void {
    this.icon = icon;
  }
}

class ToggleButton implements Button, WithToggle {
  constructor(private label: string, private state: boolean) {}

  render(): void {
    console.log("Rendering button...", this.label, this.state);
  }

  setLabel(label: string): void {
    this.label = label;
  }

  toggle(): void {
    this.state = !this.state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Technical example (Back-End)

Let's start with the assumption that transactions can be performed on relational databases but not on non-relational databases. We acknowledge that this isn't an absolute truth (it highly depends on the vendor and storage model), but for educational purposes, we'll assume this to be the case.

🔴 Incorrect Implementation

// Generic interface for databases, implementing connections, queries, and transactions.
interface Database {
  connect(): void;
  disconnect(): void;
  runQuery(query: string): unknown;
  startTransaction(): void;
  commitTransaction(): void;
  rollbackTransaction(): void;
}

// For relational databases, all implementations work.
class RelationalDatabase implements Database {
  connect(): void {
    console.log("Successfully connected");
  }

  disconnect(): void {
    console.log("Successfully disconnected");
  }

  runQuery(query: string): unknown {
    console.log(`Executing query: ${query}`);
    return { ... };
  }

  startTransaction(): void {
    console.log("Transaction - Started");
  }

  commitTransaction(): void {
    console.log("Transaction - Committed");
  }

  rollbackTransaction(): void {
    console.log("Transaction - Rolled back");
  }
}

class NonRelationalDatabase implements Database {
  connect(): void {
    console.log("Successfully connected");
  }

  disconnect(): void {
    console.log("Successfully disconnected");
  }

  runQuery(query: string): unknown {
    console.log(`Executing query: ${query}`);
    return { ... };
  }

  // PRINCIPLE VIOLATION: If transactions don't work for all database types, why is it part of the generic interface, forcing classes to implement it?

  startTransaction(): void {
    throw new Error("Non-relational databases do not support transactions");
  }

  commitTransaction(): void {
    throw new Error("Non-relational databases do not support transactions");
  }

  rollbackTransaction(): void {
    throw new Error("Non-relational databases do not support transactions");
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Correct Implementation

// We an still have a generic interface, but we segregate what is considered specific.
interface Database {
  connect(): void;
  disconnect(): void;
}

interface DatabaseQueries {
  runQuery(query: string): unknown;
}

interface DatabaseTransactions {
  startTransaction(): void;
  commitTransaction(): void;
  rollbackTransaction(): void;
}

class RelationalDatabase implements Database, DatabaseQueries, DatabaseTransactions {
  connect(): void {
    console.log("Successfully connected");
  }

  disconnect(): void {
    console.log("Successfully disconnected");
  }

  runQuery(query: string): unknown {
    console.log(`Executing query: ${query}`);
    return { ... };
  }

  startTransaction(): void {
    console.log("Transaction - Started");
  }

  commitTransaction(): void {
    console.log("Transaction - Committed");
  }

  rollbackTransaction(): void {
    console.log("Transaction - Rolled back");
  }
}

// Now, our non-relational database only implements what makes sense.
class NonRelationalDatabase implements Database, DatabaseQueries {
  connect(): void {
    console.log("Successfully connected");
  }

  disconnect(): void {
    console.log("Successfully disconnected");
  }

  runQuery(query: string): unknown {
    console.log(`Executing query: ${query}`);
    return { ... };
  }
}
Enter fullscreen mode Exit fullscreen mode

Personal example

In Super Mario Kart, there are some items that are specific to certain characters - a behavior that may be questionable, but that's not the focus of this article. In this scenario, how can we implement the interfaces for these items and adhere to the principle?

🔴 Incorrect Implementation

interface Items {
  throwShell(): void; // Item that any character can have.
  throwFire(): void; // Exclusive item of Bowser, being a fireball.
  throwMushroom(): void; // Exclusive item of Peach (at the time Princess Toadstool) and Toad.
}

// PRINCIPLE VIOLATION: We will encounter several errors in each of the specific scenarios.

class Mario implements Items {
  throwShell(): void {
    console.log('Throwing "Shell" item');
  }

  throwFire(): void {
    throw new Error("Mario does not have access to this item.");
  }

  throwMushroom(): void {
    throw new Error("Mario does not have access to this item.");
  }
}

class Bowser implements Items {
  throwShell(): void {
    console.log('Throwing "Shell" item');
  }

  throwFire(): void {
    console.log('Throwing "Fire" item');
  }

  throwMushroom(): void {
    throw new Error("Bowser does not have access to this item.");
  }
}

class Princess implements Items {
  throwShell(): void {
    console.log('Throwing "Shell" item');
  }

  throwFire(): void {
    throw new Error("Princess does not have access to this item.");
  }

  throwMushroom(): void {
    console.log('Throwing "Mushroom" item');
  }
}
Enter fullscreen mode Exit fullscreen mode

🟢 Correct Implementation

// We can split our single interface into multiple interfaces, separating what's generic from what's specific.
interface CommonItems {
  throwShell(): void;
}

interface FireSpecialItems {
  throwFire(): void;
}

interface MushroomSpecialItems {
  throwMushroom(): void;
}
Enter fullscreen mode Exit fullscreen mode

Functional example

Let's imagine a data processor interface. We would like to parse both JSON and CSV files, but each with its own specificity. If we implement the options for each of them in a unified way, we may violate the principle.

🔴 Incorrect Implementation

// In this interface, we define a function that processes data.
type DataProcessor = (
  data: string, // The data to be processed.
  jsonToObject: boolean, // Only for JSON, indicating whether to convert to object.
  csvSeparator: string // Only for CSV, indicating the column separator.
) => string[];

// PRINCIPLE VIOLATION: Every function defined based on this interface will be dependent on
// parameters that it may not need. A JSON processor, or a CSV processor, will need to
// implement those parameters regardless.

const jsonProcessor: DataProcessor = (data, jsonToObject, csvSeparator) => {
  let result = validateJSON(data);

  if (jsonToObject) {
    result = transformJSON(result);
  }

  return result;
};

const csvProcessor: DataProcessor = (data, jsonToObject, csvSeparator) => {
  let result = validateJSON(data);

  result = transformCSV(result, csvSeparator);

  return result;
};

// Note that function calls are forced to pass unnecessary parameters.

const json = jsonProcessor(jsonData, true, false);
const csv = csvProcessor(csvData, false, ",");
Enter fullscreen mode Exit fullscreen mode

🟢 Correct Implementation

// With functional programming, there are several ways to approach this solution.
// Here, I chose to segregate the interfaces into option objects.

type DataProcessorJSONOptions = {
  toObject: boolean;
};

type DataProcessorCSVOptions = {
  separator: string;
};

type DataProcessor = (
  data: string,
  // Now, the second parameter has options for each type, being optional.
  options: {
    json?: DataProcessorJSONOptions;
    csv?: DataProcessorCSVOptions;
  }
) => string[];

const jsonProcessor: DataProcessor = (data, { json }) => {
  let result = validateJSON(data);

  if (json?.toObject) {
    result = transformJSON(result);
  }

  return result;
};

const csvProcessor: DataProcessor = (data, { csv }) => {
  let result = validateJSON(data);

  result = transformCSV(result, csv?.separator);

  return result;
};

// As I mentioned, there are other ways to solve this problem.
// This would be a basic approach, using optional parameters.
// Another way would be to use Union Types or something similar.

const json = jsonProcessor(jsonData, { json: { toObject: true } });
const csv = csvProcessor(csvData, { csv: { separator: "," } });
Enter fullscreen mode Exit fullscreen mode

Applicabilities

Being the only principle applied to interfaces, personally, I see a very interesting potential. Adverse situations to this principle can be found in very complex classes, or even as a result of the lack of application of the other SOLID principles - as I have referred to, mainly the first one.

The exercise of defining class interfaces before their effective implementations can help identify these problems. It is important to question how generic the implementation would be and how reusable the methods can be across different scenarios. Nowadays, we can also work a lot with optional methods, which can be a safeguard for such scenarios, but may end up generating the need for "type gymnastics" to check if the method has been implemented or not.


Final thoughts

There is a great discussion about generalization in various aspects of Software Engineering, and this is definitely essential - the problem lies in excess, and also when it is not clear where to draw the boundary line. If we treat everything as generic, then what is specific will end up affecting all other scenarios; otherwise, if everything is specific, we will have a huge pile of classes to maintain, and we lose sight of their correct existence.

The ideal is to use this principle as a starting point for building new classes with various purposes: What methods will I have? In which scenarios will they be applied? Am I forcing classes to implement something that will not be useful to them? Starting from these assumptions, it becomes a little easier to identify the need for the principle, which simply proposes that we should take care not to create code just out of obligation, but for a purpose implemented.

Top comments (0)