SOLID Principles in Kotlin: From Theory to Practice
\n\nSOLID is an acronym for five fundamental principles of object-oriented programming formulated by Robert Martin. These principles help create flexible, maintainable, and scalable code. In this article, we will thoroughly examine each SOLID principle with examples from real-world Kotlin development.
\n\nUnderstanding SOLID is critically important for any Kotlin developer, especially when working with frameworks like Spring Boot or Ktor. These principles form the foundation of clean architecture and design patterns.
\n\n1. Single Responsibility Principle (SRP)
\n\nEssence: Each class should have only one reason to change. In other words, a class should be responsible for a single functionality.
\n\nIn Kotlin, this principle is especially important due to support for functional style. Consider a bad example:
\n\n// ❌ SRP violation: the class handles both logging and database operations\nclass UserService {\n fun saveUser(user: User) {\n // Logging\n println("Saving user: ${user.name}")\n // Business logic\n validateUser(user)\n // Database operations\n database.insert(user)\n }\n}\n\n\nLet's fix the situation by separating responsibilities:
\n\n// ✅ Correct approach: each responsibility in a separate class\nclass Logger {\n fun log(message: String) = println("[LOG] $message")\n}\n\nclass UserValidator {\n fun validate(user: User): Boolean = user.name.isNotBlank()\n}\n\nclass UserRepository {\n fun save(user: User) { /* saving to DB */ }\n}\n\nclass UserService(\n private val logger: Logger,\n private val validator: UserValidator,\n private val repository: UserRepository\n) {\n fun saveUser(user: User) {\n logger.log("Saving user: ${user.name}")\n if (validator.validate(user)) {\n repository.save(user)\n }\n }\n}\n\n\n2. Open-Closed Principle (OCP)
\n\nEssence: Classes should be open for extension but closed for modification. New functionality should be added through inheritance or composition without changing existing code.
\n\nIn Kotlin, sealed classes and interfaces are excellent for implementing OCP:
\n\n// ❌ OCP violation: adding a new shape type requires modifying the class\nclass AreaCalculator {\n fun calculate(shape: Any): Double = when (shape) {\n is Circle -> 3.14 * shape.radius * shape.radius\n is Rectangle -> shape.width * shape.height\n else -> throw IllegalArgumentException()\n }\n}\n\n\nCorrect implementation using polymorphism:
\n\n// ✅ Correct approach: new shapes are added without modifying existing code\ninterface Shape {\n fun area(): Double\n}\n\nclass Circle(val radius: Double) : Shape {\n override fun area() = Math.PI * radius * radius\n}\n\nclass Rectangle(val width: Double, val height: Double) : Shape {\n override fun area() = width * height\n}\n\n// Now you can add a triangle without changing AreaCalculator\nclass Triangle(val base: Double, val height: Double) : Shape {\n override fun area() = 0.5 * base * height\n}\n\nclass AreaCalculator {\n fun calculate(shape: Shape) = shape.area()\n}\n\n\n3. Liskov Substitution Principle (LSP)
\n\nEssence: Objects of a base class should be replaceable with objects of derived classes without breaking the program. A subclass should not weaken preconditions or strengthen postconditions.
\n\nConsider the classic example with a rectangle and a square:
\n\n// ❌ LSP violation: a square cannot replace a rectangle\nopen class Rectangle(\n open var width: Int,\n open var height: Int\n) {\n fun area() = width * height\n}\n\nclass Square(sid