SOLID Design Principles in Kotlin (Part 1)

Firuze Gümüş
12 min readMay 3, 2024

--

Hello everyone!

In this article, we’ll explore how to apply SOLID Design Principles in Kotlin.

The SOLID principles consist of five design guidelines developed to make software designs more understandable, flexible, and maintainable. Made popular by Robert C. Martin (Uncle Bob), the original definitions of the SOLID principles are briefly as follows:

Single Responsibility Principle (SRP): A class should have only one reason to change.

Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering the correct behavior of the program.

Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.

Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

Let’s start with the first principle, the Single Responsibility Principle.

1. Single Responsibility Principle

This principle states that a class should be responsible for only one type of functionality or one aspect of the program. By focusing on a single type of responsibility, the likelihood of changes leading to unexpected consequences is reduced, making the code easier to understand and maintain.

Let’s use examples to make it clearer.

class Game {
fun login(){}
fun signup(){}
fun move(){}
fun fire(){}
fun rest(){}
fun getHighScore(){}
fun getName(){}
}

In the given example, let’s examine the functions within the Game class:

  • The login and signup functions deal with user authentication and authorization.
  • The move, fire and rest functions are related to in-game actions and movements.
  • The getHighScore and getName functions provide some information about the player.

These diverse responsibilities can make the class complex and difficult to manage. Additionally, any change could affect different parts of the class, increasing the likelihood of errors. To make this class compliant with the Single Responsibility Principle, we need to separate the responsibilities.

class GameSession{
fun login(){}
fun signup(){}
}

class Player{
fun getHighScore(){}
fun getName(){}
}

class PlayerActions{
fun move(){}
fun fire(){}
fun rest(){}
}

As above, you could refactor the original Game class into multiple classes to separate responsibilities:

  • GameSession: For authentication and authorization operations like login and signup.
  • PlayerActions: For in-game actions like move, fire, and rest.
  • Player: To maintain player-specific information such as getHighScore and getName.

Here’s another example:

class PersonClass {
fun setName(){}
fun setAddress(){}
fun setPhoneNumber(){}
fun save(){}
fun load(){}
}
  • Person: This class represents information about a person and contains data-related methods such as setName(), setAddress() and setPhoneNumber().
  • PersonRepository: This class handles data storage and retrieval operations like save() and load(). Interaction with a database, file system or other storage mechanisms is the responsibility of this class.
class Person {
fun setName(){}
fun setAddress(){}
fun setPhoneNumber(){}
}

class PersonRepository{
fun save(){}
fun load(){}
}

Another example is:

class ShoppingCartClass{
fun add(){}
fun remove(){}
fun checkOut(){}
fun saveForLater(){}
}
  • add() and remove(): Related to cart management
  • checkOut(): Involving the purchase process and payment.
  • saveForLater(): A different functionality, like saving products for later use.

Similarly, to make this class compliant with the SRP, we can separate different responsibilities as follows:

class ShoppingCart{
fun add(){}
fun remove(){}
}

class CheckoutProcess{
fun checkOut(){}
}

class Wishlist{
fun saveForLater(){}
}
  • ShoppingCart: Contains the basic shopping cart management functions like add() and remove().
  • CheckoutProcess: Manages the payment and purchase process through checkOut().
  • Wishlist: Includes the functionality of saving products for later use with saveForLater().

It’s important to remember that the Single Responsibility Principle doesn’t mean that a class should only do one thing. It means that the different functions a class performs should be closely related, indicating high cohesion.

2. Open Closed Principle

This principle emphasizes that a module or class should be extendable without modifying its source code. This is typically achieved through polymorphism, abstraction, or interface-based design, allowing new functionality to be added without changing existing code.

Let’s create a scenario using the Decorator Pattern, which I think is a great example of the Open/Closed Principle (OCP).

Imagine a Cafe that sells plain coffee..

interface Coffee {
fun cost(): Double
fun description(): String
}

