SOLID Coding Principles - Simplified

SOLID coding principles are object-oriented design principles that result in better, more maintainable code. This article will cover the SOLID principles in a simplified way that applies no matter the object-oriented language.

SOLID coding principles
SOLID coding principles

SOLID coding principles are object-oriented design principles that result in better, more maintainable code. This article will cover the SOLID principles in a simplified way that applies no matter the object-oriented language.

What is SOLID?

SOLID coding principles are object-oriented programming practices that result in more maintainable, less buggy code. It applies to object-oriented languages, although a number of the concepts can be applied to any language.

The SOLID coding principles are a subset of a number of coding principles originally promoted by Robert Martin in his 2000 paper Design Principles and Design Patterns. Robert Martin is often referred to as “Uncle Bob” in the developer community. He is an influential software engineer, writer, and instructor in the software world. He is also the founder of the Agile Manifesto.

Technically, Uncle Bob didn’t coin the term SOLID. This credit must be given to Michael Feathers, who coined the term later in 2004.

What are the SOLID principles?

S - Single-Responsibility - every class should have only one responsibility

O - Open-Closed - entities should be open for extension but closed for modification

L - Liskov Substitution - derived classes must accurately represent their base classes

I - Interface Segregation - clients shouldn’t be forced to use interfaces that they do not use

D - Dependency Inversion - depend on abstractions, not concretions

Let’s cover each of these, along with some basic language-agnostic examples.

💡
Keep an eye on our @SeCareerBooster YouTube channel for videos with specific language examples!

Single-Responsibility Principle

In the single-responsibility principle, every class or module should have only one responsibility. The goal is that a class or module should only have one purpose and thus only one reason to change.

A common example used to demonstrate this principle is that of a report builder. Requirements have been given to create functionality that supports building and printing a report. Line-item detail can be provided for the report, information on what columns to group and total by, and printing of the report itself.

In a non-SOLID approach, one class might be created with 3 methods (and a constructor). These 3 methods would handle the work of building the report line-item detail, grouping and totaling by appropriate columns, and then doing the final formatting and printing of the report.

The problem here is that the class has multiple responsibilities:

  1. Building the report
  2. Printing the report

This couples the building logic and the printing logic into one class. Any time changes or enhancements are done to either the report formatting functionality or to the actual printing options, this class must be modified and unnecessary code is put at risk of being altered and/or broken.

If we instead implement two classes, one to handle report formatting and one to handle report printing, each of these activities can be independently modified and tested with much less time, effort, and impact.

Decoupling logic so that each class or module only has a single responsibility has a lot of benefits:

  1. A given class will only ever change when that single logic feature changes. Fewer changes mean few impacts on other features, fewer bug implications, and more straightforward unit testing
  2. A given class or module is smaller and more purpose-driven. It is more useful in a microservice environment and is more scalable

Open-Closed Principle

In the open-closed principle, classes should be extendable but not modifiable by other classes. The goal is that a class’s data, methods, and interfaces are consistent. They can be created, tested, and counted upon to behave consistently and their core behavior not be modified haphazardly by other classes. If this were the case, then any subclasses of that modified class would be impacted by those changes and they would have to potentially be modified as well.

This original open-closed principle was great. But class derivation puts a wrinkle in it. In any object-oriented language, you have the ability to derive a subclass from a base class. This is part of what makes object-oriented programming so valuable. Create an automobile class, then derive from that class and create a more specific car class and truck class. Each of those subclasses can have additional attributes and methods specific to them that add to the base automobile attributes and methods in the subclass.

Yes, the base automobile class is closed for modification, but even with inheritance (actually because of inheritance) we still get tight coupling between the base class and subclasses. For this reason, Robert Martin and others at some point redefined this principle to be the Polymorphic Open-Closed Principle and promote the use of interfaces over base and subclasses where possible.

By using interfaces, we can introduce loose coupling between classes but still extend and modify functionality from base classes.

Let’s build on our report builder example above, while still keeping our example simple. Our requirements have now changed such that additional print functionality is necessary. The feature now needs to support multiple specific types of print outputs, including PDF, file, and printer.

