My Thoughts on Kotlin: Perspectives after 4 years

Reading Time: 24 minutes

Often, when explaining to other devs that Masset uses Kotlin as our primary backend language, I receive inquisitive and questioning faces. “The Android language? On your backend? That seems odd… Have you considered rewriting it in Rust?” 🤨 

In response, I’ve often found myself struggling to elaborate what it is that I specifically like about Kotlin and why we choose it as our de-facto language. For better or worse, we techies are a pretty judgemental bunch when it comes to technology choices.

On paper, Kotlin (as a backend language) falls victim to many common downsides. Slower compile times, JVM restrictions, single-company backing, etc. But time and time again I find myself just saying, “I don’t know, it just feels so productive“. Kotlin just resonates with how we work at Masset. We just want to get stuff done, it does that. And I’ve heard other devs say the same.

Having used Kotlin for over 4 years of my career now, I felt like I needed a better explanation. So for the last 3 months, every time I’ve had an “aha moment” that related to Kotlin, I’ve written it down. Both good and bad.

After doing this for a while, I’ve come to the conclusion of what I suspected all along: there isn’t really a singular, special, or unique thing that I like about Kotlin. My enjoyment stems from an aggregation of lots of small, and not even necessarily unique, things. But when they are all put together, it just feels enjoyable.

For anyone that might stumble their way onto this post from the internet: understand that previous to Kotlin, my day-to-day for 15 years was Java and Javascript. Today it’s Kotlin and Typescript. During that time, I’ve also written decent-sized projects (10k+ LOC) in Rust, Go, Typescript, C#, C, PHP, and Python. I would never call myself an expert in those languages and ecosystems, but I’ve seen enough of them that I feel like I have fair exposure.

However, there will naturally be a JVM-slant on my comparisons here because of my background. I fully acknowledge that the items in this list are in no way unique to Kotlin. I’m aware that many other languages have the same or equivalent features. But in this exercise, I’m trying to describe the things I like (and don’t like) based on my personal experience.

So what are those things? I’ve included a small list of what stood out below. Here are my randomly recorded thoughts on Kotlin, categorized simply as the Good, Bad, and the Ugly, in no particular order.

The Good

null Safety Leads to Defensive Programming

Ah, null safety. Ask someone what the benefit of Kotlin over Java is and this will probably be their first response. “No more NullPointerException!”, they’ll say, like that’s a major victory for mankind.

I’ll admit, when I first saw presentations on Kotlin (years before I started using it), null safety felt underwhelming. I mean, how often do you really run into NullPointerException‘s? I mean, yes they happen. And yes you’ll probably curse yourself at some point when one happens in production. But you do a quick fix, write a quick unit/integration test so it doesn’t regress, and you move on. Right?

What’s hard to explain about null safety in Kotlin that doesn’t come through in introductory videos is that it forces you into a defensive programming position. It’s not just that it prevents exceptions from being thrown it’s that it forces you to actively think about error conditions and what you are going to do in them. And as you actively think about error conditions, it changes the way you approach the structure of your code.

Again, it’s hard to say what this feels like in practice without trivializing it in simple examples. But I often find myself being nudged more gracefully to handle error cases when needed and and nudged to know when I don’t need to.

  • “Oh, mimeType on this HTTP response is String, not String?, I guess I don’t need to worry about a missing value”
  • “Even though this database query should always return a value, the result is technically nullable? So how would I handle it if it doesn’t?
  • “The result of this function shouldn’t ever be null. But I have the possibility of errors here. How can I represent the response so that I don’t return null?”

To be clear, this type of nudging isn’t Kotlin-specific. There are lots of languages that provide null-safety and provide similar value adds. But on the flipside, there are lots of languages that don’t, or try and bolt it on after-the-fact. Having it as a builtin feels so much nicer.

?: and ?. Are nice for control flow

Because nulls are known everywhere, it’s much easier to take action on them. A fun side effect of that, paired with fact that most things in Kotlin are expressions, the ?: and ?. operators are really nice for control flow.

In simple cases, it’s an easy early return:

val record = repo.findById(id) ?: return
Kotlin

Or default value assignment:

val headers = httpClient.get(url)?.response?.headers ?: emptyMap()
Kotlin