class BasicCoffee : Coffee {
override fun cost() = 5.0
override fun description() = "Coffee"
}


fun main() {
val coffeeOrderManager = CoffeeOrderManager()
coffeeOrderManager.addDecorator(BasicCoffee())
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")
}

Let’s say we’ve decided to offer our customers the option to add milk, chocolate or sugar to their coffee, with each addition having an extra cost.

interface Coffee {
fun cost(): Double
fun description(): String
}

class BasicCoffee : Coffee {
override fun cost() = 5.0
override fun description() = "Coffee"
}

abstract class CoffeeDecorator(private val coffee: Coffee) : Coffee {
override fun cost() = coffee.cost()
override fun description() = coffee.description()
}

class MilkDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 3.0
override fun description() = super.description() + ", Milk"
}

class SugarDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 0.5
override fun description() = super.description() + ", Sugar"
}

class ChocolateDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 2.5
override fun description() = super.description() + ", Chocolate"
}

class CoffeeOrderManager {
private var coffee: Coffee = BasicCoffee()

fun addDecorator(decorator: CoffeeDecorator) {
coffee = decorator
}

fun getTotalCost(): Double {
return coffee.cost()
}

fun getDescription(): String {
return coffee.description()
}

}

fun main() {
val coffeeOrderManager = CoffeeOrderManager()
val milkCoffee = MilkDecorator(BasicCoffee())
coffeeOrderManager.addDecorator(milkCoffee)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")
val sugarCoffee = SugarDecorator(BasicCoffee())
coffeeOrderManager.addDecorator(sugarCoffee)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val sugarMilkCoffee = SugarDecorator(milkCoffee)
coffeeOrderManager.addDecorator(sugarMilkCoffee)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val chocolateDecorator = ChocolateDecorator(BasicCoffee())
coffeeOrderManager.addDecorator(chocolateDecorator)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val chocolateMilkDecorator = ChocolateDecorator(sugarMilkCoffee)
coffeeOrderManager.addDecorator(chocolateMilkDecorator)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

}

The output from our code would be as follows:

Price: 8.0, Description: Coffee, Milk
Price: 5.5, Description: Coffee, Sugar
Price: 8.5, Description: Coffee, Milk, Sugar
Price: 7.5, Description: Coffee, Chocolate
Price: 11.0, Description: Coffee, Milk, Sugar, Chocolate

As we can see, we can add the desired extras to our coffee. When we want to add a new extra item, all we have to do is create a decorator for it. That’s it!

Let’s add cream to the mix. All we need to do is create a CreamDecorator.

interface Coffee {
fun cost(): Double
fun description(): String
}

class BasicCoffee : Coffee {
override fun cost() = 5.0
override fun description() = "Coffee"
}

abstract class CoffeeDecorator(private val coffee: Coffee) : Coffee {
override fun cost() = coffee.cost()
override fun description() = coffee.description()
}

class MilkDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 3.0
override fun description() = super.description() + ", Milk"
}

class SugarDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 0.5
override fun description() = super.description() + ", Sugar"
}

class ChocolateDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 2.5
override fun description() = super.description() + ", Chocolate"
}

class CreamDecorator(coffee: Coffee) : CoffeeDecorator(coffee) {
override fun cost() = super.cost() + 1.5
override fun description() = super.description() + ", Cream"
}

class CoffeeOrderManager {
private var coffee: Coffee = BasicCoffee()

fun addDecorator(decorator: CoffeeDecorator) {
coffee = decorator
}

fun getTotalCost(): Double {
return coffee.cost()
}

fun getDescription(): String {
return coffee.description()
}

}