Instead of only creating subclasses for each of these, that inherit from a base printer class, we can create these three new classes that all also implement the same printer interface. Our feature can then use dependency injection (an upcoming SOLID principle) to instantiate any one of these three classes as needed and our core feature code does not have to change at all.

Using the polymorphic open-closed principle, we are implementing interfaces rather than just base and subclasses. The result is classes that are open to extensibility through interface implementation but closed to change. Another result is loose coupling between classes.

Liskov Substitution Principle

In the Liskov substitution principle, a derived class must accurately replace its base class such that any object must be able to use a derived class in place of its base class and not know it.

The goal is that our code can count on the same behavior when using a subclass as when using its base class. If this were not the case, then the idea of classes being extensible is thrown out the door. Each time a new subclass is created, every bit of code would need to have new unit tests created, be re-tested, and be generally treated as wholly new code rather than an extension of existing code.

The challenge with the Liskov substitution principle is that it's harder to guarantee and enforce. This is because much of its behavior cannot be enforced by the compiler and must be done through code review. For example, the compiler can help us with abstract classes, overrides, and interface implementation. But it cannot help us enforce the actual behavior that occurs behind a method that is implemented. We must ensure that an overridden method’s validation rules are comparable to what is being overridden. We might get away with being less restrictive, but if we are more restrictive it may change fundamental behavior.

Tests are another way to help verify that this principle is being followed. Proper unit tests could catch differences between base classes and derived class implementations. So proper test cases and corresponding unit tests are important, especially in situations where a lot of base class and subclass creation is occurring.

Interface Segregation Principle

In the interface segregation principle, a class interface should have a specific purpose, and objects using that interface shouldn’t be forced to use an interface that doesn’t fit their exact needs.

It’s important to keep this in mind as we are applying the other SOLID principles, like the Polymorphic Open-Closed Principle, where interfaces play an important role in good design. The goal of interface segregation is to create code for a specific purpose and to make that code available and used only where it needs to be used.

Pulling from our report builder example above, let’s review the print feature. As we add multiple print capabilities like PDF, file, and printer, we can end up having different print features across each print capability. For example, both the PDF and the file print capability would need options for saving the report to a file location. But the printer capability would not. The printer capability may have options to set print quality and color, but those options don’t make sense for PDF and file.

Therefore, all three print capabilities may implement an interface with only the common methods to all. But PDF and file print capabilities may also implement an additional interface specific to file location methods. The printer capability may also implement an additional interface specific to print quality and color methods.

This approach allows for the printer class to be used for printing, without the confusion or bugginess around why the interface implements methods for file location. The result is interfaces segregated for specific purposes.

Dependency Inversion Principle

In dependency inversion, the goal is to invert dependency decisions so that concrete object instantiations are made late instead of early. So, depend on abstractions and not concretions, as much as possible.

The value of dependency inversion is loose coupling, and this leads to all kinds of other positive outcomes too. A classic example is testing. A common practice is to “mock” certain dependencies in order to plug in stub or test code. Using constructor injection, one of the three forms of dependency injection, a database layer can be mocked such that a test data result is returned to a function in order to test that function rather than making an actual database call.

Let’s use our report builder for another example. The core code that builds the report before it is printed might use an in-memory approach to building the report. If that in-memory approach is coded as a separate interface-driven class using constructor injection, it would be easy and quick work to replace that in-memory approach with one that writes everything to disk as it is doing it. This may make testing easier because a work file could be reviewed as part of testing to see exactly what is happening. And this injection of a test file approach can be done with no changes to the core report builder logic, thanks to dependency inversion.

There are three main forms of dependency injection: constructor injection, property injection, and method injection. Each has its merits in particular situations. Constructor injection is by far the most common form of dependency inversion.

The service locator pattern is another common form of dependency inversion. The service locator pattern is actually built in to some frameworks like ASP.NET Core.

Dependency inversion is common across many languages and is a very effective way to improve testing and overall code design.

Wrap-Up: SOLID Coding Principles

SOLID coding principles are object-oriented programming practices that result in more maintainable, less buggy code. These principles, single responsibility, polymorphic open-closed, liskov substitution, interface segregation, and dependency inversion, can be applied to many different languages.

I hope this article and its simple examples has peeked your interest and makes you want to dig in more and apply SOLID principles on your next project.