In others, you can pair ?. with let to conditionally apply logic (jooq query as an example):

val filters = // filters object with optional filters
val results = jooq.selectFrom(table)
  .where(USER_ID.eq(id))
  .also { q ->
    // only apply the start date filter if its non-null 
    filters.before?.let { q.and(START_DATE.lt(it)) }
  }
Kotlin

Or because most (all?) things in Kotlin are expressions, you can pair the ?: and ?. operator with expressions and manage nulls very cleanly.

val record = repo.findById(id) ?: run { 
  // do null case logic to calculate default
  doSideEffect()
  
  // the last line is automatically returned from the lambda
  calculateDefaultResult()
}
Kotlin

I find that it tends to lend towards more concise control flows, with less branching if statements.

The standard library does (pretty much) everything

One of the first and most prominent things that I enjoy about Kotlin is the standard library. As I develop, it always feels like there an easy library function for the everything that I do. It’s really hard to explain how liberating this feels. Often it’s not quite as expressive as something like Python, but the option is there without doing it yourself.

Having a function for “everything” is obviously bit subjective. I’m not talking everything in terms of built-in http-servers and big data processors. Instead, I mean it in terms of “everything I expect to need to build common algorithms”. I feel like I’ve said this so many times before, but Kotlin feels like it found the right balance. It has the things I’d expect it to and doesn’t have the things I wouldn’t.

As an example, here’s Day 1 of Advent of Code from 2023 in Kotlin to spur your imagination. Note the use of various helper functions, readLines, firstOrNull, isDigit, lastOrNull, sumOf, let, findAnyOf, findLastAnyOf, and mapNotNull. These are just a few samples of many, many helper functions that can ease processing workflows like this.

// day 1 part 1
File("input.txt").readLines()
  .map { line ->
      line.firstOrNull { it.isDigit() } to line.lastOrNull { it.isDigit() }
  }
  .map { "" + it.first + it.second }
  .sumOf { it.toLong() }
  .let { println(it) }

// day 1 part 2
File("input.txt").readLines()
  .map { line ->
      val firstMatch = line.findAnyOf(validWords)
      val lastMatch = line.findLastAnyOf(validWords)
      wordsToNumbers[firstMatch?.second] to wordsToNumbers[lastMatch?.second]
  }
  .mapNotNull { "" + it.first + it.second }
  .sumOf { it.toLong() }
  .let { println(it) }
Kotlin

These type of library functions are everywhere in Kotlin. The best way I can describe it is that it feels like the standard language authors authors actually write application code. Their library functions actually hit the majority of the points that I run into every day.

Expressions help simplify

As noted previously, with everything being expressions, it’s easy to manage data flow and eliminate branches you’d normally have for readability. Often these don’t add a ton of value by themselves, but add up as they simply lots of small pieces of complex functions.

// try/catch is an expression
val success = try { 
  getResult() 
  true
} 
catch (e: Exception){
  log.error(e)
  false
}

// if/else is an expression
val result = if(condition) op1() else op2()

// when statements are expressions
when (user.role) {
    "admin" -> println("${user.name} is an admin.")
    "editor" -> println("${user.name} is an editor.")
    "viewer" -> println("${user.name} is a viewer.")
    else -> error("Undefined role: ${user.role}")
}

// lots of other things
Kotlin
Ecosystem

If you hate the Java ecosystem, and want to use Kotlin on the JVM, just don’t.

Now, if you’re still reading, you might welcome a bit more nuanced opinion. Kotlin can 100% exist in a void, fully outside the influence of other ecosystems. But for my primary use case (backend services), for all intents and purposes, you are living in the JVM/JDK ecosystem. Sure, there are Kotlin-specific implementations of things. You might use Ktor. But more than likely, you’re going to use something like Spring, Micronaut or Quarkus that support both Java and Kotlin. No matter what you do, if you’re deploying on the JVM, the majority of your 3rd party libraries will be Java-based libraries that have tacked on Kotlin support.

For some, this might be considered a detriment. I consider it a win. For better or worse, there are enough companies using Java that there are high quality open source libraries for anything we need. Think about it this way, what if you could consume the massive ecosystem and libraries written for the JDK and rid yourself of the dogma of “best practices” of Java? That’s what consuming Java libraries inside of Kotlin feels like.