fun main() {
val coffeeOrderManager = CoffeeOrderManager()
val milkCoffee = MilkDecorator(BasicCoffee())
coffeeOrderManager.addDecorator(milkCoffee)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")
val sugarCoffee = SugarDecorator(BasicCoffee())
coffeeOrderManager.addDecorator(sugarCoffee)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val sugarMilkCoffee = SugarDecorator(milkCoffee)
coffeeOrderManager.addDecorator(sugarMilkCoffee)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val chocolateDecorator = ChocolateDecorator(BasicCoffee())
coffeeOrderManager.addDecorator(chocolateDecorator)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val chocolateMilkDecorator = ChocolateDecorator(sugarMilkCoffee)
coffeeOrderManager.addDecorator(chocolateMilkDecorator)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

val creamDecorator = CreamDecorator(chocolateDecorator)
coffeeOrderManager.addDecorator(creamDecorator)
println("Price: ${coffeeOrderManager.getTotalCost()}, Description: ${coffeeOrderManager.getDescription()}")

}

The new output of the code would be as follows:

Price: 8.0, Description: Coffee, Milk
Price: 5.5, Description: Coffee, Sugar
Price: 8.5, Description: Coffee, Milk, Sugar
Price: 7.5, Description: Coffee, Chocolate
Price: 11.0, Description: Coffee, Milk, Sugar, Chocolate
Price: 9.0, Description: Coffee, Chocolate, Cream

As another example of the Open/Closed Principle, let’s consider using the Abstract Factory Design Pattern.

Suppose a car dealership wants to develop an application where users can create custom orders based on various features of the cars. Let’s assume our dealership sells Tesla vehicles.

We can write our code as follows:

abstract class Car {
abstract fun assemble()
abstract fun paint()
abstract fun test()
}

class TeslaModelS : Car() {
override fun assemble() = println("Assembling Tesla Model S")
override fun paint() = println("Painting Tesla Model S")
override fun test() = println("Testing Tesla Model S")
}

class TeslaModel3 : Car() {
override fun assemble() = println("Assembling Tesla Model 3")
override fun paint() = println("Painting Tesla Model 3")
override fun test() = println("Testing Tesla Model 3")
}

class TeslaModelX : Car() {
override fun assemble() = println("Assembling Tesla Model X")
override fun paint() = println("Painting Tesla Model X")
override fun test() = println("Testing Tesla Model X")
}

class TeslaModelY : Car() {
override fun assemble() = println("Assembling Tesla Model Y")
override fun paint() = println("Painting Tesla Model Y")
override fun test() = println("Testing Tesla Model Y")
}

class DependentCarFactory {
fun createCar(model: String): Car? {
return when (model) {
"Model S" -> TeslaModelS()
"Model 3" -> TeslaModel3()
"Model X" -> TeslaModelX()
"Model Y" -> TeslaModelY()
else -> {
println("Error: Invalid Tesla model")
null
}
}
}
}

fun main() {
val factory = DependentCarFactory()
val car1 = factory.createCar("Model S")
car1?.apply {
assemble()
paint()
test()
}
val car2 = factory.createCar("Model 3")
car2?.apply {
assemble()
paint()
test()
}
}

Then the dealership informed us that it would start selling TOGG vehicles and asked us to update the application.

If you’re not familiar with TOGG, I’m leaving a picture of it below:)

Here’s how our code looks after the update:

