The SOLID Principles

The SOLID Principles are a great way of not just finding the right abstractions for a system, but also respond to the need of making designs robust.

In my previous article, I talked about abstractions, which we defined as “Hiding Implementation Details under concepts”, and we saw a small example of how a design can be improved with better use of concepts that make more sense within a Domain (Area of expertise)

The SOLID Principles are a great way of not just finding the right abstractions for a system, but also respond to the need of making designs robust. A robust system refers to systems that are loosely coupled and high cohesive. Let’s take some time to explain more about this

Coupling and Cohesion

When you try to modify one component of the system, and other parts of the system are no longer compatible with the changes (meaning they no longer work or they have to be changed), it’s said that part of the system is coupled.

What does “they no longer work”? Let’s put a quick example with two functions:

  • A function that receives a list of Employee IDs and calculates how much a person needs to get payed depending on the number of hours they worked
  • A function that performs the payments depending on the previous class, and provides the Employee IDs

Now, let’s say our first function no longer accepts a list of Employee IDs, but it now accepts a Contract Sheet that has a signature saying if the payments are approved by HR. In that case, we would say the second function would break as it sends employee IDs, not a Contract Sheet. In other words, the interface breaks.

👉 Do not get frightened by this, coupling is actually not bad most of the times, it’s an inevitable part of designing a system. There are actually two types of coupling, soft coupling and hard coupling (the previous example was about hard coupling). The most important part to understand is that coupling should be a conscious decision made by the development team, not an accidental.

Design towards Interfaces

An interface is a contract. It defines what should be the input and output of a function/class. It’s a signature that must be respected.

What’s the deal with that? What’s the problem if I change the signature of a function, and I take the decision of changing it’s parameters and return values?

There’s actually not a problem, we know software evolves and things like this are unavoidable. The biggest outcome of making a good software design is that changes to interfaces are minimized. One can somehow predict if an interface will change or not depending on the domain or area of expertise of a system, so it’s in the best of our interests to

For example, in our previous Employees example, we pay to our employees depending on the hours they worked, but we could have many strategies to pay them, one can be using the corporate debit card, or maybe we want to pay them with another payment provider, let’s say Stripe for simplicity.

In that case, we can know that we can create an abstraction called PaymentGateway, PaymentClient, PaymentFacade, which will define an interface called pay(employee), and whatever’s the implementation inside, we shouldn’t really care. Making this abstraction would allow us to have multiple classes for each payment provider like StripeGateway or DebitCardGateway.

🎨 Designing systems is a hard and a very creative task. For me, making a good software is inclined more to an art than to an engineering (I’ll talk about this in another post!)

So, what does SOLID actually mean?

SOLID is an acronym which contains 5 software design principles gathered by Robert C. Martin (famously known as “uncle Bob”). He’s also the creator of Clean Code, Clean Architecture, and has written many books about software engineering, and has some great tutorials online I highly recommend (although his sense of humor might not be suitable for everyone, the content is incredibly useful).

  • Single Responsibility Principle: A class should have one and only one reason to change, meaning that a class should have only one job.
  • Open Close Principle: Objects or entities should be open for extension but closed for modification.
  • Liskov’s Substitution Principle: Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.
  • Interface Segregation Principle: A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.
  • Dependency Inversion Principle: Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

👉 I will be describing only the S and D parts of SOLID for simplicity, as those are the ones I use the most

Single Responsibility Principle (SRP)

This principle stands that a function, class or module should only know how to do one thing, and do it really well.

What is “one thing”? As Uncle Bob distills it, is about creating parts of a system that are responsive to just one actor of the system.

This is in my opinion the most interesting principle of all, as it’s the most “simple” in theory, because when one reads about it, we think “it’s so easy, I just need my function to do one thing, my function is only responsible of parsing a string into a number, I must be following SRP”.

Why SRP has nothing to do with Don’t Repeat Yourself

The name of this principle wrongly promotes the use of abstractions that are re-usable across the system, with the purpose of saying “this principle promotes that, if a class is just responsible of doing one thing, it can be re-usable across other parts of the system”. That actually conveys more to the Don’t Repeat Yourself principle, which I personally don’t like that much, as it sometimes promotes engineers to create the wrong abstractions to a system. I’ll explain more

When we try to create a function/class that is “responsible” to perform just one operation (let’s say, get the payments from customers who paid in less than a month), and we use it in multiple parts of the system (different clients use it), there’s a high risk that one of the clients of that function might require additional functionality, so the most common answer from an engineer is to put a conditional flag to that function, so that if one client requires an extra functionality (let’s say, charging moratory customers if detect there’s a missing payment), they would get the way to put in that functionality to that same function/class, but we would be introducing Code Smells and Technical Debt, as that class has multiple actors asking for changes, which can be re-phrased to that class has multiple reasons to change

