How to Win Friends and Influence Code with SOLID Principles in Kotlin

Amsavarthan Lv
7 min readFeb 24, 2023

--

Photo by Jo Jo on Unsplash

Have you ever inherited a codebase that made you want to pull your hair out? Or have you ever written code that you knew was going to be a nightmare to maintain? As developers, we’ve all been there.

One of the keys to writing maintainable, flexible, and reusable code is to follow the SOLID principles. In this blog post, we’ll explore how SOLID principles can help you write better code in Kotlin, and we’ll walk through some code examples to show you how to apply these principles in practice.

By the end of this article, you’ll have a solid understanding of the SOLID principles and how they can help you become a better developer. So let’s get started!

S — Single Responsibility Principle

The Single Responsibility Principle (SRP) states that each class or module should have only one responsibility. This means that a class or module should do one thing and do it well. If a class or module has more than one responsibility, it becomes harder to maintain and change over time.

Here’s an example of a class that violates the SRP:

class UserService {
fun registerUser(username: String, password: String) {
// validation logic
// database insert logic
// email sending logic
}
}

This class has three responsibilities — validating user input, inserting the user into the database, and sending an email. This violates the SRP because if any one of these responsibilities needs to be changed, the entire class needs to be modified.

Here’s an example of how to refactor this class to follow the SRP:

class UserValidator {
fun validate(username: String, password: String): Boolean {
// validation logic
}
}

class UserRepository {
fun saveUser(username: String, password: String) {
// database insert logic
}
}

class EmailService {
fun sendEmail(to: String, subject: String, body: String) {
// email sending logic
}
}

class UserService(
private val userValidator: UserValidator,
private val userRepository: UserRepository,
private val emailService: EmailService
) {
fun registerUser(username: String, password: String) {
if (!userValidator.validate(username, password)) {
// handle validation error
return
}

userRepository.saveUser(username, password)

emailService.sendEmail(username, "Welcome to our app", "Thanks for registering!")
}
}

In this refactored code, each class has only one responsibility — validating user input, inserting the user into the database, and sending an email. The UserService class now depends on these three classes instead of having all the responsibilities itself.

O — Open-Closed Principle

The Open-Closed Principle (OCP) states that classes or modules should be open for extension, but closed for modification. This means that you should be able to add new functionality to a class or module without changing its existing code.

Here’s an example of a class that violates the OCP:

class PaymentService {
fun processPayment(paymentMethod: String, amount: Double) {
when (paymentMethod) {
"creditCard" -> {
// credit card processing logic
}
"paypal" -> {
// PayPal processing logic
}
}
}
}

This class violates the OCP because if a new payment method needs to be added, the processPayment method needs to be modified. Instead, we can use the Strategy pattern to make this class extensible:

interface PaymentMethod {
fun processPayment(amount: Double)
}

class CreditCardPayment : PaymentMethod {
override fun processPayment(amount: Double) {
// credit card processing logic
}
}

