1. Introduction

Robert C. Martin formulated SOLID principles in order to bring some order to the chaos of enterprise software. Over the years, they were interpreted every which way, and the promise of OOP for many a programmer turned rather sour. There are numerous jokes ridiculing Java’s way of designing and coding things. So are SOLID principles still alive and relevant, or should we abandon them in favor of more pragmatism?

In this tutorial, we’ll see that these rules still apply, although the scope of their applicability needs a severe review.

2. Single Responsibility

The Single Responsibility principle is perhaps the least controversial one. Nobody would argue that their class or method should do ten things at once. It seems obvious that the support of the source code is much easier if changing one block of code changes only one aspect of the program behavior.

However, it’s often difficult to define what a “single aspect” is. Consider the following:

data class Employee(
    val id: UUID,
    val firstName: String,
    val secondName: String,
    val title: String,
    val salary: BigDecimal
) {
    fun save() {
        TODO("Save Employee to the database")
    }

    companion object {
        fun loadFromDb(id: UUID): Employee {
            TODO("Go to DB and create an Employee object")
        }
    }
}

This code only deals with Employee. However, are our database operations part of one domain or not? How to deal generally with a very common OOP practice of having data and logic which operates on such data in the same class?

The truth is that the behavior is much more pliable and error-prone than the data and, as such, will change much more often. Therefore, it makes sense to separate the behavior into a service class and have the data class dumb:

data class PureEmployee(
    val id: UUID,
    val firstName: String,
    val secondName: String,
    val title: String,
    val salary: BigDecimal
)

class EmployeeService(dataSource: DataSource) {
    fun upsert(employee: PureEmployee) {
        TODO("Save Employee to the database")
    }

    fun findById(id: UUID): PureEmployee {
        TODO("Go to DB and create an Employee object")
    }
}

The same thought process applies to functions with side effects. A good rule is to either keep the function pure (i.e., no side-effects at all, referentially transparent) or else make the side-effect the only thing this function does so that its user is acutely aware of the consequences:

class EmployeeService(dataSource: DataSource) {
    // ...
    fun raiseSalary(employee: PureEmployee, raise: BigDecimal): PureEmployee =
        employee.copy(salary = employee.salary + raise)
}

// Somewhere in the controller code

service.raiseSalary(employee, raise)
  .let { service.upsert(it) }

Overall, the scope of design improvements should be much narrower. OOP focused too much on big modules and their relationships, while inside these modules, things became quite unreadable. If we concern ourselves with the concepts on the method level, we might achieve better readability.

3. Open for Extension, Closed for Modification

With the lower level of details in mind, let us look at the second letter of SOLID. In fact, we can argue that all higher-order functions embody Open/Closed principle very firmly. We may extend the behavior of a map {} function whichever way we need, but the original library code will stay unchanged:

// Here we return a list of Strings
departments.map { it.toString() }

// And here - a list of Ints
departments.map { it.employeeCount }

In the context of enterprise software development, the cases of class reusability are scarce. First of all, the domain classes are modeled to reflect their domain and, as such, are quite specialized in their function. Second, there are classes that are abstract enough to be useful elsewhere, but extracting them into a distributable library requires extra effort. Overall, an average class represents too big a chunk of logic to fit elsewhere. However, on a method level, the patterns often emerge. It’s therefore, the task of the engineer to spot these patterns and generalize them into higher-order functions, thus making code shorter and, by introducing higher-level concepts, more understandable:

class TraditionalEmployeeService(private val employeeRepository: EmployeeRepository) {
    fun handleRaise(id: UUID, raise: BigDecimal) =
        employeeRepository.findById(id)
          .copy(salary = salary + raise)
          .let { employeeRepository.upsert(it) }
    
    fun handleTitleChange(id: UUID, newTitle: String) =
        employeeRepository.findById(id)
          .copy(title = newTitle)
          .let { employeeRepository.upsert(it) }
}

But if there are many fields like that, finding and saving get repetitive. Instead, we may tease out the common part and customize the rest:

class FunctionalEmployeeService(private val employeeRepository: EmployeeRepository) {
    private fun handleChange(id: UUID, change: PureEmployee.() -> PureEmployee) {
        employeeRepository.findById(id)
          .change()
          .let { employeeRepository.upsert(it) }
    }

    fun handleRaise(id: UUID, raise: BigDecimal) =
        handleChange(id) { copy(salary = salary + raise) }

    fun handleTitleChange(id: UUID, newTitle: String) =
        handleChange(id) { copy(title = newTitle) }
}

4. Liskov’s Substitution

Liskov’s principle basically says that all descendants of an interface should be interchangeable. This allows the engineer to change the behavior of a program without changing how it transforms the types. If we have a functional sequence:

fun f(a: A): B = TODO()
fun g(b: B): C = TODO()
fun h(a: A): C = g(f(a))

then if we change B for its descendant B’, we still get C for the result type. That, however, mostly implies classes A, B, and C has some non-trivial behavior. As we discussed in section 2., mixing data and behavior isn’t something we should undertake lightly.

On the other hand, if in this sequence we change function g() instead but leave its signature unchanged, we still keep the type transformation sequence. In that case, g'() function may be considered a descendant from the same functional interface. And, in fact, library functions such as map {} will take all the functions that take one argument and produce one result – that’s actually a definition of a functional interface:

fun interface G {
    fun invoke(b: B): C
}

fun H(a: A, f: (A) -> B, g: G) = g(f(a))

5. Interface Segregation

With the level of support provided for functions in Kotlin, this particular principle is really easy to follow. Indeed, we can create as many standalone functions as we need:

internal fun createDataSource(): DataSource = TODO("Provide a datasource")

Such functions may implement a functional interface as well.

6. Dependency Inversion

Kotlin lends itself very readily to the Dependency Inversion principle. Unlike Java, we may define in one line several constructor versions at once:

class VeryComplexService(
    private val properties: Properties = Properties(),
    private val employeeRepository: EmployeeRepository = EmployeeRepository(createDataSource()),
    private val transformer: G = G { C() }
)

As we can collate property declaration and constructor arguments together, it’s really easy to specify all the dependencies in the constructor.

What’s more, Kotlin forbids extending classes by default. Only interfaces are open for extension. This is also in line with the Dependency Inversion Principle, as it makes engineers define proper abstract interfaces or explicitly break the principle.

7. Conclusion

SOLID principles can be useful, but it’s necessary to adjust their interpretation according to the industry experience. Functional programming and OOP aren’t incompatible or mutually exclusive. They’re concepts of different levels.

The Single Responsibility principle is just “divide and conquer”, which we can encounter in all areas, from education to psychology. The Open-Close rule makes us think in terms of mass production, not one-off constructs. Before we construct our software from classes and modules, we have to construct it from functions, and here functional programming becomes very useful indeed. Liskov’s principle and Interface Segregations teach us to make our software pieces small and durable, and a function is the smallest piece of software. The Dependency Inversion talks about these pieces being easily detachable from one another.

In this tutorial, we tested SOLID principles against the new language features and ensured that they can still guide us even if we write Kotlin. The code from the examples can be found over on GitHub.