← Back

S.O.L.I.D.

An overview of the S.O.L.I.D. principles and inversion of control for designing software that is decoupled, extensible, reusable, and easier to maintain and test.

PrinciplesS.O.L.I.D.Best Practices

How can we make a software project maintainable and easy to change?

There are many answers to this question, depending, of course, on the nature of the project itself. Nevertheless, there are a number of principles that, if followed, can radically change a project’s life (and structure).

Far too often, I see projects—including many GitHub repositories—where it’s impossible to tell how even the most basic software engineering design principles have been implemented. And that’s not necessarily a bad thing; it all depends on how long the project is expected to last.

We don’t need to know every detail of each design pattern more or less known and accepted by the industry, and thoroughly explained in classic books like this one. But if we want our project to be improved continuously without too much difficulty, then we do need to follow some basic development practices.

This is where the S.O.L.I.D. principles come in.

If your project has extraordinarily long classes or functions, hundreds of lines of code at a time; methods with countless if statements or deeply nested blocks; or methods and functions with many parameters, then your application is heading toward a spaghetti solution that’s difficult to maintain and evolve.

If that’s the case, read on…

What distinguishes a design principle from a pattern?

A pattern is a clear technical solution to a particular problem, while a principle is a design guideline your code should follow. The best development frameworks are those that adhere most closely to these principles. One example that comes to mind is Seneca, a framework I really like for building microservices in Node.js, though there are many others.

Broadly speaking, the S.O.L.I.D. principles help us design applications that:

  • Have a high degree of decoupling between their functional parts.
  • Can be extended easily without major changes to the application.
  • Are testable.
  • Make code more reusable.

Although they’re more natural to implement in object-oriented programming, they can also be applied in functional languages. In this article, I’ll use software entities to refer to classes, modules, methods, functions, and so on.

Below is a brief overview of these five principles, along with an equally relevant technique: inversion of control.

Single responsibility principle (SRP)

A class should be dedicated to solving a single problem. In other words, we shouldn’t implement a class whose functionality mixes different areas of the application.

When this principle is applied well, we can create relatively small classes that are therefore more reusable and easier to change and test.

In this context, responsibility means a reason to change: if there’s more than one reason a class might need to change, it doesn’t follow this principle.

This may not be easy to spot at first, but this code example shows a simple (and somewhat academic) example written in JavaScript for Node.js.

Open/closed principle (OCP)

According to this principle, a class should be designed so that it can be extended easily without changing its current functionality. It’s easy to describe, but implementing it requires a certain degree of abstraction. Depending on the part of the application you’re working on, this principle may or may not apply.

I won’t deny that this principle is somewhat subtle, but once understood, it essentially says that a software entity should abstract the core of the functionality it implements as much as possible.

OCP aims to let us extend a software entity’s functionality without modifying its code. As I said, that requires a degree of abstraction.

This other code example shows how to apply the principle to the classic example of validating user input.

Liskov substitution principle (LSP)

This is perhaps the most subtle principle to understand, but it’s just as important. LSP aims to ensure that inheritance between classes is implemented correctly.

A child or derived class is one that inherits from another class.

The definition is somewhat academic, so here’s a simpler explanation: LSP says that if a program uses a class, it should remain valid (that is, continue to work correctly) if that class is replaced with a new class derived from it. If a program uses class P and we replace P with O (where O derives from P), the program should continue to work correctly.

Here’s the definition from its author, Barbara Liskov: if o1 is an object of type S and o2 is an object of type T, and the behavior of a program (function, method, and so on) based on objects of type T doesn’t change when using o1 and o2, then S must be a subtype of T.

Phew… In practice, it describes how inheritance between classes should be implemented.

It’s easy to say, but not so obvious to implement. Following this principle helps ensure inheritance is done correctly and also makes base (parent) classes and their derived (child) classes easier to test.

LSP is closely related to OCP because it also states that a child class should extend the behavior of its parent, not change it.

Implementing LSP requires a high degree of abstraction in the solution we’re trying to build. If our design violates this principle, then we’re not abstracting well enough.

Here’s an illustrative example that follows LSP.

Finally, one way to enforce this principle is to use design by contract, where interfaces are specified and implemented by base classes.

Interface segregation principle (ISP)

This principle aims to prevent the creation of large interfaces containing unrelated methods, which would force a class deriving from them to leave some methods unimplemented because it doesn’t need them.

ISP suggests splitting this interface (fat interface) into smaller, coherent interfaces, so that classes derive from them only when they need every one of their methods.

As a result, the interfaces defined in the project have a high degree of abstraction and granularity.

This other example shows how to apply the principle.

Dependency inversion principle (DIP)

This is perhaps the most popular and easiest of all the principles to implement, and it’s sometimes confused with dependency injection.

It says that our code (in a module, class, and so on) should depend on abstractions, not concrete instances of them.

An abstraction is a definition that a concrete class must implement—the equivalent of abstract classes and interfaces in object-oriented programming.

DIP helps decouple code and keep functional units smaller (and therefore more reusable and testable).

Inversion of control (IoC)

Inversion of control isn’t part of the S.O.L.I.D. set, but it also helps us create more decoupled and maintainable code. However, it isn’t applicable to every kind of project.

Traditionally, we can say—very simplistically—that a program is implemented by executing one method or function call after another. As the program grows, however, the application needs to be structured into modules, different architectures come into play, and so on.

In other words, an application’s usual flow consists of calling procedures (functions or methods) in a library.

Inversion of control is implemented when that flow is reversed: the library itself calls the user’s code, which is what actually implements the application.

It may sound convoluted, but there are many examples of inversion of control. It’s also the natural approach when we need our application to be easy to extend. Still, this mechanism isn’t always necessary; it depends on the nature of the project.

IoC and DIP (inversion of control and dependency inversion) often go hand in hand, since it’s easy to implement IoC using dependency injection (DI):

  • Service Locator: a well-known design pattern that, in fact, implements inversion of control.
  • In the Drupal content management system, users can implement modules that the application’s core invokes at the right time in response to different events (inversion of control). This is also the usual mechanism for extending functionality in WordPress, Dolibarr, and similar applications.
  • In Express, a popular framework for building web applications with Node.js, there’s a mechanism called middleware that injects modules so they’re invoked on every request (inversion of control).

One of the products I’ve designed—the IRIS Remote Management Platform—also implements inversion of control in its task management. An engine processes the tasks as they’re created, and new tasks can be implemented with the functionality each client needs, without changing the product’s core.

Conclusion

It’s worth taking the time to study these principles and read examples of how to implement them in the environment you use regularly.

An application’s structure and design when it follows these principles—even in different ways—are nothing like those of the same application when it ignores them, even if it works correctly.

So why bother? Simply because the application will be far more maintainable, reusable, and easier to test.