S.O.L.I.D.ify Your Software

and stop writing bad code that slows you down

Vi Hsieh
The Zeals Tech Blog
10 min readJul 15, 2022

--

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:

SSingle Responsibility Principle
OOpen/Closed Principle
LLiskov Substitution Principle
IInterface Segregation Principle
DDependency 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.

not closed for modification

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.

the Pokemon class is open for extension but closed for modification

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):

  1. 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 method eat(Cake)
    - A subclass overriding that method can be either eat(Cake) (same type) or eat(Dessert) (a more abstract type)
    - The method cannot be eat(CheeseCake) (a subclass of Cake)
  2. 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 method getCake(): Cake
    - The overriding method can have the following signature:
    getCake(): CheeseCake
    - and not getCake(): Dessert, because the client code can’t be sure the dessert isn’t a cookie.
  3. 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):

  1. 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).
  2. 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.
  3. 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.
  4. 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 an ImmutableArray 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.

Ditto.evolve() throws an exception

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.

The base Pokemon does not evolve.

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!

The Normal Trainer doesn’t satisfy the bloated interface of Trainer

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.

All classes satisfy their respective interfaces

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.

Trainer depends on Pokeball

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.

Trainer depends on abstract Item

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

--

--