SOLID Design Principles In Kotlin
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. SOLID is an acronym for five design principles in Object-Oriented software development intended to make software designs more understandable, flexible and maintainable. These five principles are described by Robert C. Martin.
SOLID principles are briefly as follows:
S — Single Responsibility Principle (known as SRP)
O — Open/Closed Principle
L — Liskov’s Substitution Principle
I — Interface Segregation Principle
D — Dependency Inversion Principle
1. Single-Responsibility Principle
The single-responsibility principle states:
A class should have only one reason to change.
The Single Responsibility Principle indicates that every class should have one and only one responsibility. In other words, if our class does more than one responsibility, we should split the functionalities and responsibilities in various classes. Therefore, this makes the class more robust.
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 three 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.
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).
Benefits
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
2. O — Open/Closed Principle
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.
The Open/Closed principle states “software entities such as classes, components, and modules should be open for extension but closed for modification." That is, such an entity can allow its behavior to be extended without modifying its source code.
Let’s imagine that we were asked to implement a logging feature for custom header objects. However, the logger itself should avoid printing the values of particular headers: header-one and header-two, as they contain vulnerable data.
Given these requirements, we’ve decided to 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 perfectly, but what if at some point the 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 implement a new one. Definitely, the above code is neither open for extension nor closed for modification.
How could we change the above code to follow the above principle? Well, let’s see the following snippet:
class HeadersLogger(val forbiddenHeaders: 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.
Benefits
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
3. Liskov substitution principle
Next, 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.
This principle indicates that parent classes should be easily substituted with their child classes without changing the behavior of software. It means that a sub-class should override the methods from a parent class, which does not break the functionality of the parent class.
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.
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 a more specific contract with two, separate interfaces. Definitely, the hypothetical substitution won’t break the flow.
Benefits
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
4. Interface segregation principle
We've already covered three rules, so it’s time to check the interface segregation principle:
Many client-specific interfaces are better than one general-purpose interface.
The interface-segregation principle indicates classes that implement interfaces, should not be forced to implement methods they do not use. This principle is related to the fact that a lot of specific interfaces are better than one general-purpose interface. In addition, this is the first principle, which is used in interface, all the previous principles applies on classes.
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 violations of this rule may lead to the creation of a monolithic monster. And trust me, we don’t want to get back to such a code at some point in the future.
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.
Benefits
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
5. Dependency inversion principle
Finally, let’s check out the dependency inversion principle:
Depend upon abstractions, [not] concretions.
This principle suggests that high level modules should not depend on low level. So, both should depend on abstractions. Furthermore, abstraction should not depend on details. Details should depend upon abstractions.
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.
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 care about is that the formatter has to return a String value. As we can see, applying this principle gives us much more flexibility.
Benefits
allows the codebase to be easily expanded and extended with new functionalities
furthermore, it improves reusability
Conclusion
Writing code according to SOLID principles will make our software more readable, understandable, flexible, and maintainable.