Use the good parts, throwaway the bad. It really feels like we get the upside of a massive pre-existing ecosystem, and not too much of the downside.

(Private) Extension functions are great

When I first saw Kotlin extension functions, I quite literally cringed. “Oh no”, I thought, “It’s Javascript prototype pollution all over again! Why in the world you let code seemingly modify the interfaces of other package objects? No!

And so I didn’t use them. I swore off extension functions. I even wrote it into our best practice and code style document. And so it stayed for years.

… Until I realized you can throw private on the front of extension functions. “Wait… so I can modify the interfaces only within bounded scopes??? I can add utility functions that don’t spill globally??” Despite what all the demo code out there shows, your extension functions don’t have to be global. They can add utility functions to whatever standard scoping you want: package, private, or public.

Some of the really cool power of private extension functions come when integrating with Java libraries that don’t know how to deal with null like Kotlin does. With nullable receivers, you can define extensions that work on nullable types. One simple example is the Koltin provided isNullOrBlank() extension.

 val foo: String?
 
// instead of this
if(foo == null || foo.isBlank())

// you can have an extension function on a nullable receiver type
private fun String?.isNullOrBlank(): Boolean = 
  if(this == null) true else this.length == 0
  
// now you can have this even if the variable is null!
if(foo.isNullOrBlank())
Kotlin

The code example is so contrite that you might not see the real value there, but it can come really in handy when you’re writing things like parsers and making the same changes or checks to variables over and over.

Pragmatic best practices

Kotlin feels like it frees the JVM from the dogmatic practices of Java.

Myself from ten years ago would not believe that I wrote that statement. But it feels true.

Let’s be honest, Java “best practice” is exhausting. I’m not familiar enough to comment on best practices of other languages, but the jokes I see on r/programmerhumor make me think its the same way in lots of other languages.

In the case of Java, it has nothing to do with the language anymore. Because of early constraints, patterns were established to ensure that developers solved the same problems in common ways. Crappy constructors? Use Builders! Unconfigureable object instantiation? Factories!

And this worked. Until it didn’t. Languages have evolved and changed, but the best practices don’t feel like they’ve properly evolved with the language changes. I seriously doubt that Java “best practice” would be what it is if it were based on of Java in 2025. And yet the community perpetuates a lot of that same cruft. Not all of it is bad, but some feels like bike-shedding at this point.

Just a few minor examples:

  1. Builders: Haven’t needed them once. Named Parameters + Default Values + Flexible constructors have completely done away with them.
  2. Factories: Haven’t run into them in Kotlin. Have still seen them in Java libraries, but with really nice lambda support in Kotlin, most people will just through an .apply on the end of a construction and call it good.
  3. Class Per File: Not strictly a requirement in Java, but likely to get your PR declined pretty much anywhere. Not such a dogmatic thing in Kotlin. Want to throw all your data classes in a single “FooDtos.kt” file? Go ahead, it’s not against the rules.
  4. Getters/Setters: Oh the arguments I’ve sat in debating getters/setters, private/public properties. In Kotlin, the community feels more permissive. Is object enclosure still a valuable principle? Of course? Is it sometimes okay to have a public property? Sure, just make sure you understand the consequences.

In a similar vein to what I mentioned earlier, it’s not necessarily that Kotlin does things better than other languages, but it’s best practices feel tailored more around “What’s productive and good enough?” than “What’s perfectly right?”.

Data Classes

Having a conceptual difference between a class storing data and a class for functionality leads to more functional styles and easier data operations.

A data class is so concise and flexible that I wonder how I ever lived without them.

// no toString, equals, hashcode, constructor, getters, or setters. ❤️
data class ProcessingResult(val errorCode: UInt, val output: File? = null)

// can instatiate without all properties. thanks default params
return ProcessingResult(500)
Kotlin

I recently had to write some Java to update a library we use from Kotlin. I never disliked Java when I was using it, but I was surprised how quickly I had a visceral reaction to having to implement/update getters, setters, hashcode, equals, and toString. Once you haven’t done them for a while, it feels really odd that you have to.

