The SOLID Principles and Kotlin

SOLID refers to a set of five Object Orientated Design(OOD) principles, originally complied by Robert Martin that can be followed in order to design more scalable, flexible and maintainable codebases. If you're looking for an acronym that you should know as a software engineer, look no further. In this article will cover each principle and how it can help, with examples in Kotlin.

  • S - Single Responsibilty Principle
  • O - Open-Closed Principle
  • L - Liskov substitution Principle
  • I - Interface segregation Principle
  • D - Dependancy Inversion Principle

Single responsibility principle

A class should have one, and only one, reason to change.

This principle is based on the fact that a class or module should only be concerned with one aspect of a program, or be responsible for one thing. Requirements change and software evolves all the time. When this happens, classes, modules and functions must reflect that change. The more a class is concerned with, or more responsibilities it had, the more it must be changed. Changing such classes can be time consuming and difficult, often lead to side affects. Let’s have a look at some kotlin:

class Robot(val name: String, val type: String) {

   fun greet() {
       println("Hello my name is $name, and I am a $type robot")
   }
}

Currently, this class is doing two things. Firstly, it’s representing our Robot entity and holding state for a name and type, and secondly, it’s concerned with how our robot is communicating. In this case, our robot isn’t very advanced and just prints to communicate.

data class Robot(val name: String, val type: String)

class RobotPrinter {
   fun greet(robot: Robot) {
       val name = robot.name
       val type = robot.type
       println("Hello my name is $name and I am a $type robot")
   }
}

Now, after some minor refactoring, we have two classes with more specific tasks. We still have our Robot class, however, the functionality of greet has been moved to a separate class RobotPrinter. Without writing too much more code we’ve managed to decouple our entity from its presentation logic, which will benefit us in the long term. We’ve also been able to use a data class for our robot, a wonderful feature of kotlin.

Open-Closed Principle

You should be able to extend a classes behavior, without modifying it.

The second SOLID Principle is the Open-Closed Principle. This means that classes, modules and methods should be open for extension but closed for modification. We do this by creating abstractions, in languages such as kotlin we can use interfaces, this abstraction should then be injected where needed. The aim of this is to drive a modular design. Let's look at an example.

class Network {
   private val uri = URI("https://robot-hq.com")

   fun broadcast(message: String) {
       val httpClient = HttpClient.newHttpClient()
       val body = HttpRequest.BodyPublishers.ofString(message)
       val request = HttpRequest.newBuilder(uri).POST(body).build()
       httpClient.send(request, HttpResponse.BodyHandlers.discarding())
   }
}

So looking at the above class, if we ever wanted to change how our Network broadcasts messages, we’ll have to change this class. What if we want to introduce a different means of communication, such as RPC? Or what if we want to format the message in a particular way? Let's try and clean this up.

interface Network {
   fun broadcast(message: String)
}

class HttpNetwork: Network {
   private val uri = URI("https://robot-hq.com")

   override fun broadcast(message: String) {
       val httpClient = HttpClient.newHttpClient()
       val request = HttpRequest.newBuilder(uri).POST(HttpRequest.BodyPublishers.ofString(message)).build()
       httpClient.send(request, HttpResponse.BodyHandlers.discarding())
   }
}

With only a small refactor we’ve made the code more modular. If we want to add WebSockets, that easy, just write a new class that implements Network. What if we want to use RPC or some other method? No problem, just write a new class. This means that any class dependant on Network will not need to change when we create new classes derived from Network.

Liskov Substitution Principle

If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.

Essentially, we should be able to replace any instance of a parent class with that of a child class without anything breaking. When getting started with object-orientated programming, referring to the phrase “is a” to help determine relationships between classes can be helpful. This SOLID Principle helps us ensure we do this properly.

Let’s we have the following code. Someone has decided to follow the Open-Closed Principle in order to make the code more modular, we can add as many different robots as we like with easy (Hurray for the Open-Closed Principle).

abstract class Robot {
   abstract fun goToLocation(lat: Double, long: Double)
   abstract fun jump()
}

However, later on, we decide to start shipping heavy robots(to deal with larger tasks). So we have this kotlin class:

class HeavyRobot: Robot() {
   override fun jump() {
   }
...
}

