1. Introduction
In this article we will figure out what exactly SOLID principles are and most importantly- we will check out a few Kotlin examples to get an even better understanding. I am totally aware, that there are plenty of other articles already covering this topic. Nevertheless, I personally believe that your time spent on discovering different examples and approaches presented in various ways is always a great investment.
So, without any further ado, let’s learn the SOLID principles the Codersee way.
2. What Are SOLID Principles?
Basically, SOLID is an acronym for five design principles, which main goal is to make software designs easier to read, maintain and work with. It’s been introduced around 2004, by Michael Feathers. Nevertheless, it’s just a subset of many principles promoted by Robert C. Martin, also known as “Uncle Bob”.
With that in mind, let’s unveil what exactly this acronym stand for:
- Single-responsibility principle
- Open–closed principle
- Liskov substitution principle
- Interface segregation principle
- Dependency inversion principle
Please don’t worry if they look a bit unclear or mysterious at the moment. We’ll take a deep dive into each one in the next chapters.
Make a real progress thanks to practical examples, exercises, and quizzes.
- 64 written lessons
- 62 quizzes with a total of 269 questions
- Dedicated Facebook Support Group
- 30-DAYS Refund Guarantee
3. Single-Responsibility Principle
3.1. Theory
The Single-responsibility principle states:
A class should have only one reason to change.
In other words, a class should have only one reason to exist and moreover- be responsible for one thing. Although it seems pretty straightforward, the judgment itself is oftentimes really subjective and may vary between programmers.
To put it simply, we should separate concerns and design classes in such a way, that changes requested by one person or a group within the organization won’t affect other functionalities.
In practice, if we ask ourselves: “what exactly is this class responsible for” and we can’t answer it without using “and”, then it’s quite possible that we’ve broken the rule.
3.2. Violation Example
With all of that in mind, let’s see the following example:
class User( val id: Int, val name: String, val email: String, val phoneNumber: String ) class AdminDashboardService { fun sendNotification(user: User) { println("Preparing email content") println("Sending email to ${user.email}") } fun deleteUser(user: User) { println("Deleting user with id ${user.id} from the database") } }
For the purpose of simplicity, the above functions are just printing some text to the output. Nevertheless, in the real-life scenarios the sendNotification() would be responsible for preparing an HTML content for the email and sending it to the given email address. On the other hand, the deleteUser() would perform an SQL query deleting the record from connected database.
In such a case, we can clearly see that our service is responsible for 3 different things. Moreover, let’s imagine that:
- the marketing team requested a change in the e-mail template because of the branding change
- the CTO requested an email automation provider change
- the data team requested a change in SQL query
We can clearly see that each of these requests may easily affect theoretically unrelated business functions.
3.3. Solution
As the next step, let’s see how the refactored code could look like:
class UserAccountService { fun deleteUser(user: User) { println("Deleting user with id ${user.id} from the database") } } class EmailContentProvider { fun prepareContent() { println("Preparing email content") } } class EmailNotificationService { fun sendNotification(user: User) { println("Sending email to ${user.email}") } }
This time, we can clearly see that each class is responsible for exactly one thing (has one reason to change).
3.4. Benefits
Finally, let’s summarize with the most important benefits that come with well designed, isolated classes with one responsibility:
- most importantly- any bug introduced to the particular class affects less parts of the system (and organization as a whole)
- additionally, the number of merge conflicts is reduced when multiple people are working with the codebase
- whatsoever, it can introduce much better readability than the monolithic classes
4. Open-Closed Principle
4.1. Theory
As the next step, let’s take a look at the open-closed principle:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
This time, the principle itself is a bit more explanatory. In simple words, when designing our structures we should always keep in mind that requirements may change in the future. Under those circumstances, the entities we create should be easily extendable without the need to modify them.
4.2. Violation Example
Let’s imagine that we were asked to implement a logging feature for custom headers objects. However, the logger itself should avoid printing values of particular headers: header-one an header-two, as they contain vulnerable data.
Given these requirements, we’ve decided put the excluded headers inside the companion object and expose a log() function, which will make sure that we won’t log any vulnerable data:
data class Header( val name: String, val value: String ) class HeadersLogger { companion object { private val forbiddenHeaders = setOf("header-one", "header-two") } fun log(header: Header) { if (!forbiddenHeaders.contains(header.name)) println(header.value) } }
Everything works perfect, but what if at some point client asks us to exclude additional headers?
Given the above code, we have no other option, than to either modify the HeadersLogger class to contain the additional header or to implement a new one. Definitely the above code is neither open for extension nor closed for modification.
4.3. Solution
How could we change the above code to follow the above principle? Well, let’s see the following snippet:
class HeadersLogger(val additionalForbiddenHeaders: Set<String> = setOf()) { companion object { private val forbiddenHeaders = setOf("header-one", "header-two") } fun log(header: Header) { val headerName = header.name if (!forbiddenHeaders.contains(headerName) && !additionalForbiddenHeaders.contains(headerName) ) println(header.value) } }
As we can see, such a small change gives us much more flexibility. Although we’ve predefined two headers, which should be always skipped, we can easily add new without modification of the existing class. Furthermore, we can extract this code as a common dependency and reuse it in multiple parts of the system.
4.4. Benefits
Identically, let’s see the most important advantages of the open-closed principle:
- first of all, reusability and flexibility. We can use already existing codebase to implement new features or apply changes without the need of reinventing the wheel
- moreover, the above advantage is a great time-saver
- additionally, modification of existing classes might introduce unwanted behavior everywhere they’ve been used. With the open-closed principle, we can easily avoid this risk
5. Liskov Substitution Principle
5.1. Theory
Nextly, let’s check the definition of the Liskov substitution principle:
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.
Well, we’ve probably all seen easier definitions in our lives. To put it simply, classes deriving from the base class should behave in the same manner as the superclass. Most importantly, if we would decide to replace the base class with the derived one, it should not break the existing functionality.
5.2. Violation Example
Let’s say that we’ve got two types of users in the app: standard and admin. Both types of the account can be created. Nevertheless, the admin account can not be deleted in our app (for instance it can be done only from an external one).
Given that, let’s have a look at the example:
interface Managable{ fun create() fun delete() } class StandardUser : Managable{ override fun create() { println("Creating Standard User account") } override fun delete() { println("Deleting Standard User account") } } class AdminUser : Managable{ override fun create() { println("Creating Admin User account") } override fun delete() { throw RuntimeException("Admin Account can not be deleted!") } }
At first glance, everything seems to be working, as expected. The interface methods has been overriden and we can compile it successfully. Additionally, we’ve applied the requirements and the delete() implementation prohibits from deleting the Admin account.
Nevertheless, we can clearly spot, that replacement of the interface invocation with the derived method will definitely break the flow. Let’s imagine that someone else wrote a generic test for Managable interface. Given the contract, tester assumed that invoking the delete() should remove the data from the external database regardless of the user type. Unfortunately, that’s not the case here and the test will work as expected with the StandardUser instance and fail with the exception for the AdminUser.
5.3. Solution
There are plenty of possibilities when it comes to the above code. Let’s see the example one:
interface Creatable { fun create() } interface Deletable { fun delete() } class StandardUser : Creatable, Deletable { override fun create() { println("Creating Standard User account") } override fun delete() { println("Deleting Standard User account") } } class AdminUser : Creatable { override fun create() { println("Creating Admin User account") } }
This time, we’ve introduced more specific contract with two, separate interfaces. Definitely, the hypothetical substitution won’t break the flow.
5.4. Benefits
Given the above, what does the Liskov substitution principle bring to the table?
- when our subtypes conform behaviorally to the supertypes in our code, our code hierarchy becomes cleaner
- furthermore, people working with the abstraction (interface in our case) can be sure that no unexpected behavior occurs
6. Interface Segregation Principle
6.1. Theory
We’ve covered already three rules, so it’s time to check the interface segregation principle:
Many client-specific interfaces are better than one general-purpose interface.
Just like the open-closed principle, this one is pretty self-explanatory. Basically, it means that we should always lean towards the more specific, self-descriptive interfaces, instead of the monolithic ones.
6.2. Violation Example
You might already noticed that we’ve already seen this violation in the chapter five:
interface Managable{ fun create() fun delete() }
First of all, people working with the above interface can not be sure what exactly are its boundaries at first glance. What exactly does manage mean in terms of our application? Is it just creating and deleting people, or should suspending also count in this case?
Furthermore, frequent violation of this rule might may lead to creating a monolithic monster. And trust me, we don’t want to get back to such a code at some point in the future.
6.3. Solution
Similarly, we’ve already introduced a possible solution in the previous chapter:
interface Creatable { fun create() } interface Deletable { fun delete() }
This time, we don’t have any doubts when working with the above interfaces. We can definitely assume that all classes implementing Creatable will be responsible for the creation and in case of Deletable- for removal.
6.4. Benefits
Applying the interface segregation principle in our designs has plenty of perks. Let’s check a few of them:
- well designed interfaces help us to follow the other principles. It’s much easier to take care of single responsibility and as we could see- Liskov substitution
- additionally, precise contract described by the interface makes the code less error-prone
- whatsoever, it really improves readability of the hierarchy and the codebase itself
7. Dependency Inversion Principle
7.1. Theory
Finally, let’s check out the dependency inversion principle:
Depend upon abstractions, [not] concretions.
If you’ve ever been working with Spring Boot, then you might be familiar with this rule (dependency injection helps us to follow this principle). Basically, when following this rule we make the high-level modules independent of the implementation details. When working with Kotlin, this might be easily achieved with interfaces. On the other hand, we might find function types passed as an arguments useful, as well.
7.2. Violation Example
Just like in the previous examples, let’s see the code, which does not apply the above principle:
class EmailNotificationService { fun sendEmail(message: String) { val formattedMessage = message.uppercase() println("Sending formatted message: $formattedMessage") } }
As we can see, the EmailNotificationService will send a formatted to upper case message. Although everything is working as expected, we can spot that this method depends on the specific implementation.
7.3. Solution
Let’s modify the service a bit:
class EmailNotificationService { fun sendEmail(message: String, formatter: (String) -> String) { val formattedMessage = formatter.invoke(message) println("Sending formatted message: $formattedMessage") } }
This time, we made the EmailNotificationService independent of the formatter implementation. The only thing that this service cares about is that the formatter has to return a String value. As we can see, applying this principle gives us much more flexibility.
7.4. Benefits
Finally, let’s enumerate the dependency inversion principle benefits:
- allows the codebase to be easily expanded and extended with new functionalities
- furthermore, it improves reusability
8. Summary
And that would be all for this post covering the SOLID principles with Kotlin examples. As I mentioned in the beginning- there are plenty of great articles already created about this topic and each author has his own way of describing things. Nevertheless, I really hope that this one will help you to understand these principles even better, no matter whether you are a beginner or an advanced programmer.
On the other hand, if you are interested in other materials, I can recommend you a pictured explanation on Medium.
As always, please let me know about your thoughts in the comment section, or by using the contact form.
2 Responses
Probably on 4.3 have an error,
you not declare additionalForbiddenHeaders con the constructor.
Hi!
I thought I fixed that already but apparently forgot to publish the change.
Thank you for being cautious!