SRP has nothing to do with avoiding duplication, but rather with how a function/class should evolve if multiple people ask for a change to a piece of functionality.

Dependency Inversion Principle

This principle states that a class should not depend to implementation details, but it should rather depend on abstractions.

This is exactly what we’ve been talking this past two articles! Implementation details, when thinking on the big picture, are just noise! Following the previous example, classes should not be coupled to a single payment provider, they should depend to an abstract PaymentGateway interface, which in our previous example, was a class with a pay(employee) method. All the other details around how a payments are done should be handled by the class internals.

Example

Let’s say we have a PaymentGateway base class that handles the connection with a payments provider so a higher level class can use it to perform business logic. We could have multiple classes inheriting from a base PaymentGateway one, which would implement the exact details each payment provider requires. Or we can also use composition to create multiple classes respecting a unified interface, and inject it to the main business logic class.

class DispatchPayroll {
  constructor(paymentGateway, employeeIds) {
    this.paymentGateway = paymentGateway;
    this.employeeIds = employeeIds;
  }

  execute() {
    const employees = this.findEmployeesByIds();

    employees.forEach(employee => {
      this.paymentGateway.pay(employee, employee.monthlySalary);
    });
  }
}

💡 As long as we inject a paymentGateway object with the interface pay(employee, monthlySalary), we should be able to respect the open close principle, which is that this DispatchPayroll class would never need to be modified

What’s the big deal with this principles?

This principles are just guidelines, they are not absolute rules, and IMO they must not be enforced. One of my biggest mistakes in my career was being stubborn enough to tell my colleagues “This is not following the Single Responsibility Principle” or stuff like “This interface is wrong, it doesn’t make any sense”.

The reason why they should not be enforced is that, at least in startups, business have not figured out yet what is the right way to operate their business. In this particular cases, building extra abstractions for the sake of applying this principles not improve the business, but would just be extra work for the engineering team, work that most of the times would not add value. But, whenever we identify this principles can be useful, we should at least think a bit about them, and make preparations.

In other words, if you think you might not need an abstraction because there’s not enough information from the business that it might be required in the future, feel free, without any guilt, to copy/paste your code, couple your classes, and DO evaluate later if you need an abstraction to give your system flexibility and remove rigidity.

I will enlist some of the biggest advantages of using SOLID, aside from Readability, Extensibility, Improved Debugging, Ease of Refactoring.

Improving Unit/Integration Testing

When we think about making classes respond to just one responsibility (one actor), and when we think about dependency inversion (decouple a class from it’s implementation details), we are able to test a class with fake implementations (mocks), meaning that, if there’s a functionality that calls an external API, we could easily inject a fake external API mock that would simulate a real call, with a real response from the external world.

This is really useful as:

  • It decreases the amount of time it takes to run the tests (not making a network or database call can save some time)
  • It decouples your test suite from the network, meaning you should be able to run the tests without an internet connection, or if external APIs go down, tests would not fail.
    • It simplifies the use of Test Driven Development

Improving how we support new functionality via interfaces

As we could see with the payment gateway example, we are pretty much able to create new classes as we continue supporting new payment providers (Stripe, ApplePay, etc), as long as our classes respect the same interface.

Q&A

Does the industry uses this? Isn’t this just stuff from books nobody cares because it’s just purism?

Yes, but obviously not everyone believes in the same approaches. In fact, it’s rare that I see companies using them explicitly, and I don’t encourage to follow them at all times or circumstances.

Curious thing, I have found more companies in Europe than America where they put this principles in their Job Descriptions.

What if my company doesn’t apply this principles? Is our software wrong?

I don’t think there’s such a thing as wrong software. At the end, we all make software with what we understand about a business. There’s no silver bullet.

My company is full into Serverless and No-Code. Do this principles apply if we don’t have that much code or if we use only integrations?

I do think they are not as relevant as systems that require a fine granularity in terms of functionality. But also, I think if you are using no-code tools for specific tasks, you might want to use granular services that specializes in just one area of expertise, because sometimes using the “one solution for all” tools might not give you what you want, making you change the providers with more frequency. Pick the tools that excel in just one thing, and also, prepare for the change, software always change.

Conclusions

I hope this post was useful for you! I would love to hear your comments, and I know, it’s a pain to create a Hashnode account, but feel free to give me your comments in my LinkedIn post!

What happens when an error occurs in the system? Let’s say, we had errors paying to our employees, it’s a big deal right? Identifying errors and handling them correctly is a very important part of designing software. I’ll be talking about Error Handling in my next post!

Did you find this article valuable?

Support Alberto Romero by becoming a sponsor. Any amount is appreciated!