… And yes, I’m aware of Java records, but they aren’t quite the same as data classes. And with their only-recent introduction. It’ll be a long time before we see wide-spread adoption.

1st class Lambdas lead to some cool abilities

The concept of 1st class function and lambdas were conceptually baked into Kotlin from the beginning and it shows. When compared with other languages that evolved into these concepts, the support just feels more seamless.

Again, lots of languages support lambdas. But the more modern languages that were created with them in mind set themselves apart with how seamless it all feels. Kotlin fits into that category.

Here one example: For integration tests, we created a simple DSL to help developers setup test conditions in their integration tests. I’ve done this in other languages, and there ended up being 100’s of test functions of different variations to populate databases, set up properties, etc.

In our project, because the “DSL” was simply a lambda receiver function, it’s all code. A few simple functions and data classes is it all it took to provide our developers the ability to do relatively complex entity setup. We provide basic defaults and allow the developer to modify whatever they need using the code inside the block. Under the covers, it all calls the same services the application code uses. Win and win.

// looks declarative for simply cases
val company1 = company {
  user { email = "[email protected]" }
  user { email = "[email protected]" }
}

// but actually, everything is a code block! 
// create a variable number of users!
val numUsers = 10
val company2 = company {
  name = 'My Company'
  repeat(numUsers){ 
    user { email = "foo+${it}@baz.com" }
  }
}
Kotlin

I’ll probably write a full blog post on our DSL for test setup as I think it’s an interesting topic unto itself. Under the covers, this is all just Kotlin data classes and receiver functions that pass through to existing application code. It often looks declarative, but everything between {} is actually code flow.

Kotlin made for one of cleanest test setup structures I’ve seen in a long, long time.

Equality checks

It’s nice that combined with data classes, equality is just ==. For 99%+ of the time, that’s all you need. Duplication detection in Collections? It just works; even for complex objects. This seems so insignificant and silly, but if you’re coming from Javascript or other JDK-based languages, it’s a nice mental break to not have to think about it.

Immutable by default

Kotlin supports both val and var for variable declarations. val is an immutable constant; var is reassignable. I can count on two hands the number of times I’ve used var in the last 4 years. It does happen, but the vast majority of code paths in Kotlin naturally lean towards immutability. It kind of just naturally happens due to the functional aspects of the language.

The same holds true for Collections. List, Set, Map, etc are all immutable by default in Kotlin. Getting a mutable copy is as simple as calling .toMutableX. My first impression of this was that it didn’t matter. The more I’ve used it, the more I realized that it’s a sane default. It’s nice to know that if I pass collections around as parameter they can’t get modified without me making it explicitly possible.

Being immutable isn’t necessarily better than mutable, both obviously have their places. But I would argue that immutable as the default makes a ton of sense for the vast majority of applications out there. It makes control flow explicit and prevents common mutation bugs around parameter passing.

Functional Programming Encouraged, Not Required

My brain naturally operates in an object-oriented fashion. I guess that’s the burden I was born into. However, I can appreciate the beauty of functional flows for data processing.

Kotlin again finds a nice balance here. By default, it’s object-oriented. But it feels like less of an escape hatch when you do want to do something functional-style.

It’s extensive collection library, functional interfaces, data classes, and lambdas make functional flows easy, not second class citizens.

Result<T> Type Guidance

I have a complicated relationship with Result<T> types.

In case the vernacular is uncommon across context… you know the return objects that are essentially data and error in one? In theory, I feel like they should be used a lot. Pretty much every function should tell you the control flow result and the data result. But in practice, they can often become cumbersome. I’m looking at you Go (cough, error != nil, cough).

That theory/practice complication still exists in Kotlin, but I find that it generally pushes me into the right path. Since ?: is generally used for defaulting behavior (not just values), it’s easy to see when we’re default data vs control flow.

If I find that I can do an entire function with just ?: return DEFAULT or similar, then I know I’m safe to just return data.

If I find myself reaching for any ?: throw Exception() statements as behavioral default, I can clearly see that I’m creating control-flow and hiding it from my caller. In those cases, I should probably return a Result<T> (included in Kotlin by default, by the way).

The Bad

Just give me ternary

Kotlin doesn’t have a ternary. It doesn’t need one. If/else is an expression, so you can just use that. Their documentation even literally says that:

Therefore, there is no ternary operator (condition ? then : else) because ordinary if works fine in this role.

https://kotlinlang.org/docs/control-flow.html

I know that and I get that, but I want a ternary. I don’t know why, but there just times where the condition ? then : else syntax feels more concise than the if/else.

Understanding Java is still essential

If you’re doing Kotlin on the backend, there’s no way around it. You’ll have to read Java. The vast majority of the libraries you’ll be using the in JVM ecosystem are written in Java. This is a blessing and a curse. A blessing because there are tons and tons of high quality JVM libraries you can use without them having to be written. A curse because you will likely smash your monitor as you dig through AbstractSingltonProxyFactoryBean.

return inside lambdas

This isn’t necessarily bad so much as unfortunate. Kotlin being so expression-heavy is amazing in so many ways, but one of the unfortunate side-effects of this is that early-returns inside of nested expressions or lambdas need to be scoped. If return statements are not scoped, they always reference the function not the lambda. It’s not terrible once you understand it and how to work with it, but it can be confusing to new Kotlin developers. This is especially true if developers are coming from Javascript, where this is different.

For example, here’s an early return inside of a Caffeine cache. Notice how the early return after the elvis operator is scoped as return@get. Without that, the return would reference cachedLoadUserGroups, which would bypass the cache loader.

fun cachedLoadUserGroups(userId: UUID) = cachedCreators.get(userId) {
  val user = userRepo.findById(userId) ?: return@get emptyList<>()
  groupRepo.findGroupsForCompany(user.companyId)
}
Kotlin
Coroutines

I debated putting anything about coroutines on here. In all honesty, in my 4 years of Kotlin use, I’ve rarely used them. But coroutines are a huge selling point for Kotlin in certain domains, namely Android. The flexibility they bring to asynchronous UI flows can’t be overstated.

But for the majority of our backend use cases of Kotlin? They’re superfluous and to be honest, I just don’t trust them. When I’m already operating in a thread-per-request model of Jetty or other HTTP servers, it’s very rare that I need asynchronous flows. Asynchronous flows are normally handled through messaging so that the load can be distributed.

When I do need machine-local async flows, I find that I trust the standard JVM thread pools or executors more. To be fair, this has nothing to do with coroutines and everything to do with our use of Spring Boot and Spring Security at Masset.

Spring Security uses ThreadLocal as it’s default Context provider. Coroutines break that model by reusing threads across multiple coroutines executors. While technically there are implementations in Spring that are supposed to resolve this, they feel too error prone. We can’t expect our developers to always know to pass the right contexts to the right places when running coroutines.

In addition, it feels like for all the effort put into building coroutines, the core benefits will eventually be superseded by Project Loom. In reality, they are meant for different purposes, but in practice, application developers will often use them to accomplish the same thing.

Destructuring is worthless without naming

When compared to destructuring in other languages, Kotlin falls flat. It’s technically supported but with some weird .componentN magic that requires knowing property ordering and enough contextual understanding of classes that I find myself just ignoring it.

Named destructuring of properties would solve the majority of my issues with this. This is total supposition, but the fact that it hasn’t been done already makes me suspect there is something in the underlying JDK or other foundational libraries that prevent it.

Thankfully there have only been a handful of cases where I really felt like I needed destructuring. The majority of the time, I don’t miss it.

Split vision

I’m biased here because I only use Kotlin on the JVM, but I very much disagree with the Kotlin team’s large emphasis on Kotlin Multiplatform. The “single language” for all platforms dream has been promised for so long. So many technologies have tried and failed to sufficiently deliver on the promise that I have no faith that it’ll ever be solved. It feels like a complete waste of time and a repeat of many, many previous attempts.

In my opinion, they are trying to do too much platforming instead of just focusing on the language and tools themselves. I’d much rather see the Kotlin team double down on what is already great with Kotlin and improve items around adoption (see below).

The Ugly

Intellij Holds Back the Language

Story time. I discovered IntelliJ very early in my career. I think I developed using Eclipse for probably 1-2 months before I found IntelliJ and never looked back. I find it so productive that I’ve just paid for my own license at this point and take it to any company I go to, just in case they don’t offer a company subscription. That’s a rarity nowadays, but happened surprisingly often a decade ago.