Can you see the issue? The method doesn’t do anything as this is a heavy robot, when we follow the Open-Closed Principle, we want our derived classes to work the same. So when our developer wants the robots to jump, we’d be left with a set of robots just sitting around, not doing much. This is what happens when we fail to follow the Liskov Substitution Principle, things stop working as we expect. Let’s see how we’d fix this.

abstract class Robot {
   abstract fun goToLocation(lat: Double, long: Double)
}
abstract class LightweightRobot: Robot() {
    abstract fun jump()
}

What we’ve done is moved the jump method into another derived class. Now, our HeavyRobot can inherit from Robot and we will have a much easier time getting the robots to where they need to be.

Interface Segregation Principle

no client should be forced to depend on methods it does not use

The Interface Segregation Principle sounds pretty simply right? ISP violations can sneak into your system over time as features are added and requirements change. Codebases that violate this principle tend to be a bit tough to change as a lot of side effects can occur due to the larger interfaces and classes that implement them. What we’re aiming for is a few smaller interfaces, for specific tasks, rather than larger more generic ones.

interface Robot {
   fun goToLocation(lat: Double, long: Double)
   fun wave()
}

Looks harmless right? However, what if one day we decide to write an implementation like so:

class StationaryRobot: Robot {
   override fun goToLocation(lat: Double, long: Double) {
   }

   override fun wave() {
       println("👋")
   }
}

We have an implementation that doesn’t implement goToLocation, a clear violation of the Interface Segregation Principle, therefore the Robot interface is not a great abstraction. What we can do to remedy this is split the Robot interface into two smaller interfaces. Let’s say MobileRobot and WavingRobot.

interface WavingRobot {
   fun wave()
}

interface MobileRobot {
    fun goToLocation(lat: Double, long: Double)
}

By breaking up the monolithic interface, we’re decoupling moving from waving and separating the responsibilities. If left unattended, interfaces like these can grow over time and cause a lot of headaches later on, splitting them up early will make it much easier in the long run.

Dependency Inversion Principle

Depend on abstractions, not on concretions.

  • High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces).
  • Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

This may sound like a lot, but let's explain it with kotlin.

class BeerBot {
   fun dispenseBeer() {
       println("Dispensing 🍺")
   }
}
class WineBot {
   fun dispenseWine() {
       println("Dispensing 🍷")
   }
}
class RoboPub {
   private val wineBot = WineBot()
   private val beerBot = BeerBot()

   fun dispenseDrinks(){
       wineBot.dispenseWine()
       beerBot.dispenseBeer()
   }
}

So what we have here is our higher level class RoboPub depending on our low level, concrete classes WineBot and BeerBot, causing this class to violate the Dependency Inversion Principle. In addition to this, dispenseBeer and dispenseWine are details coupled with their classes, meaning that we'd need to change this before we add an abstraction. What if we want our pub to have robots that dispense other drinks? In its current state, we’d have to change the RoboPub class, which violates the Open-Closed Principle. Let’s refactor this.

interface DrinksBot {
  fun dispense()
}

class BeerBot: DrinksBot {
   override fun dispense() {
       println("Dispensing 🍺")
   }
}

class WineBot: DrinksBot{
   override fun dispense() {
       println("Dispensing 🍷")
   }
}

What we’ve done here is introduce an abstraction DrinksBot and refactored our concrete classes to implement this. These classes are now interchangeable, we can also add more implementations with ease, similar to the Open-Closed Example. We still have one refactor left:

class RoboPub(private val drinksBots: List<DrinksBot>) {

   fun dispenseDrinks() {
       drinksBots.forEach { it.dispense() }
   }
}

So now, rather than the RoboPub creating instances of the robots, we use Dependency Injection to provide the RoboPub with the robots it needs to function. Notice we’re using our abstraction DrinksBot rather than a concrete class, therefore completely decoupling our high level RoboPub from the concrete classes and their details. This means we'd be able to add all kinds of implementations and our RoboPub wouldn't change at all.

Wrapping Up

In this article, we’ve talked a lot about the five SOLID Principles. Hopefully, these will help you structure your next project and even refactor your existing ones. After following these you should find your code is more manageable, easier to read and easier to change. I should point out, these are principles, and sometimes it’s very difficult to implement them all perfectly, sometimes it might just not be feasible. In these cases, common sense prevails. Please feel free to reach out with any questions or feedback.