class PaypalPayment : PaymentMethod {
override fun processPayment(amount: Double) {
// PayPal processing logic
}

class PaymentService(
private val paymentMethod: PaymentMethod
) {

fun processPayment(amount: Double) {
paymentMethod.processPayment(amount)
}

}

In this refactored code, we have created an interface called PaymentMethod that defines a processPayment method. We have then created two classes, CreditCardPayment and PaypalPayment that implement the PaymentMethod interface. Finally, we’ve refactored the PaymentService class to take an instance of PaymentMethod in its constructor, so we can pass in any implementation of PaymentMethod at runtime.

L — Liskov Substitution Principle

The Liskov Substitution Principle (LSP) 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 subclasses should be able to be used in place of their parent classes without breaking the code.

Here’s an example of a class hierarchy that violates the LSP:

open class Shape {
open fun area(): Double = 0.0
}

class Rectangle(
private val width: Double,
private val height: Double
) : Shape() {
override fun area(): Double = width * height
}

class Square(
private val side: Double
) : Shape() {
override fun area(): Double = side * side
}

This class hierarchy violates the LSP because a Square cannot be used in place of a Rectangle - if we try to create a Square with a width and height that are different, we'll get an incorrect area.

Here’s an example of how to refactor this class hierarchy to follow the LSP:

interface Shape {
fun area(): Double
}

class Rectangle(
private val width: Double,
private val height: Double
) : Shape {
override fun area(): Double = width * height
}

class Square(
private val side: Double
) : Shape {
override fun area(): Double = side * side
}

In this refactored code, we’ve created an interface called Shape that defines a area method. Both Rectangle and Square implement this interface, so they can be used interchangeably.

I — Interface Segregation Principle

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they don’t use. This means that interfaces should be designed with the specific needs of their clients in mind, and clients should not be required to implement methods they don’t need.

Here’s an example of a class hierarchy that violates the ISP:

interface Car {
fun startEngine()
fun stopEngine()
fun drive()
fun reverse()
}

class Sedan : Car {
override fun startEngine() { ... }
override fun stopEngine() { ... }
override fun drive() { ... }
override fun reverse() { ... }
}

class SportsCar : Car {
override fun startEngine() { ... }
override fun stopEngine() { ... }
override fun drive() { ... }
override fun reverse() { ... }
}

This class hierarchy violates the ISP because not all clients of the Car interface need to implement the reverse method. For example, a client that only needs to drive the car forward would be forced to implement the reverse method, even though it's not needed.

Here’s an example of how to refactor this class hierarchy to follow the ISP:

interface Car {
fun startEngine()
fun stopEngine()
fun drive()
}

interface ReverseableCar : Car {
fun reverse()
}

class Sedan : Car {
override fun startEngine() { ... }
override fun stopEngine() { ... }
override fun drive() { ... }
}

class SportsCar : ReverseableCar {
override fun startEngine() { ... }
override fun stopEngine() { ... }
override fun drive() { ... }
override fun reverse() { ... }
}

In this refactored code, we’ve split the Car interface into two separate interfaces - Car and ReverseableCar. The Car interface only includes methods that all clients need to implement, while the ReverseableCar interface includes the additional reverse method. Clients that only need to drive the car forward can use the Car interface, while clients that need to drive the car in both directions can use the ReverseableCar interface.

D — Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This means that modules should be designed to depend on abstract interfaces or classes, rather than concrete implementations.

Here’s an example of a class hierarchy that violates the DIP:

class UserService {
private val userRepository = UserRepository()

fun createUser(user: User) {
userRepository.save(user)
}
}

class UserRepository {
fun save(user: User) {
// save the user to the database
}
}

This class hierarchy violates the DIP because the UserService depends directly on the UserRepository class, which is a low-level module. If we later decide to change the way users are stored (e.g. switch to a different database or a web service), we'll need to change the UserService class as well.

Here’s an example of how to refactor this class hierarchy to follow the DIP:

interface UserRepository {
fun save(user: User)
}

class UserService(
private val userRepository: UserRepository
) {
fun createUser(user: User) {
userRepository.save(user)
}
}

class DatabaseUserRepository : UserRepository {
override fun save(user: User) {
// save the user to the database
}
}

In this refactored code, we’ve created an abstract UserRepository interface that defines the methods that the UserService needs. We've also created a DatabaseUserRepository class that implements the UserRepository interface and provides the concrete implementation of the save method.

Now, the UserService class depends only on the abstract UserRepository interface, rather than the concrete UserRepository class. This makes the code more flexible and easier to maintain, because we can swap out the DatabaseUserRepository class with a different implementation (e.g. a web service implementation) without needing to change the UserService class.

Advice to Fellow developers

In summary, the SOLID principles are a set of guidelines for writing flexible, maintainable, and reusable code. By following these principles, we can write code that is easier to test, easier to maintain, and less prone to bugs.

While the examples in this article were written in Kotlin, the SOLID principles are applicable to any object-oriented programming language. If you’re new to SOLID, start by focusing on the Single Responsibility Principle, which is the most important of the five principles. Once you’ve mastered SRP, you can move on to the other principles and start applying them to your own code.

SOLID is not a set of hard-and-fast rules — it’s a set of guidelines that can help you write better code. As with any set of guidelines, there will be times when you need to break the rules in order to achieve your goals. But by following the SOLID principles whenever possible, you’ll be able to write code that is more robust, more maintainable, and more reusable.

Thanks for reading this article. You can connect with me on LinkedIn, Twitter, and Instagram.

If you found this article helpful, please recommend it by hitting the clap icon as many times you wish 👏 Let’s enable each other with the power of knowledge.

--

--

Amsavarthan Lv

Your friendly neighbourhood Android developer 🧑🏻‍💻