S.O.L.I.D.ify Your Software
and stop writing bad code that slows you down
Standing on the shoulders of giants today, we programmers wield incredible power at our fingertips. Anyone can create anything in the digital world; the possibilities are endless! If only we don’t spend most of our time fixing bugs and chasing dependencies to implement a new minor feature. We just want to write good code that is easy to maintain, refactor, extend, that allows ourselves to frequently make changes without introducing bugs…
Luckily, many people have written bad code before us, and they have consolidated the lessons they learned into rules of thumb (eg. DRY), or design patterns (eg. Factory). These guidelines help us make sense of the infinite number of choices we can make.
This article focuses on one such guideline known as SOLID.
SOLID is a mnemonic for 5 design principles introduced by Uncle Bob in his book Agile Software Development, Principles, Patterns, and Practices, and are built upon OOP principles such as abstraction, encapsulation, inheritance, and polymorphism.
SOLID stands for:
S — Single Responsibility Principle
O — Open/Closed Principle
L — Liskov Substitution Principle
I — Interface Segregation Principle
D — Dependency Inversion Principle
I will break each of these down using Pokémon concepts as examples.
S — Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change.
The Single Responsibility Principle states that every class should be responsible for only one part of the software’s functionality. Conversely, each independent functionality should be encapsulated entirely within its own class.
The goal of this principle is to keep classes simple.
By defining responsibility to mean reason to change, you can easily spot a class that has multiple responsibilities by asking “what varies when?”. Then, you can extract the extraneous functionality into its own class.
Here’s a Pokémon:
It has a name, belongs to a generation, and has potential to evolve. You can even print out its details in a predefined format to display them in a card. The main job of this Pokemon
class is managing information about a given Pokémon.
Actually, it turns out a Pokémon can belong in multiple generations, so we change the getGeneration
method to return a list getGenerations
. We’ve modified the class in order to get up-to-date information about a Pokémon.
Oh, but now we have to update the profile format to render the list of generations! We also need to add some spacing between each generation…
Modifying the format template for the card has nothing to do with the Pokémon’s personal details, so we should probably have a dedicated class to manage the formatting.
Now, when we make changes to the Pokemon
class, the only reason would be because information about the Pokémon needs to be updated (eg. add elemental type). When we make changes to the Pokemon
class, we may need to make changes to the PokemonProfile
, but only because the printing format needs updating.
O — Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification.
What does it mean for a class to be open or closed?
- Open: You can do whatever you want with it: add new fields or methods, override base behavior, extend or produce a subclass from it.
- Closed: The class is complete. It’s ready to used by other classes, and its interface will not change in the future.
If a class has been published and especially if it is being used as a package by other software, making any modification to the class can break the implementation of those using your class.
A class should be designed such that new requirements can be fulfilled by extending it with abstractions without directly modifying the class itself.
Take this Pokémon.
Every time we introduce a new Pokémon type we would have to add new conditions to this if/else block. This means the class is not closed for modification.
We can close the class by first extracting each type into their own classes with a common interface, and have the Pokemon
class be dependent on that interface. Then, when we have new Pokémon types, we can derive a new class from the interface.
Clearly, the implementation has gotten more complicated, but the responsibility for each class is clear, and most importantly, you can quickly add new Pokémon types without touching the Pokemon
class.
L — Liskov Substitution Principle (LSP)
When extending a class, remember that you should be able to pass objects of the subclass in place of objects of the parent class without breaking the client code.
The Liskov Substitution Principle is named after Barbara Liskov who first introduced it. It states:
“if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.)”
In other words, objects of a derived class should be valid substitutes for objects of the base class. This is to ensure that subclasses are compatible with the behavior of the base class so that new classes do not produce undesirable effects when used in existing client code.
Unique among the SOLID principles, the LSP has a formal set of requirements on signatures and behavior.
Requirements on signature (variance rules):
- Parameter types of a method in a subclass should be contravariant (ie. they should be the same types or be more abstract than the parameter types of the method in the base class). For example:
- Given a base class with a methodeat(Cake)
- A subclass overriding that method can be eithereat(Cake)
(same type) oreat(Dessert)
(a more abstract type)
- The method cannot beeat(CheeseCake)
(a subclass of Cake) - The return type of the method in a subclass should be covariant (ie. is the same type or a subtype of the type returned by the method in the base class). The rule for the return type is the inverse of the rule for parameter types.
- Given a base class with a methodgetCake(): Cake
- The overriding method can have the following signature:
getCake(): CheeseCake
- and notgetCake(): Dessert
, because the client code can’t be sure the dessert isn’t a cookie. - A method of a subclass can only throw exceptions that are the same type or a subtype of the exceptions thrown by the method in the base class.
Many statically typed OOP languages (eg. Java) won’t even allow the program to compile if these rules are violated.
Requirements on behavior (contract rules):
- Preconditions shouldn’t be strengthened in the subclass. Preconditions are typically enforced by guard clauses in the beginning of the method. Take a base class with a method that takes an integer(
int
) for its parameter. It also throws an error if the value is negative(<0
). The overriding method in its subclass cannot strengthen the guard by throwing an error if the value is lower than 10 (<10
). - Postconditions shouldn’t be weakened in the subclass. Postconditions can also be enforced by guard clauses, but most real-world postconditions deal with connections. Say we have a method that opens a file, manipulates it, and then closes the file at the end. The overriding method in the subclass cannot keep the file open for other methods to reuse it.
- Invariants must by preserved in the subclass. An invariant is something that is true during the entire lifecycle of the class. Often, these fields and methods are made private in the base class to guarantee they’re preserved. But even in languages that can protect private fields, this principle can be difficult to enforce because it’s not always clear which members of a class should be invariants. A water molecule has to have one oxygen atom and two hydrogen atoms. The invariants are clear for a water molecule. But does a human have to have two arms and legs? Because of its difficulty to enforce, this rule is often considered to be less of a rule and more of an ideal to strive for.
- The “history rule”. If a field in a superclass cannot be modified by any methods in the class, the same field in the subclass must also not be modified by any overriding or newly introduced methods. You cannot derive a
MutableArray
subclass from anImmutableArray
base class.
These behavioral requirements follow the Design by Contract approach.
The Liskov Substitution Principle has a long checklist, but they’re just checks to help programmers determine whether a subclass is truly compatible with its base class. Uncle Bob summarizes it as:
“Subtypes must be substitutable for their base types”
Let’s look at a Pokémon.
Pokémons can evolve, and Ditto is a Pokémon, but because Ditto can’t evolve, we’ll just throw an Unimplemented
exception. Right? No. This violates the 3rd signature requirement:
A method of a subclass can only throw exceptions that are the same type or a subtype of the exceptions thrown by the method in the base class.
By redesigning the inheritance hierarchy and deriving a subclass that extends the functionality of the superclass, we’re able to separate Pokémons that evolve from those that don’t. You may notice that Blastoise and Ditto are now on different levels of the hierarchy, so we may want to derive yet another subclass for Ditto to extend from, but that is up to the designer and does not concern the SOLID design principles.
I — Interface Segregation Principle
Clients shouldn’t be forced to depend on methods they do not use.
Clients here refer to classes that implement interfaces. The primary goal of this principle is to keep interfaces lean and clean without too many functionalities. You might think all browsers should at least have a search bar, tabs, and bookmarks, so you implement your browser interface accordingly. Out of nowhere, you find out about an awesome, up and coming browser that doesn’t use tabs but instead applies an innovative tags system. You create a class that implements your browser interface and add the tags functionality as an extension, but the class is also now forced to implement your outdated tabs. How do you trim down your interfaces?
Take a Pokémon trainer this time. A trainer can choose a Pokémon at the start of a battle, and switch to a different one in the middle of a battle when the going gets tough. Little did your know, switching out Pokémons in the middle of the battle is a privilege reserved for players and ace trainers, and most normal NPC trainers don’t have that power!
A normal trainer doesn’t have the same authorization to switch Pokémons mid-battle, so we would have to throw an Unimplemented
exception in the switchPokemon
method, or write a stub, or we can segregate the extra functionality from the base interface to a separate, smaller interface.
Now, both classes only implement the interfaces that fit their requirements. Of course on the opposite end of the spectrum, you can have too many overly specific interfaces that defeat the purpose of having interfaces in the first place and can make your program too complex. If an interface isn’t so “fat” it’s forcing clients to implement methods they don’t use, it might not need trimming.
D — Dependency Inversion Principle
High-level classes shouldn’t depend on low-level class- es. Both should depend on abstractions. Abstractions shouldn’t depend on details. Details should depend on abstractions.
What makes a class high-level as opposed to low-level?
- High-level classes are more abstract and contain the business logic and use-cases; what the software does to meet business requirements
- Low-level classes are often “tools” for higher level classes; how the software achieves the business requirements
Generally higher level classes direct lower level classes to do something. Class Timesheet
directs a class CSV
to output some data into a CSV file. The instinct would be to have class Timesheet
depend on class CSV
, but the Dependency Inversion Principle asks us to invert this dependency. What if in addition to CSV, the client now wants the data in JSON as well? We would have to modify the Timesheet
class to accommodate both.
Timesheets and CSV are boring. Let’s talk Pokémon.
A pokeball is a tool a trainer can use. With this design, when we want to introduce new tools a trainer can use, we would have to modify the higher level class Trainer
each time a new tool is introduced.
By creating an interface Item
that both Trainer
and Pokeball
can depend on, we can introduce a new type of item without having to touch the higher level class Trainer
.
Caveats
It goes without saying that real world applications are much more complicated than Pokémon. And the SOLID principles are not hard-and-fast rules that must be followed. They make up a compass that gently reminds you when you might be making poor design decisions to save you some pain down the road; they don’t make your code perfect, but they can alleviate symptoms of rotting design: rigidity, fragility, immobility, and viscosity.
Credits
- The quotes under each principle’s header are taken directly from Refactoring Guru’s Dive Into Design Patterns
- Motivational posters taken from https://www.globalnerdy.com/2009/07/15/the-solid-principles-explained-with-motivational-posters/