Explaining SOLID through Code
Man is a consumer and a producer, a pattern interpreter and pattern producer; thus the code we produce and consume ought to have characteristics that favor such activities. So, how do we produce easily consumed code? We do so by following patterns and code principles that enable us to increase encapsulation (information hiding), modularity, maintainability, readability, etc., all properties of a well-designed system.
This post briefly covers SOLID, a set of design principles that provide guidance on structuring object-oriented programs. I'll provide examples of code that doesn't follow SOLID and then pointers of how to refactor the code to follow SOLID. All the examples are implemented in Kotlin but should be comprehensible to anyone with OOP programming experience.
Table of Contents
Background
SOLID is a mnemonic acronym for the following design principles:
- S - Single responsibility principle
- O - Open/closed principle
- L - Liskov substitution principle
- I - Interface segregation principle
- D - Dependency inversion principle
While some of the principles sound complicated, they are quite easy to understand and readily applicable to your code.
Code Example
The example we'll be covering is an application that enables people to register as users or non-users. Users are modeled as the class User
and non-users as the class NonUser
. The User
class can buy items and is eligible for discounts, whereas the NonUser
class can only purchase items. As the application is intended for production use, we'll include logging and persisting data to a database.
The application is first implemented in the "wrong" way and then step by step refactored to follow the SOLID principles.
The Wrong Way
S - Single Responsibility Principle
A class should have only a single responsibility (i.e., changes to only one part of the software's specification should be able to affect the specification of the class).
The Wrong Way
If we look at the User
class we can see that the buy
method has multiple responsibilities and thus violates the SOLID principles:
- It's responsible for writing exceptions to a file
- It instantiates a database object
The Right Way
We can adhere to the single responsibility principle by delegating the responsibility to separate classes and instead call the methods of those classes:
O - Open/Closed Principle
Software entities such as classes, modules, functions, etc. should be open for extension, but closed for modification.
The Open/closed principle relates to structuring code such that when we add functionality, we should opt for writing new classes, modules or functions instead of modifying existing ones.
The Wrong Way
The function calcPriceAfterDiscount
in the User
class goes against this principle since it encourages modification of conditional
statements when we want to add another user type.
The Right Way
To follow the open/closed principle, we will refactor the calcPriceAfterDiscount
function and create new subclasses. This way, whenever we create new user types, we extend code instead of modifying existing code.
L - Liskov Substitution Principle
Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
The Wrong Way
In our example, we have a parent class User
and a child class NonUser
. Imagine that we create a list of users and then call the calcPriceAfterDiscount
method on each user. The aforementioned code would compile but would fail during runtime since the NonUser
class doesn't have a valid (throws an exception) implementation for the calcPriceAfterDiscount
method and thus we violate the Liskov substitution principle.
The Right Way
There are numerous ways to resolve this, and one of those ways is to use interfaces and create a new class for the NonUser
instead of inheriting from the User
class.
I - Interface Segregation Principle
It's better to have many client-specific interfaces than one general-purpose interface.
The Wrong Way
The class DatabaseHandler
implements two methods from the IDatabaseHandler
interface: read
and write
. Similarly, the DatabaseHandlerV2
class implements three of the methods, read
, write
and query
. However, since the query
method isn't required for the DatabaseHandler
class, the interface segregation principle isn't followed.
The Right Way
To follow the interface segregation principle we merely split the IDatabaseHandlerV2
interface into distinct interfaces and let each class specify the interfaces it needs:
D - Dependency Inversion Principle
Depend on abstractions, not on concretions.
The Wrong Way
In this example, we can see that the NonUser
class depends on a concrete method of the class File
(belonging to the standard library) and not on an abstract method.
The Right Way
If we instead pass in an interface for the logger, then NonUser
wouldn't be depending on a concretion but on an abstraction (the abstraction of logging something). In the future, if we want to change log medium (from file to database for instance), we can take a look at the abstraction instead of the class concretion.
The Right Way
So in summary, when we combine all the refactored code, we get SOLID compliant code:
Summary
Following the SOLID principles leads to maintainable code and is not applicable only to OOP but functional programming as well.