Introduction
SOLID is a mnemonic acronym for five principles of object-oriented design that were introduced by Robert C. Martin, who is popularly referred to as Uncle Bob. These principles are intended to help software designers, architects, engineers, and developers create more flexible, maintainable, and scalable software. By following these principles, you can design classes that are easier to test, refactor, reuse, and extend.
The SOLID acronym stands for:
S – Single Responsibility Principle
O – Open-Closed Principle
L – Liskov Substitution Principle
I – Interface Segregation Principle
D – Dependency Inversion Principle
In this article, we will explain each principle individually to understand how it can help you write better code. Additionally, we will add code snippets for each principle to show you how you can apply them in your coding journey, as well as what you should avoid in clean code architecture. To demonstrate the concepts, we will be using the Kotlin programming language developed by JetBrains and Open-source contributors.
Single Responsibility Principle
The Single Responsibility Principle (SRP) is a software design principle that states that every class or module in a program should have a single, well-defined responsibility. This means that a class should have only one reason to change, and it should be responsible for only a single part of the functionality of the program.
Consider the following code snippet as an example of how this principle can be applied in Kotlin:
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
The
data keyword in Kotlin indicates that this class is a data class, which means that it is intended to hold data and does not have any complex behavior. Using this data class, we can create a
UserService class for managing users as shown below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // code to create a user in the database } fun deleteUser(user: User) { // code to delete a user from the database } fun updateUser(user: User) { // code to update a user in the database } fun getUser(id: Int): User { // code to retrieve a user from the database } } |
In this example, the UserService class has a single responsibility: managing users in a database. Each user is represented by the data class User code shared earlier. All of the methods in the UserService class are related to this responsibility and are therefore cohesive. This makes the class easier to understand and maintain, as it is clear that all of the methods in the class are related to a single, well-defined task.
A method that violates the Single Responsibility Principle (SRP) in the
UserService class would be one that is not related to the primary responsibility of the class, which is managing users in a database. For example, consider the following variation of the
UserService class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // code to create a user in the database } fun deleteUser(user: User) { // code to delete a user from the database } fun updateUser(user: User) { // code to update a user in the database } fun getUser(id: Int): User { // code to retrieve a user from the database } fun sendEmail(user: User, subject: String, message: String) { // code to send an email to the user } } |
In this example, the sendEmail method does not relate to the primary responsibility of the UserService class, which is managing users in a database. This method is responsible for sending emails, which is a separate concern from managing users. As a result, this method violates the SRP, as it introduces a second reason for the UserService class to change.
To adhere to the Single Responsibility Principle, it would be better to separate the email-sending functionality into a separate class, such as an EmailService class. This would allow the UserService class to focus on its primary responsibility of managing users, and the EmailService class to focus on its responsibility of sending emails.
You should note that the Single Responsibility Principle is not about the number of methods a class has, but rather about the cohesiveness of the methods and the clear separation of responsibilities within a class.
Open-Closed Principle
The Open Closed Principle (OCP) is a software design principle that states that software entities (such as classes, modules, or functions) should be open for extension, but closed for modification. This means that it should be possible to add new functionality to a class or module without changing its existing code.
Consider the
UserService class from the previous example. Suppose we want to add a new feature to the
UserService class that allows us to search for users by email address. One way to do this would be to add a new method to the
UserService class as highlighted in the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // code to create a user in the database } fun deleteUser(user: User) { // code to delete a user from the database } fun updateUser(user: User) { // code to update a user in the database } fun getUser(id: Int): User { // code to retrieve a user from the database } fun searchUsersByEmail(email: String): List<User> { // code to search for users by email address } } |
This approach works, but it violates the Open Closed Principle, as we had to modify the existing UserService class in order to add the new feature. A better approach would be to use inheritance or composition to extend the functionality of the UserService class without modifying its code.
To achieve this, we could create a new class called
UserSearchService that extends the
UserService class and adds the email search functionality:
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // code to search for users by email address } } |
In this example, the UserSearchService class is open for extension, as it provides additional functionality beyond what is offered by the UserService class. At the same time, the UserService class remains closed for modification, as we did not need to modify its code in order to add the email search feature.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) is a software design principle that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. This means that a subclass should be a valid substitute for its superclass, and should behave in the same way as the superclass when used in the same context.
We will continue the demonstration using the
User and the
UserService classes from the previous examples. To allow the User class to be extended, we use the
open keyword in Kotlin:
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Here is the original
UserService class that uses the
User data class above:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // code to create a user in the database } fun deleteUser(user: User) { // code to delete a user from the database } fun updateUser(user: User) { // code to update a user in the database } fun getUser(id: Int): User { // code to retrieve a user from the database } } |
Suppose we want to create a subclass of
User called
AdminUser that represents users with administrative privileges. We might do this like this:
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
In this example, the
AdminUser class is a valid substitute for the
User class, as it behaves in the same way as the
User class and can be used wherever a
User object is expected. For example, we can use the
AdminUser class with the
UserService class like this:
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
This code is correct, as the AdminUser class is a valid substitute for the User class and can be used in the same way as a User object.
It’s important to note that the Liskov Substitution Principle is about more than just inheritance. It’s about ensuring that objects of a subclass behave in the same way as objects of the superclass, regardless of how the subclass is implemented. For example, if the AdminUser class were to behave differently from the User class in some way, it would violate the Liskov Substitution Principle, as it would not be a valid substitute for the User class.
Interface Segregation Principle
The Interface Segregation Principle (ISP) is a software design principle that states that clients should not be forced to depend on interfacesthey do not use. This means that it is generally a good idea to create small, focused interfaces that do one thing well, rather than creating large, general-purpose interfaces that try to do many things.
Here’s an example of how this principle can be applied by rewriting the
User and
UserService classes from the previous examples:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // code to create a user in the database } override fun deleteUser(user: User) { // code to delete a user from the database } override fun updateUser(user: User) { // code to update a user in the database } override fun getUser(id: Int): User { // code to retrieve a user from the database } } |
In this example, the UserService interface defines four methods that are related to managing users in a database. The DatabaseUserService class implements this interface and provides concrete implementations for these methods.
Suppose we want to add a new feature to the
UserService interface that allows us to search for users by email address. One way to do this would be to add a new method to the
UserService interface:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Your code will not run, unless you also implement this method in all classes that implement the
UserService interface:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // code to create a user in the database } override fun deleteUser(user: User) { // code to delete a user from the database } override fun updateUser(user: User) { // code to update a user in the database } override fun getUser(id: Int): User { // code to retrieve a user from the database } override fun searchUsersByEmail(email: String): List<User> { // code to search for users by email address } } |
While this approach works, it violates the Interface Segregation Principle, as it forces the DatabaseUserService class to implement a method ( searchUsersByEmail ) that it may not need or use.
A better approach would be to create a separate
interface for the email search functionality:
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Now we have separate, small, and focused interfaces, i.e.
UserService and
UserSearchServicethat have a single responsibility. A class requiring all the functionalities of these interfaces can implement them as shown in the code snippet below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // code to create a user in the database } override fun deleteUser(user: User) { // code to delete a user from the database } override fun updateUser(user: User) { // code to update a user in the database } override fun getUser(id: Int): User { // code to retrieve a user from the database } override fun searchUsersByEmail(email: String): List<User> { // code to search for users by email address } } |
This adheres to the Interface Segregation Principle, as it ensures that clients (such as the DatabaseUserService class) are not forced to depend on interfaces they do not use.
To understand this concept better, suppose we have another class called
MemoryUserService that implements the
UserService interface but does not need the email search functionality, we can write the code like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
In this example, the MemoryUserService class only needs to implement the methods defined in the UserService interface, and does not need to worry about the email search functionality. This allows the MemoryUserService class to focus on its primary responsibility of managing users in memory, rather than being forced to implement unrelated functionality.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) is a software design principle that states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. This means that it is generally a good idea to design your software in such a way that high-level components are not tied to specific implementations of low-level components, but rather depend on abstractions (such as interfaces or abstract classes) that can be implemented in different ways.
Let’s look at an example of how this principle can be applied to the
User and
UserService classes used in previous code snippets:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
In this example, the UserService class depends on an abstraction called the UserRepository interface, rather than depending on a specific implementation of a user repository. This allows us to implement the UserRepository interface in different ways, such as with a database or in-memory storage, without affecting the UserService class.
For example, here’s an implementation of the
UserRepository interface that uses a database:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // code to create a user in the database } override fun delete(user: User) { // code to delete a user from the database } override fun update(user: User) { // code to update a user in the database } override fun get(id: Int): User { // code to retrieve a user from the database } } |
Here is another implementation of the
UserRepository interface that uses in-memory storage:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
This makes the system more flexible and maintainable, as it allows us to change the repository implementation without affecting the rest of the system. It also makes it easier to test the UserService class, as we can mock the UserRepository dependency in our tests.
Conclusion
In this article, we talked about the five principles of SOLID code and shared code snippets satisfying each principle. Adhering to these principles can help you design software systems that are more flexible, maintainable, and scalable. However, it’s important to keep in mind that these principles are guidelines, rather than hard and fast rules, and it’s up to the developer to decide when and how to apply them in the context of their specific project. Continue your learning by checking out our blog for more in-depth and up-to-date articles and tutorials on cloud computing and DevOps, software design and development, technology trends to look out for, and more.
Happy Coding!
- Removing Spaces in Python - March 24, 2023
- Is Kubernetes Right for Me? Choosing the Best Deployment Platform for your Business - March 10, 2023
- Cloud Provider of tomorrow - March 6, 2023
- SOLID: The First 5 Principles of Object-Oriented Design? - March 3, 2023
- Setting Up CSS and HTML for Your Website: A Tutorial - October 28, 2022