A Look into SOLID Principles 😶‍🌫️ with Examples

Jan 26, 2024·

8 min read

A Look into SOLID Principles 😶‍🌫️ with Examples

I have been learning the SOLID principles and the benefit it brings, so in this article I will give you a glimpse of SOLID principles.

Most of the examples are given in Java, but the syntax will be similar to other OOP languages.

Before looking into SOLID principles, we have to know why SOLID principles.

Remember when you started learning coding. You started with basic hello world program. You then imagined yourself as a next Elon musk and Google CEO. (Yes, I have done this)

Then you proceeded to learn further, building projects and more. But after few months or even weeks, when you re-visit your old projects, you may feel confused. :(

Your reaction when you look into your code!

You can’t understand what your code does. Somehow you managed to understand the code. Now you added one small feature, now the application itself breaks.

Is this happened to you? Yes it always happens to me, until I started to apply SOLID principles. (I’m still learning haha)

That’s why SOLID is here!

There are 5 principles for you to follow to make your code more readable, maintainable, and easy to test.

Single Responsibility Principle
Open — closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle

Single Responsibility Principle (SRP)

A class should have one and only one, reason to change

SRP enforces to have one single responsibility for a class.

Let’s take an example (In Java),

class YouTubeDownloader {
  private boolean isSuccess;

  /* Verify if the url is a valid Youtube url*/
  public boolean verifyUrl(String youTubeUrl) {
    // implementation detail
  }

  /* Get the download url from passed Youtube Url */
  public String getDownloadUrl(String youTubeUrl) {
    // implementation detail
  }

  /* Download the passed url to passed path */
  public void downloadToDir(String url, String downloadPath) {
    // implementation detail
  }
}

Here YouTubeDownloader has three responsibilities:

  • Verify the YouTube URL

  • Extract download URL from YouTube URL

  • Download the passed URL (Note: not YouTube URL, its downloadable URL).

In future if we want to have Spotify downloader we need to write the similar class but duplicating the downloadToDir method. This is redundant.

We can split this into two classes, each having one single responsibility.

class Downloader {
  private boolean isSuccess;
  /* Download the passed url to passed path */
  public void downloadToDir(String url, String downloadPath) {
    // implementation detail
  }
}

class YouTubeProvider {
  /* Get the download url from passed Youtube Url */
  public String getDownloadUrl(String youTubeUrl) {
    // implementation detail
  }
  /* Verify if the url is a valid Youtube url*/
  public boolean verifyUrl(String youTubeUrl) {
    // implementation detail
  }
}

Now, for Spotify we only need to have declare SpotifyProvider with two methods.

But wait, does YouTubeProvider has more than one responsibility?

Answer is Yes, it is still not completely SRP.

Because It Depends! It depends based on your requirement, and these principles can be partially or completely followed. These principles are here for to help you, so don’t follow blindly instead understand the problem it solves.

For me, It is okay to YouTubeProvider to have two responsibilities.

A single responsibility doesn’t mean a class should have only one method. You can have many methods to serve one responsibility.

Open/Closed Principle

A class should open for extension, but closed for modification

It means if there is an existing class, if we want to have a separate feature, we should not modify the class, instead we should extend.

Let’s take an example in Java,

class Logger {
  /* Logs into console */
  public void log(String message) {
    System.out.println("Logger: " + message);
  }
}

Let’s modify the code so that it can be logged into console or file.

class Logger {
  /* Type can be "console" or "file" */
  private String type;
  public Logger(String type) {
    this.type = type;
  }

  /* Logs into console or file */
  public void log(String message) {
    if (type.equals("console")) {
      System.out.println("Logger: " + message);
    } else {
      File file = new File("temp.log");

      // This is an example implementation
      // Writing to a file requires more code
      // I hate to work with files in Java
      // Because there is so much code to just write to file
      // For the sake of example, I have simplified this
      file.write("Logger: " + message + "\n");
    }
  }
}

Here we added file logging feature by passing the type, but we have modified the Logger class itself.

This can be a problem, if we want to have multiple logging implementation, this if (type) check will get increased and it can difficult to maintain.

To solve this, we can have a interface that has this out method.

interface LogOutput {
    void out(String message);
}

class Logger {
    private LogOutput logOutput;
    public Logger(LogOutput logOutput) {
      this.logOutput = logOutput;
    }

    /* Logs into LogOutput */
    public void log(String message) {
      logOutput.out("Logger: " + message + "\n");
    }
}

class ConsoleLogOutput implements LogOutput {
    @Override
    public void out(String message) {
        System.out.print(message);
    }
}

class FileLogOutput implements LogOutput {
    @Override
    public void out(String message) {
        File file = new File("temp.log");
        file.write(message);
    }
}

Here in this new implementation, I can create new Log outputs and still not touching the Logger class.

Liskov Substitution Principle

A subclass should be substitutable for their base / parent class without breaking code

A instance of subclass can be passed instead of instance of parent class without breaking the code.

Let’s take an example,

interface User {
  String email();
  String name();
  String type();
}

class Admin implements User {
  public String email() {
    return "admin@mail.com";
  }
  public String name() {
    return "Admin";
  }
  public String type() {
    return "admin";
  }
}

class Student implements User {
  public String email() {
    return "student@mail.com";
  }
  public String name() {
    return "Student";
  }
  public String type() {
    return "student";
  }
}

So the application has two kinds of users: Student, Admin. So the User interface will be implemented by Student and Admin class.

Later, a new requirement is arrived. The application needs a Guest user.

Lets create a Guest user class.

class Guest implements User {
  public String email() {
    throw new Exception("No email for Guest");
  }
  public String name() {
    return "Guest";
  }
  public String type() {
    return "guest";
  }
}

The Guest doesn’t have a email, so we should throw an exception.

This is a violation of Liskov Substitution Principle. The Guest class cannot be substituted for User interface as it has different behavior for email() method.

We can change the Base interface.

interface User {
  String name();
  String type();
}

interface AuthenticatedUser extends User {
  String email();
}

class Admin implements AuthenticatedUser {
  public String email() {
    return "admin@mail.com";
  }
  public String name() {
    return "Admin";
  }
  public String type() {
    return "admin";
  }
}

class Student implements AuthenticatedUser {
  public String email() {
    return "student@mail.com";
  }
  public String name() {
    return "Student";
  }
  public String type() {
    return "student";
  }
}

class Guest implements User {
  public String name() {
    return "Guest";
  }
  public String type() {
    return "guest";
  }
}

Now the guest doesn’t have any misbehaviors as it implements a User interface. Other classes implements AuthenticatedUser interface.

Interface Segregation Principle

Many interfaces is better than one general purpose interface

We need to avoid adding all methods to a single interface, instead we should separate methods to its own interface.

Lets see an Example,

interface View {
  void render();
  void onClick();
}

class Button implements View {
  public void render() {
    // Render to Screen
  }
  public void onClick() {
    // Clicked
  }
}

class TextView implements View {
  public void render() {
    // Render to Screen
  }
  public void onClick() {
    // Clicked, Oh the user won't be expecting this
  }
}

Here the View interface has two methods. And the Button and TextView is implementing the View interface.

But wait, do TextView can be clicked. Generally TextView is used to display a non-interactive text.

We can split the interfaces like below,

interface View {
  void render();
}

interface Clickable {
  void onClick();
}

class Button implements View, Clickable {
  public void render() {
    // Render to Screen
  }
  public void onClick() {
    // Clicked
  }
}

class TextView implements View {
  public void render() {
    // Render to Screen
  }
}

Here we have splitted the onClick method to a separate interface.

Now TextView only implements the View Interface. Button implements View and Clickable.

Dependency Inversion Principle

One should depend upon abstractions, rather than concrete classes

If there is a Base class and its implementation, only Base class Types should be preferred instead of its implementation.

Base class can be interface, abstract class or a class.

Let’s take an example,

class NewsDataSource {
  public List<News> getNews() {
    // fetch from network
  }
  public boolean addNews(News news) {
    // add news
  }
  public boolean favoriteNews(News news, boolean isFavorite) {
    // Add news to favorite
  }
}

class NewsRepository {
  private NewsDataSource dataSource;
  public List<News> getNews() {
    return dataSource.getNews();
  }
  public boolean addNews(News news) {
    return dataSource.addNews(news);
  }
  public boolean favoriteNews(News news, boolean isFavorite) {
    return dataSource.favoriteNews(news, isFavorite);
  }
}

Here the NewsRepository depends on NewsDataSource.

It violates the Dependency Inversion Principle.

interface NewsDataSource {
  List<News> getNews();
  boolean addNews(News news);
  boolean favoriteNews(News news);
}

class NewsRemoteDataSource implements NewsDataSource {
  public List<News> getNews() {
    // fetch from network
  }
  public boolean addNews(News news) {
    // add news
  }
  public boolean favoriteNews(News news, boolean isFavorite) {
    // Add news to favorite
  }
}

class NewsRepository {
  private NewsDataSource dataSource;
  public List<News> getNews() {
    return dataSource.getNews();
  }
  public boolean addNews(News news) {
    return dataSource.addNews(news);
  }
  public boolean favoriteNews(News news, boolean isFavorite) {
    return dataSource.favoriteNews(news, isFavorite);
  }
}

Now we have added a interface and the DataSource to implement the interface.

In NewsRepository we are depends on the interface rather that its implementation. In future we can have multiple implementation of NewsDataSource like NewsLocalDataSource. Or for testing we can have our own Test implementation.

Conclusion

While writing the examples, myself have found that many examples have some overlap with other principles. If you take one example, it can follow more than one SOLID principles.

If you have come this far, Thank you for reading this article.

Connect with me:

My Personal Portfolio: sanjaydev.tech