Today we’re going to discuss Kotlin extension functions. It’s a mechanism that allows adding new functions to existing classes that we can’t normally modify (e.g. because they come from another library). Calling an extension function looks like a regular function call on an object. Kotlin isn’t the first language that offers them. Only in JVM world, they were firstly popularized by Groovy. However, with great power, there comes a great responsibility. In this article, I would like to focus on the abuse of Kotlin extension functions and how to avoid it.

tl;dr;

The very simple rule that protects you from the abuse is simply not using extension functions for classes that you own and are a part of the same project. Such use cases can be easily replaced with other techniques.

Kotlin extension functions in action

In Kotlin, declaring an extension function does not require any special keyword. Instead, we need to include the type of the receiver object in the function name. Below, we can see an extension function that extends Double type to safely compare two floating-point values:

fun Double.closeTo(value: Double, epsilon: Double = 0.00000025): Boolean {
    return Math.abs(this - value) < epsilon
}

val someDouble: Double = 1.3

if (someDouble.closeTo(1.3)) {
   // do something
}

Under the hood, Kotlin compiles extension functions to a static method in Java. In fact, this is how we can call it from Java context: DoubleKt.closeTo(someDouble, 1.3). It is also possible to create them on generic types, or create an extension infix function.

Great power can be abused

Extension functions are a powerful feature. We can create something that looks like a regular function of the given class. What is more – we can do it from any place. However, just like every language feature that hides the true nature of something, we can easily abuse it. There is one particular example that caught my eye while looking at different Kotlin codebases. It is mapping between different representations of the same data, let’s say an entity and DTO. Let’s take a look at the following example entity:

data class Author(
   val id: AuthorId,
   val firstName: String,
   val lastName: String,
   val age: Int,
   val publicationCount: Int
)

At the REST endpoint, we would like to present this data in JSON format, but we don’t also want to use the original entity to decouple the API from the logic. Therefore, we create a data structure that represents the JSON representation of author, and we need a mapper:

@RestController
class AuthorController(private val authorService: AuthorService) {
   @GetMapping("/authors/{id}")
   fun fetchAuthor(@PathMapping("id") val id: String): AuthorJson {
      val author: Author = authorService.fetchAuthor(id)
      return author.toJson()
   }
}

data class AuthorJson(
   val id: String,
   val firstName: String,
   val lastName: String,
   val age: Int,
   val publicationCount: Int
)

fun Author.toJson() = AuthorJson(
   id = id.raw,
   firstName = firstName,
   lastName = lastName,
   age = age,
   publicationCount = publicationCount
)

Why this is an abuse?

At the first sight, the example looks very clean and short. However, as the number of such mapping functions grows, we can observe the following effects:

  • developers start placing new functions in random places – hard to enforce a single convention,
  • the usage place also misleads us about where the source code is located, and how it is related to the original class.

The third negative effect shows up in large codebases. Over time, we get multiple representations of the same thing: the business entity, the JSON-s for different API versions, the database entity, some internal DTO-s… . It is possible to create the same extension function in all those places:

// file 1
fun BusinessEntity.toDto() = BusinessEntityDto(...)

// file 2
private fun BusinessEntity.toDto() = AnotherBusinessEntityDto(...)

// file 3
fun BusinessEntity.toDto() = YetAnotherBusinessEntityDto(...)

Now, let’s notice that we the usage is identical in all the cases: myBusinessEntity.toDto(). But depending on where we call it, it does different things. It also returns different result types! To complicate things even more, some variants can be private or internal. It happened to me once when I contributed to a third-party project. I wasted a lot of time, writing a code for a wrong result type, before I realized that in my part of the code, I have only access to a variant that returns something completely different. For this reason, I consider this example as an abuse of Kotlin extension functions.

In short…

Extension functions can be placed anywhere. It’s very easy to loose control over the location of your logic in larger codebases, if you use them. It can also produce weird side effects that are not possible with regular instance function calls: myObject.functionCall().

Break of execution order

There is also one more issue with creating mappers with extension functions. Imagine that in your controller code, you have to map the request to some internal DTO, and then the result to the response. With Java approach and static functions, it would look like this:

public OutputJson process(InputJson input) {
   return OutputJson.fromDto(logic.process(input.toDto()));
}

One can say that this is not a good code, because you need to read it from right to left to follow the execution order. But let’s see, what happens with extension functions:

fun process(input: InputJson): OutputJson {
   return logic.process(input.toDto()).toJson()
}

Now, to read it properly, we need to start in the middle, move to the left, and then – to the right. That’s right, in the previous version we need to read the code backwards. However, at least we don’t have to change the reading direction, and don’t need to start in the middle.

In short…

If the execution order becomes more chaotic after using extension functions, there’s likely something wrong with how you use them.

Alternative to extension functions

So, what’s an alternative? Simply – don’t throw away your existing experience with OOP. We can easily resolve the mapping problem with explicit mapper objects:

class AuthorMapper {
   fun toDomain(input: AuthorJson) = Author(...)
   fun fromDomain(output: Author) = AuthorResponseJson(...)
}

If we create additional generic interfaces, we can enforce a single naming convention, and also write a general-purpose code that works with every mapper. For small codebases, this is an overkill, but even the small projects like to grow beyond initial expectations. If you want to make the execution order more visible, you can use sequences, reactive streams or even create some DSL.

Consider the following example that solves the mapping problem using OOP techniques. It also uses extension functions, but for a completely different purpose – to combine the third-party API with our custom interfaces. This is an intended use case for extension functions.

interface InputMapper<A, B> {
   fun toDomain(input: A): B
}

interface OutputMapper<A, B> {
   fun fromDomain(output: A): B
}

object MyMapper :
   InputMapper<InputJson, DomainEntity>,
   OutputMapper<DomainResult, OutputJson>
{
   override fun toDomain(input: InputJson) = DomainEntity(...)
   override fun fromDomain(input: DomainResult) = OutputJson(...)
}

fun <T, R> Mono<T>.toDomain(mapper: InputMapper<T, R>) = map { mapper.toDomain(it) }
fun <T, R> Mono<T>.fromDomain(mapper: OutputMapper<T, R>) = map { mapper.fromDomain(it) }

fun process(input: Mono<InputJson>): Mono<OutputJson> = input
   .toDomain(MyMapper)
   .map { service.runLogic(it) }
   .fromDomain(MyMapper)

In short…

Do not throw away your current experience just because a programming language offers new tools.

Conclusion

Over time, I created the following two nice guidelines for using Kotlin extension functions , and avoiding abuse. The starting point was the original use case for them:

  1. extending third-party API with functions that match the responsibilities of those classes,
  2. an element of a larger Kotlin DSL.

As an example, it is okay to extend the type Collection<T> with an additional general-purpose operation (such as mapping to Guava immutable collection). I would not, however, put any functions with business logic, because it would be a violation of single responsibility principle and in turn, making them harder to find. And the basic rule of thumb could be:

Illustration of when to use Kotlin extension functions to avoid abuse, as stated in the description.
If both the receiver class and the function are in the same codebase, we should not use extension functions (unless it’s a part of a larger DSL)

Sample code

Find a complete example on Github: zone84-examples/kotlin-extension-functions

Subscribe
Notify of
guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Artur
Artur
7 months ago

Really useful article.