So when I first investigated and learned Kotlin, it didn’t even occur to me that it couldn’t really be used outside of IntelliJ. When I first presented to my team as the company standard, their first question was “What’s the experience in VSCode?”. I was honestly caught off guard. Not because of the question, but I felt dumb because I honestly hadn’t even considered it.

Mentally, I always think of programming languages as agnostic of developer environment. Code C in whatever editor you want, same for Rust, Go, and more recently, Java. Javascript, Typescript? Same deal. PHP? I think most people use Notepad for that, right? C#? Okay, maybe a little more vendor tie-in, but there are still other options out there.

But with Kotlin? Nope. It’s IntelliJ or bust. For me personally, eh, not a big deal. I’m on the IntelliJ all-product suite subscription for life. But for developers who have other preferred developer experiences, it’s a HUGE deal. And I think that’s an extremely valid concern.

To be fair, JetBrains is kind enough to support Kotlin in IntelliJ Community Edition, so there are at least free ways of using it. And there is a 3rd-party language server out there, but my experience trying to get it to work with any other editors was not pretty.

I don’t know if the Kotlin devs are unintentionally blind here or if the conspiracy theorists are right in assuming JetBrains is intentionally trying to sell IDE licenses. Honestly, I wouldn’t be surprised if it’s the latter. Either way, I personally feel it’s the #1 hindrance to the broader adoption of Kotlin.

I was hopeful that the compiler backend rewrite for 2.0 would come with an accompanying project for an open source language server, but based on what I’ve seen from Reddit chatter, it’s not a lift the team is willing to take right now.

Compile Times Are still slow Despite significant investment

Compile times with Kotlin are kinda slow.

There, I said it. Now, let’s get into more specifics.

So… It’s not that slow. It’s fast enough that I maintain developer context. It’s not slow enough that I hit the build button and head to Hacker News. It’s not slow enough that my CI/CD pipelines take hours (although Gradle downloading the entire internet over and over again tries its best to push it that way). It’s not slow enough that I generally feel like I need to complain about it.

But it’s definitely slower than other languages. And when developers are arbitrarily looking at benchmark metrics when choosing languages, it’s one that stands out. My developer iteration cycle is definitely slower than Go, slower than Typescript with Vite, slower even than Java. Do I really care about the cycle being 5-6 seconds instead of < 1s? Personally, not really. But its a number on a page that stands out to developers researching what to use for their next project.

But even that isn’t that big of a deal. Honestly, what worries me most about compile times is that I don’t have much hope that it will get better. Code bases grow over time. Unless there are language improvements to offset that, compile times grow over time as well. What is 5-6 seconds on today’s codebase might be 12-15 seconds on next year’s codebase. Maybe it’ll be 20-30 on our code base in 2 years. And that’s assuming compile times grow linearly with code size, which they normally don’t.

Despite lots of promises of significant improvements to compile times with the new K2 compiler (delivered as part of Kotlin 2.0), I haven’t seen much improvement in real-life scenarios. Whether or not it’s faster, things feel the same. As a result, I don’t have much hope that we’ll continue to see measurable compiler improvements over time. If we couldn’t get there with > 1 yr investment on K2, it’s not likely to happen any time soon. Like Ted Lasso said, “It’s the lack of hope that comes to getcha”.

There are obviously ways to work around these potential issues, splitting codebase, caching, etc. But they feel like just that, workarounds.

Conclusion

In the end, I really like Kotlin. The language itself is enjoyable and most importantly, it feels like it enables productivity significantly more than it hinders it.

Would I recommend it for new companies and projects? If you’re doing backend, yah, I would.

I think it finds a really nice balance. It has it’s tradeoffs just like any tech, but I feel it makes the right ones for a certain demographic.

There’s not really anything else to say in summary. It was never my intent to convince readers to use or not use it, but mostly to provide a singular ancedotal point of view.

It’ll be interesting to continue to record my “aha moments” over time. Even as I was writing this post I had a few more as I remembered different experiences. But I forced myself to constrain this post to the things I’d already written down. Maybe I’ll do a follow-up Part 2 in another year as I record more.

Until then, happy Kotlin-ing!

Categories: