S.O.L.I.D. Principles are a set of widely-known principles for object-oriented software development. They were first introduced by Robert C. Martin, a highly influential software instructor and author on software design patterns and principles. You can check out a few early writings on SOLID from Robert C. Martin here –
The purpose of these principles is to help avoid software from “rotting” over time and to help keep the code maintainable long-term.
SOLID Principles are also applicable across other object-oriented languages, whether it be C#, Java, etc.
As you can probably guess, each letter in S.O.L.I.D. represents a principle.
- S – Single-responsibility
- “A class should have one, and only one, reason to change.”
- O – Open-Closed
- “You should be able to extend a classes behavior, without modifying it.”
- L – Liskov Substitution
- “Derived classes must be substitutable for their base classes.”
- I – Interface Segregation
- “Make fine grained interfaces that are client specific.”
- D – Dependency Inversion
- “Depend on abstractions, not on concretions.”
The Single-Responsibility principle
“A class should have one, and only one, reason to change.”
The Single-Responsibility principle is the most straightforward principle of SOLID so I won’t spend too much time on it. A class should just do one thing. For example, if you have a “Login” class, it should only have code that is relevant for login – not code such as say, getting data for a different page, or even user administrative code like creating a new user/deleting a user, etc. That code does not belong in a Login class.
You can also apply Single-Responsibility to your methods. Do you have long, monstrous methods that actually do a million things? Not only does that get harder to read, maintain, and debug, it is harder to unit test.
Writing your code to have clear single responsibility will also keep reasons you need to update a class to a minimum. If you’re having to update code in your Login page not just for the actual login code, but also need to update the code for creating a new user/deleting user, then you increase your chance of introducing new bugs simply by having to have more reasons to update a class.
Example
The Open-Closed Principle
“You should be able to extend a classes behavior, without modifying it.”
This principle at first may seem impossible – but note that this doesn’t include bug fixes. It isn’t saying your not allowed to modify code to fix bugs. It’s referring to when you have requirement changes or need to add new features/change logic. Ideally, if you know certain requirements or features that are likely to change, write your code in such a way that you don’t have to modify the existing code. How can you achieve this?
A few approaches to make your code extendible are:
- Passing in parameters
- Using inheritance
- Composition/Injection
Parameter-based Approach
In this very simple example, we are just allowing the ShowMessage ability to be extendible by being able to simply pass in any other message we want.
Inheritance-based Approach
In this approach, we have a Game class with a method determining whether a player has won. The issue is over time, the rules may change and we may have new rules. If we have the ability to create a new class and override HasPlayerWon, we can then add our new game/rules without touching the Game class.
Composition/Injection Approach
In this approach, I have an IGameRulesService interface injected into the Game at run-time. This gives me the flexibility to create a new GameRulesService so long as it implements the IGameRulesService interface. I can completely change the game rules logic without touching the Game class at all.
Why does this matter? What’s wrong with just modifying the existing code?
This is because when you put your new code in new classes, you are less likely to break the existing functionality. Also, by designing code to be extendible, you are less likely to have tons of conditional if-statements and checks which can grow over time as you add more requirements. For example – imagine if we had just 1 Game class. Over time, say you try to use this same class to make it work for newer games your company wants to add. You’ll probably end up with lots of conditional logic and a very long monstrous class. Every time you try to make a change, you could potentially break any other code that uses this Game class. It would become a house of cards. I’m pretty sure you have seen code like this before.
The bottom line is, your code will be cleaner, easier to maintain, and test! When putting your new features in new classes, it’s often easier to set up unit tests for the new classes.
Liskov-Substitution Principle
“Derived classes must be substitutable for their base classes.”
The Liskov-Substitution principle (aka LSP) was initially introduced by Barbara Liskov, a computer scientist, in 1987. She described it as,
“Let φ(x) be a property provable about objects x of type T. Then φ(y) should also be true for objects y of type S where S is a subtype of T.”
https://en.wikipedia.org/wiki/Liskov_substitution_principle
I don’t know about you, but that didn’t make sense to me at first. The Liskov-Substitution principle probably is the most confusing to developers of the SOLID principles (or at least it was for me). Thankfully, Robert C. Martin put it a bit more plainly – “Derived classes must be substitutable for their base classes.” That’s better, but what does that mean?
It means if you have a base class and a derived class, you should be able to use objects of the derived class wherever objects of the base class are expected, without causing any issues or unexpected behavior.
You should not replace the existing functionality of the base class. You want your derived classes to still hold true to what the base class is as far as its properties and methods. Your derived classes can extend it’s features though. In this way, you can then use objects of the base class interchangeably with objects of the derived class, and your code will still work correctly.
We’ll skip the most common “square/rectangle” example and look at a different example with printers.
The Problem
In the below example, we have before code with a class Printer and FancyNewPrinter which inherits from Printer. The FancyNewPrinter overrides the Print() method and adds some new behavior, like adding photo filters and sending push notifications when printing is done.
The Resolution
In the “after” code, we are taking the method both classes have in common, Print(), and moving it to a separate interface. You can then have your classes have its own implementation of Print(), which is now expected, and therefore continue to use the Printer base class without any unexpected behavior or errors.
Hints You May be Violating LSP
A few hints you might be violating the Liskov-Substitution Principle:
- Type-checking
- Checks in your code like
- if (printer is FancyNewPrinter f) then do ___
- You shouldn’t have to have special conditions checking for what type the Printer is.
- You should be able to call Print() and know that it will work correctly regardless of it being a regular Printer vs FancyNewPrinter.
- Checks in your code like
- NotImplementedExceptions
- You shouldn’t in your derived classes omit parts of the base class functionality by throwing NotImplementedExceptions
- Example – let’s say base class Printer has a Fax() method. The FancyNewPrinter does not have Fax() so, we throw a NotImplementedException. This is altering the behavior of the Printer class.
- To resolve this, you can break out the Fax() method into an IFaxable and have just the Printer class implement that.
- You shouldn’t in your derived classes omit parts of the base class functionality by throwing NotImplementedExceptions
Coming up ahead…
I hope these simple examples helped you! Coming up we will look at the remaining 2 SOLID principles – Interface Segregation, and Dependency Inversion.