abstract class Car {
abstract fun assemble()
abstract fun paint()
abstract fun test()
}
class ToggT10X : Car() {
override fun assemble() = println("Assembling Togg T10X")
override fun paint() = println("Painting Togg T10X")
override fun test() = println("Testing Togg T10X")
}
class ToggT10F : Car() {
override fun assemble() = println("Assembling Togg T10F")
override fun paint() = println("Painting Togg T10F")
override fun test() = println("Testing Togg T10F")
}
class TeslaModelS : Car() {
override fun assemble() = println("Assembling Tesla Model S")
override fun paint() = println("Painting Tesla Model S")
override fun test() = println("Testing Tesla Model S")
}
class TeslaModel3 : Car() {
override fun assemble() = println("Assembling Tesla Model 3")
override fun paint() = println("Painting Tesla Model 3")
override fun test() = println("Testing Tesla Model 3")
}
class DependentCarFactory {
fun createCar(brand: String, model: String): Car? {
val car = when (brand) {
"Togg" -> {
when (model) {
"T10X" -> ToggT10X()
"T10F" -> ToggT10F()
else -> {
println("Error: Invalid Togg model")
null
}
}
}
"Tesla" -> {
when (model) {
"Model S" -> TeslaModelS()
"Model 3" -> TeslaModel3()
else -> {
println("Error: Invalid Tesla model")
null
}
}
}
else -> {
println("Error: Invalid brand")
return null
}
}
car?.apply {
assemble()
paint()
test()
}
return car
}
}
fun main() {
val factory = DependentCarFactory()
val toggCar = factory.createCar("Togg", "T10X")
toggCar?.apply {
assemble()
paint()
test()
}
val teslaCar = factory.createCar("Tesla", "Model S")
teslaCar?.apply {
assemble()
paint()
test()
}
}

However, we noticed that the code became more complex and prone to errors every time the dealership added a new brand. It also violated the Open/Closed Principle (OCP). To address this, we decided to redesign our code using the Abstract Factory Design Pattern.

First, let’s separate the unique aspects of each brand. Tesla can have its own models, and TOGG can have theirs..

abstract class Car {
abstract fun assemble()
abstract fun paint()
abstract fun test()
abstract fun chooseWheels(wheelType: String)
abstract fun chooseSeats(seatType: String)
abstract fun choosePaintColor(color: String)
}
class ToggT10X : Car() {
override fun assemble() = println("Assembling Togg T10X")
override fun paint() = println("Painting Togg T10X")
override fun test() = println("Testing Togg T10X")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Togg T10X: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Togg T10X: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Togg T10X: $color")
}
class ToggT10F : Car() {
override fun assemble() = println("Assembling Togg T10F")
override fun paint() = println("Painting Togg T10F")
override fun test() = println("Testing Togg T10F")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Togg T10F: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Togg T10F: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Togg T10F: $color")
}
class TeslaModelS : Car {
override fun assemble() = println("Assembling Tesla Model S")
override fun paint() = println("Painting Tesla Model S")
override fun test() = println("Testing Tesla Model S")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Tesla Model S: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Tesla Model S: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Tesla Model S: $color")
}
class TeslaModel3 : Car {
override fun assemble() = println("Assembling Tesla Model 3")
override fun paint() = println("Painting Tesla Model 3")
override fun test() = println("Testing Tesla Model 3")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Tesla Model 3: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Tesla Model 3: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Tesla Model 3: $color")
}
interface CarFactory {
fun createCar(model: String): Car?
}
class ToggFactory : CarFactory {
override fun createCar(model: String): Car? {
return when (model) {
"T10X" -> ToggT10X()
"T10F" -> ToggT10F()
else -> {
println("Error: Invalid Togg model")
null
}
}
}
}
class TeslaFactory : CarFactory {
override fun createCar(model: String): Car? {
return when (model) {
"Model S" -> Tesla Model S()
"Model 3" -> Tesla Model 3()
else -> {
println("Error: Invalid Tesla model")
null
}
}
}
}
class CarProductionManager(val factory: CarFactory) {
fun buildCar(model: String) {
val car = factory.createCar(model)
car?.apply {
assemble()
paint()
test()
chooseWheels("Sport")
chooseSeats("Leather")
choosePaintColor("Red")
} ?: println("Unable to build the car: Model '$model' is invalid.")
}
}
fun main() {
val toggFactory = ToggFactory()
val teslaFactory = TeslaFactory()
val toggManager = CarProductionManager(toggFactory)
toggManager.buildCar("T10X")
val teslaManager = CarProductionManager(teslaFactory)
teslaManager.buildCar("Model S")
}

