I planned on getting this finished earlier in the week but ended up shearing some of our sheep 🐑. A few days late, whatever, let's get cracking.
In this post you're going to learn about each of the 5 SOLID Principles. I've included some code examples to make them a bit more real. I've also added some thought exercises / mental models that helped me understand these principles in the hope that they'll help you too.
These principles are a subset of the principles promoted by Robert C. Martin.
- S - The Single Responsiblity Principle
- O - The Open/Closed Principle (OCP)
- L - The Liskov Substitution Principle (LSP)
- I - The Interface Segregation Principle (ISP)
- D - The Dependency Inversion Principle (DIP)
1. The Single Responsibility Principle (SRP)
The single responsibility is defined as follows:
Each class should should have RESPONSIBILITY over a single part of the functionality provided by the program.
What does this mean practically though? As a beginner programmer this isn't very helpful. Let's expand on the concept.
Examples of single responsibilities :
- Validating inputs.
- Performing business logic.
- Saving and retrieving information to / from a database.
- Formatting a document.
- Performing calculations for the document.
So if you see a class that is validating inputs, logging events, reading and writing information to the database and performing business logic, you have a class with A LOT of responsibilities; violating the Single Responsibility Principle.
How can you spot a class that may be violating the Single Responsibility Principle?
The class may have:
- Tight coupling
- Low cohesion
- No seperation of concerns
Changing one class results in having to change a lot of other classes to get the program working again. Sound familiar? 😁
The class contains fields and methods/functions that are unrelated to each other in any meaningful way.
A good way to spot this is if methods in a class don't reuse the same fields. Each method is using different fields from the class.
No Separation Of Concerns
Should my class that deals with validating an input be performing business logic and saving the data to the database? Not likely. Separate the program out into sections that deal with each concern.
A classic real world example of something having too many responsibilities are the multi function knives. They try to do too much and end up doing nothing well.
2. The Open/Closed Principle (OCP)
Software entities (classes, methods, modules) should be open for extension but closed for modification
What does this mean in a practical sense?
You should be able to change the behaviour of a method without changing it's source code.
For simple methods, adding / changing the logic in the method is perfectly reasonable. If you have to revisit this method 3+ times (not a hard number) due to requirements changing, you should start to think about the Open/Closed Principle.
Closing code to modification, why would you want to do this?
Code that we don't alter is less likely to create bugs due to unforeseen side effects.
Here's an example of some code that is not closed for modification. We'll use a switch statement that will perform something different for each transport type.
If a new transport type needs to be handled by our program then we need to modify the switch statement; violating the Open/Closed Principle.
So how can we achieve the Open Closed Principle in our code?
Typically we'd use
- Composition / Injection
The AddNumbersClosed method is not Closed for modification. If we have to alter the numbers that it's adding we have to change the method.
The AddNumbersOpen method is Open and extensible for situations where any two numbers need to be added. We don't need to modify the method as long as we're adding two numbers. We can say that this method is closed for modification but open for extension.
The MakeSound() Method here is open for many different animals to make many different sounds.
Using Composition / Injection:
In the following example, the responsibility for making the sound has been moved to the SoundMaker Class.
To add new behaviour, we could add a new class. This new class could provide some new behaviour to the Dog class.
Why would you create a new class for new behaviour?
- We know that stuff we've already built isn't affected.
- We can design the class to perfectly suit the new requirement.
- New behaviour can be added without interfering with old code.
3. The Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that:
Subtypes must be substitutable for their base types.
Ok great, but what does that mean practically?
You may have learned about the 'is-a' relationship related to OOP inheritance.
e.g. A dog 'is-a' animal (I know it should be an animal, cut me some slack for demo purposes ).
The Liskov Substitution Principle is basically stating that this 'is-a' relationship is not good enough for maintaining clean code. We should examine the relationship further and explore if we can slot in 'is-substitutable-for' instead.
e.g. A dog 'is-substitutable-for' an animal. Can we say that we can substitute our dog for the animal?
I'll show a classic example showing how the 'is-a' relationship can break down and cause some problems.
It has a fantastic name; the rectangle-square problem.
Rectangle; 4 sides and 4 right angles.
Square; 4 equal sides and 4 right angles.
So.... A square 'is-a' rectangle.
We have a Rectangle class that could look something like this:
And we have a square class that inherits from the rectangle class, because a square 'is-a' rectangle. This is what the square class looks like.
Now say we have a method that calculates the area of a rectangle. Should be pretty straightforward. We'll pass our rectangle as a parameter and return the width multiplied by the height.
This won't work when we have code like this:
We create a new Rectangle.
It has a Height of 3 and a Width of 2.
Our expected result is an area of 6, however, the actual result is 4.
Why did this happen?
If you look at the code for the square class. When the width property is set, it overwrites the height. So we actually created a square with a width of 2 AND height of 2.
This example is trivial and you can see that we instantiated a square. In real world programs this may not be as easy to spot. You might be receiving the object as a parameter from another class and not know it's type. This could lead to unintended results as shown above.
It comes down to our Square not being 'substitutable-for' a rectangle. A square's sides must be of equal length but a rectangles width and height can be different. We didn't perform the check before inheriting from the Rectangle class.
Some clues as to when your code is violating the LSP:
- Type checking
- Null checks
You can also perform the duck test:
If it looks like a duck and quacks like a duck but it needs batteries, you probably have the wrong abstraction - Derick Bailey
4. The Interface Segregation Principle (ISP)
What is the interface segregation principle?
Clients should not be forced to depend upon interfaces that they do not use - Bob Martin
The client in this case is any calling code.
Take a look at this interface name IPersonService.
It has three methods. Any client that implements this interface will have to implement these methods.
Now let's take this Child class that implements the IPersonService Interface.
For a child, the SetSalary() method and the Salary property do not make sense!
The client (child class) depends on an interface that it does not use. ❌
The interface is only partially implemented 😒.
See that method throwing the NotImplementedException() ? It's a good sign that you're violating the Interface Segregation Principle.
This isn't so bad here, but it will become a problem with larger interfaces. It introduces higher coupling ( I like to think of high coupling as classes being super-glued together and tougher to separate). Future changes to the code will be more difficult.
How do we remedy this?
Split the interface into more cohesive interfaces.
The IPersonSalaryService is an interface that defines members related to a person's salary.
The IPersonNameService does the same for members related to a person's name. Both are more cohesive than the original IPersonService.
Now our client code (Child class) can depend on code that it actually uses. Much better. 😎
We can easily implement multiple classes in C# using this syntax. Take a look at this Adult class. It depends on code (the two interfaces) that it actually uses. ✅
5. The Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that
"High-level modules should not depend on low level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions"
In a C# project, your project references will point in the direction your dependencies.
In domain driven design or onion architecture as it's sometimes called, the references will point away from low-level (implementation code) and towards your business logic / domain layer.
You can think of high-level code as being more process-orientated. It's more abstract and more concerned with the business rules.
Low level code is the plumbing code.
Here's an example showing what high-level code and low-level code might look like in a program.
The Domain layer is just concerned with publishing a course. As we move to lower level code we get more concrete. See changes to the course status id - this is low level code (In the context of a language like C#).
Abstractions are generally achieved using interfaces and abstract base classes.
@ardalis put it really well when he said that they're generally types that can't be instantiated (Read, you can't make a new object from them).
Abstractions define a contract, they don't do the work
Abstractions specify WHAT should be done without telling us HOW they should be done.
Again, thinking of abstractions in terms of a contract is a useful exercise.
I like to think of an abstraction speaking to any class that depends on the abstraction as saying something like:
"This is what you must do, I don't care how you do it." - Abstraction speaking to a class that depends on it.
This may seem stupid. It helps me understand abstractions and interfaces and how they can be used to make programs easier to design and manage.
If it's stupid, but works, it ain't stupid!
So that's it, by now you'll have a better understanding of these 5 principles and you can start incorporating them into your work. You'll be aware of them if nothing else and being aware of them is half the battle.
I first learned about these principles in my University but what really drove them home was the SOLID Principles for C# developers course by Steve Smith on Pluralsight called 'SOLID Principles for C# Developers'. I highly recommend it.
If you have any questions pop them into the comment section below or reach out to me on twitter where I post coding tips regularly.