SOLID Principles are one of those things that every developer has heard of but few fully understand. If you use an object-oriented language, being familiar with these principles will help you write better code.
Principles such as the Single Responsibility principle are fairly self-explanatory whereas the Liskov Substitution Principle requires a bit more mental effort to wrap your head around.
Even those of you that think you know them well could always do with a refresher. Hopefully, by the end of this article, you will have a good understanding of each of the principles, even if you have struggled to understand them before.
Subscribe for more video content
Single Responsibility
This is one of the simpler principles of SOLID. As the name suggests your classes should have a single responsibility.
Giving a class multiple responsibilities makes you more likely to introduce bugs when something needs to change. A class should have only one reason to change.
So what does this look like in real life?
Let's say we are writing a teaching application and we have a student class. We could put all of this in the student class like this:
public class Student
{
public int StudentId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public void Save()
{
// Save student to database
}
public void Email(string subject, string body)
{
// Email implementation
}
public void Enrol(Course course)
{
// Enrol the student in a courseSer
}
}
This however quite clearly breaks the single responsibility principle. The student class is now responsible for:
- Storing the student's details
- Saving them to the database
- Sending them emails
- Adding them to courses
We now have multiple reasons that we might need to change this file and therefore multiple ways that we could introduce bugs.
If we have multiple developers on our team working on different features for a student, they would need to update the same file, which will cause all sorts of merge conflicts.
We need to extract all those responsibilities into separate classes to fix this. This has the added benefit of allowing us to reuse that functionality in other parts of our application without having to repeat ourselves.
Even though this seems quite simple it is very easy to take this too far. Single responsibility doesn't mean that your class should do only one thing. It just means everything that it does should be closely related so you don't end up with bloated classes.
Open-Closed Principle
The open-closed principle states that your code should be open for extension but closed to modification.
If you need to add some additional functionality to your application then you shouldn't be touching any of the existing methods or classes to do that.
After all, you would have spent ages writing all the unit tests for those methods and any changes will cause them to break.
You are writing unit tests, right?
On top of that, introducing new functionality to an existing method might cause unexpected consequences where ever that method is being used.
So how do add functionality without changing the code of your existing methods and classes?
There are a number of different ways that you can achieve this but depending on what language you are using not all of them will be an option for you. For these examples, I am using C# but you will need to check what options you have available for your preferred language.
Decorator Pattern
One way to achieve this is to use the decorator pattern. If you need to add functionality to a method that happens before or after the existing code, then we can use this pattern.
Instead of modifying the method, we create a new class that implements the same interface.
public class NewService : IService
{
private readonly IService _service;
public NewService(IService service)
{
_service = service;
}
public int DoSomething(int input)
{
// Do something new here
var value = _service.DoSomething(input);
// or here
return value * 2;
}
}
We can then have our new method call our old method and then add the additional functionality either before or after this method call.
Then we can call this new method without affecting the other code in our application.
The benefit of this approach is that we can use dependency injection to control at runtime when this new class is used.
However, you can only use this approach if the method you want to extend is public and included in the interface.
Extension Methods
Another great option is to use extension methods which you can do with languages such as C# or Kotlin.
Extension methods allow you to add a method to an existing class without having to modify the file.
public static class AddedFunctionality
{
public static void DoSomethingElse(this IService service, int input)
{
// Do something new here
_service.DoSomething(input);
// or here
}
}
Provided you have the namespace imported, you can use the extension method in your code as if it were part of the original class.
The only downside of using extension methods is that, unlike the decorator pattern, you can't switch between different implementations at runtime.
There are also many other ways to add functionality without changing the original code such as using inheritance or adding attributes.
Liskov Substitution Principle
The Liskov Substitution principle is a little harder to understand, it states that a child class should always be able to perform the same actions as its parent.
So if we had an actual Parent Class that has the actions:
- Eat
- Sleep
- Work
- MakeDinner
Then we have a Child Class that is inherited from the Parent Class, there are obviously some actions that the child cannot do such as MakeDinner or Work.
This would therefore break the Liskov Substitution Principle as we can't replace the child with the parent.
It would therefore make more sense to have a Human class and then have Adult and Child classes inherit from that class instead.
Interface Segregation
Interfaces provide a contract that classes need to implement. If you have particularly bulky interfaces, the classes are forced to implement methods that might not be needed.
If you have ever used the Repository Pattern you might have come across this before.
With this pattern, you might have an interface that looks like this:
public interface IRepository<T>
{
void Add(T item);
void Delete(T item);
void Update(T item);
T GetById(int Id);
IEnumerable<T> GetAll();
}
Every time you use this interface you have to write an implementation for each of the methods even if you aren't going to use them all.
You could of course throw a NotImplementedException, but that would also break the Liskov Substitution Principle.
To overcome this you can split these methods into separate interfaces. You can always have a main interface that inherits from all the others.
public interface IItemReader<T>
{
T GetById(int Id);
IEnumerable<T> GetAll();
}
public interface IItemWriter<T>
{
void Add(T item);
void Update(T item);
}
public interface IItemRemover<T>
{
void Delete(T item);
}
public interface IRepository<T> : IItemReader<T>, IItemWriter<T>, IItemRemover<T>
{
}
This way you only need to implement the methods that you are actually using and you don't have to worry about calling some code that hasn't been written yet.
Dependency Inversion
The last principle of SOLID is Dependency Inversion which states that high-level models shouldn't depend on low-level modules. Both should depend on an abstraction.
For example, if you had a service class and you wanted to save something to the database you could create a new instance of the repository directly inside the class.
public class Service
{
private readonly Repository _repo = new Repository();
public void Save(Item item)
{
_repo.Add(item);
}
}
But now your service is dependent on a lower-level component.
To overcome this we create an interface for our repository and then inject it into the constructor when we create our class.
public class Service
{
private readonly IRepository _repo;
public Service(IRepository repo)
{
_repo = repo;
}
public void Save(Item item)
{
_repo.Add(item);
}
}
Now both the high-level and low-level modules depend on an abstraction which is the interface.
The service doesn't need to know which repository it is using or how it works. It only cares that it meets the specifications of the repository interface.
Final Thoughts
With all software principles, it is important to understand that they are not rules that you have to obey. They are just there to help you write better code.
For example, you can take interface segregation too far and have just one method per interface even when you always use all of them.
Or the requirements could change and instead of modifying the code you extend it leaving the old code behind that just never gets used.
Dependency inversion is really good in principle but in most cases, you are going to have just one class using an interface. Being able to swap out one database with another using the same interface is a nice idea but how often have actually needed to do that?
The real benefit of using interfaces is that it makes everything a lot easier to test because you just inject a mock when you are testing.
To be honest, the SOLID principles are in some ways too vague to be useful. It is very easy to obey SOLID but still end up writing bad code.
As always the best advice is to keep it simple. Another coding principle called CUPID tries to overcome these shortfalls, which I will cover in a future article.
📨 Are you looking to level up your skills in the tech industry?
My weekly newsletter is written for engineers like you, providing you with the tools you need to excel in your career. Join here for free →
Top comments (0)