Yesss, with our code in this state, when we try to include Audi models in our application, we’ll see that we haven’t modified the existing code but only extended it, as shown below.

abstract class Car {
abstract fun assemble()
abstract fun paint()
abstract fun test()
abstract fun chooseWheels(wheelType: String)
abstract fun chooseSeats(seatType: String)
abstract fun choosePaintColor(color: String)
}
class ToggT10X : Car() {
override fun assemble() = println("Assembling Togg T10X")
override fun paint() = println("Painting Togg T10X")
override fun test() = println("Testing Togg T10X")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Togg T10X: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Togg T10X: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Togg T10X: $color")
}
class ToggT10F : Car() {
override fun assemble() = println("Assembling Togg T10F")
override fun paint() = println("Painting Togg T10F")
override fun test() = println("Testing Togg T10F")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Togg T10F: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Togg T10F: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Togg T10F: $color")
}
class TeslaModelS : Car() {
override fun assemble() = println("Assembling Tesla Model S")
override fun paint() = println("Painting Tesla Model S")
override fun test() = println("Testing Tesla Model S")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Tesla Model S: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Tesla Model S: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Tesla Model S: $color")
}
class TeslaModel3 : Car() {
override fun assemble() = println("Assembling Tesla Model 3")
override fun paint() = println("Painting Tesla Model 3")
override fun test() = println("Testing Tesla Model 3")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Tesla Model 3: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Tesla Model 3: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Tesla Model 3: $color")
}
class AudiA4 : Car() {
override fun assemble() = println("Assembling Audi A4")
override fun paint() = println("Painting Audi A4")
override fun test() = println("Testing Audi A4")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Audi A4: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Audi A4: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Audi A4: $color")
}
class AudiQ5 : Car() {
override fun assemble() = println("Assembling Audi Q5")
override fun paint() = println("Painting Audi Q5")
override fun test() = println("Testing Audi Q5")
override fun chooseWheels(wheelType: String) = println("Choosing wheels for Audi Q5: $wheelType")
override fun chooseSeats(seatType: String) = println("Choosing seats for Audi Q5: $seatType")
override fun choosePaintColor(color: String) = println("Choosing paint color for Audi Q5: $color")
}
interface CarFactory {
fun createCar(model: String): Car?
}
class ToggFactory : CarFactory {
override fun createCar(model: String): Car? {
return when (model) {
"T10X" -> ToggT10X()
"T10F" -> ToggT10F()
else -> null
}
}
}
class TeslaFactory : CarFactory {
override fun createCar(model: String): Car? {
return when (model) {
"Model S" -> TeslaModel S()
"Model 3" -> Tesla Model 3()
else -> null
}
}
}
class AudiFactory : CarFactory {
override fun createCar(model: String): Car? {
return when (model) {
"A4" -> AudiA4()
"Q5" -> AudiQ5()
else -> null
}
}
}
class CarProductionManager(val factory: CarFactory) {
fun buildCar(model: String) {
val car = factory.createCar(model)
car?.apply {
assemble()
paint()
test()
chooseWheels("Sport")
chooseSeats("Leather")
choosePaintColor("Red")
} ?: println("Unable to build the car: Model '$model' is invalid.")
}
}
fun main() {
val toggFactory = ToggFactory()
val teslaFactory = TeslaFactory()
val audiFactory = AudiFactory()
val toggManager = CarProductionManager(toggFactory)
toggManager.buildCar("T10X")
val teslaManager = CarProductionManager(teslaFactory)
teslaManager.buildCar("Model S")
val audiManager = CarProductionManager(audiFactory)
audiManager.buildCar("A4")
}

Great, we didn’t change the existing code; we just extended it to accommodate new requirements.

I tried to explain these two principles as simply as possible with concrete examples. I hope you found it useful. Let’s discuss the ‘L’, ‘I’, and ‘D’ letters of SOLID in the next article. See you in the next one.

Keep coding!

--

--