SOLID Principles in Kotlin: The Secret Sauce to Writing Clean Maintainable Code

SOLID Principles in Kotlin: The Secret Sauce to Writing Clean Maintainable Code

Ensuring proper code structure is vital for software development. Ignoring this crucial aspect can result in numerous difficulties when it comes to scaling and maintaining your project. One way to guarantee well-structured and scalable software is by following SOLID principles. This approach will allow you to continuously add new features without having to worry about breaking the code.

What are the SOLID principles?

SOLID is an acronym for S-Single Responsibility, O-Open/ Closed Principle, L-Liskov Substitution Principle, I-Interface Segregation, and D-Dependency Inversion Principle. They are principles for designing software in a way that promotes scalability, flexibility and maintainability.

Single Responsibility Principle(SRP)

The Single Responsibility Principle is a fundamental concept in software engineering. It stipulates that each class should perform only one task or function to ensure the smooth running of an application and simplify testing. By separating authentication and sales functionalities in a grocery app into distinct classes or modules rather than combining them into one, it becomes easier to test individual classes and extend the app's functionality.

class GroceryAuth(){
    fun login()
    fun logout() 
}

class GrocerySales(){
    fun pay(amount:String)
}

If a change needs to be made to the sales module, it can be done in isolation without affecting other features.

Open/Closed Principle (OCP)

This principle states that classes should be open for extension but closed for modification. It indicates that a class should be constructed in a manner that allows for expansion without altering the preexisting code. Modifying the class directly to incorporate new features is not advisable. Observe the code provided below.

  • Violates the principle
//open to enable extension
open class Animal(val name:String)

class Dog():Animal("dog")
class Cat(): Animal("cat")

class AnimalSound(){
    fun makeSound(animal:Animal):String{
        return when(animal.name){
            "dog" -> "bark"
            "cat" -> "meow"
            else  -> throw IllegalArgumentException("Unsupported animal")
        }
    }
}

fun main(){
    //instance of the Cat class
    var cat = Cat()
    var dog = Dog() 
    val animalSound = AnimalSound()
    println(animalSound.makeSound(cat)) // prints meow meow
    println(animalSound.makeSound(dog)) // prints bark bark
}
  • Follows the principle
interface Animal{
    fun makeSound():String
}

//The cat implements the Animal interface
class Cat(): Animal{
     override fun makeSound():String{
        return "meow"
     }
}
/**
* No need for the 'when' expression here
*/
class AnimalSound(){
    fun createSound(animal:Animal):String{
        return animal.makeSound()
    }
}

fun main(){
    var cat = Cat()
    val animalSound = AnimalSound()
    println(animalSound.createSound(cat))
}

It can be seen from the second code snippet that rather than having a when expression when a new instance of the Animal class is needed, as seen in the first code, the Animal is made an interface with the sound() method, such that when a new instance is needed, it simply implements the interface and its sound() method. The benefit of applying this principle is improved scalability. Making components closed for modification also makes them reusable and extensible.

Liskov's Substitution Principle (LSP)

This principle emphasizes the importance of inheritance and polymorphism in software development. It states that an object of a class should be created in a way that allows it to be interchangeable with the object of its superclass while still retaining its functionalities without breaking the code. Consider a Circle class that extends a Shape class and its methods, when an object of this class is created, it should be replaceable with that of the Shape class.

//super-class
abstract class Shape(){
   abstract fun area():Double
}
//sub-class
class Circle(private val radius:Double):Shape(){
   override fun area():Double{
      return 3.14 * radius * radius
   }
}

fun main(){
   val circle:Shape = Circle(3.0)
   println(circle.area()) //Prints 28.26
}

Because the Circle class is a subclass of the Shape class, it can be declared as an object of its superclass Shape without affecting the code and still retaining its implementation.

Interface Segregation (ISP)

This principle works in hand with the single responsibility principle(SRP). It states that no class should be forced to implement methods that it does not need. Consider the SRP example; if a class implements an interface with different responsibilities, it will be forced to implement all its methods even when some of them are not needed. It can lead to unnecessary exceptions and some boilerplate codes. So, what is the solution? The methods should be grouped into smaller interfaces depending on their functionalities. Each interface should contain methods that are closely related and are most likely to be used when the interface is implemented. For example, an interface for user authentication, another for sales, and so on.

Dependency Inversion(DIP)

This principle underscores the importance of abstraction in object-oriented programming. It states that high-level components/classes and low-level ones should not depend on each other; they should depend on abstractions. This abstraction can be in the form of an interface. Consider a class A depends on a repository class; by following DIP rather than class A depending directly on the repository class, it depends on the abstraction of that class, ensuring that new features can be added without affecting the current code, and it is easier to test classes independently.

//abstraction
interface Repository{
   fun getItem():String
}

//low-level module/implementation
class RepositoryImpl():Repository{
   override fun getItem():String{
      return "dependency inversion"
   }
}

//high-level module
class A(private val repository:Repository){
   fun getItem():String{
       return repository.getItem() //returns 'dependency inversion'
   }
}

The above code shows that both the low-level and high-level classes depend on the abstraction, and the abstraction does not depend on its implementation.

Conclusion

Having highlighted the importance of each principle, it is always a good practice to follow them during a software development process, as they help developers write clean, concise and more efficient code. I hope this was helpful and that you can now apply these principles to your software development process. Thank you for reading. Cheers!