Sitemap
Digital Frontiers — Das Blog

Dies ist das Blog der Digital Frontiers GmbH & Co. KG (http://www.digitalfrontiers.de). Hier veröffentlichen wir zu Themen, die uns interessieren und bewegen.

Migrating to Kotlin 2.0 — A slightly bumpy journey

10 min readMay 9, 2025

--

Press enter or click to view image in full size
Photo by Roman Synkevych on Unsplash

On May 21, 2024, the time had come: Kotlin 2.0.0 was released, along with the new K2 compiler.
The language enhancements in this release are relatively limited, but significant work was done in the background to enable future adjustments that were not possible with the K1 compiler.
And while improved compilation performance was not an immediate goal, it turned out to be somewhat of a natural side effect.
Reports indicate up to 94% faster compilations, a initialization phase up to 488% faster, and an analysis phase up to 376% faster.

In the meantime, Kotlin 2.1.0 has also been released as of November 27, 2024, bringing additional preview features and minor improvements. Additionally, the K2 mode for IntelliJ IDEA is now stable and available since version 2024.3, moving out of beta. The K2 mode supports the language features introduced with Kotlin 2.0+ and enhances code highlighting, code completion, and more.
The recent release of Kotlin 2.1 and the stable support for the K2 mode were also the motivation for this blog post. Therefore, I migrated two of our services to Kotlin 2.0.0 to gain some insights of the actual compilation improvements and how easy it is to migrate more services to the new major version.

Spoiler: It went more smoothly than expected, but the issues that did arise were quite challenging.

Why Kotlin 2.0.0?

Aside from the fact that dependencies in a project should be updated regularly anyway to avoid major “big bang” migrations, Kotlin 2.0.0 also offers a number of improvements within the compiler’s behaviour.
For example, smart casts now occur in many additional scenarios where this was previously not possible:

I really missed the “type checks with the logical OR operator” feature recently in a service that hadn’t been migrated at that time:

In Kotlin 2.0.0, if you combine type checks for objects with an or operator (||), a smart cast is made to their closest common supertype. Before this change, a smart cast was always made to the Any type.

In this case, you still had to manually check the object type afterward before you could access any of its properties or call its functions.

Source: Type checks with the logical OR operator

Now, you can use those properties or functions directly without hassle.

Additionally, Kotlin’s multiplatform support continues to be enhanced and expanded. Maybe someday I’ll have the opportunity to experiment with it more. For now, I can’t provide any practical examples.

Setup

Upgrading to Kotlin 2.0 is as simple as bumping your kotlin-related dependencies to the new major version in the building tool of your choice.

For gradle, we are changing our build.gradle.kts:

kotlin("jvm") version "1.9.23"
kotlin("plugin.spring") version "1.9.23"
kotlin("plugin.jpa") version "1.9.23"

to

kotlin("jvm") version "2.1.22"
kotlin("plugin.spring") version "2.1.22"
kotlin("plugin.jpa") version "2.1.22"

As we’re using Spring Boot in our project, we are required to update the spring and jpa plugin as well.

Now, it would be interesting to gather our own metrics regarding compile-time improvements between these two compiler versions. Therefore, we’re adding the following two lines to our gradle.properties:

kotlin.build.report.output=json
kotlin.build.report.json.directory=.kotlin

You will most likely want to add these properties before upgrading to Kotlin 2.0, so that you have a reference point with the previous version.

On our side, upgrading to Kotlin 2.0 did not go without issues, but more on that later. Let’s gather some compile-time results first.

First compilation results

We were migrating two of our services to the new K2 compiler, one of them with quite a large code base. After setting the properties in the gradle.properties file and executing the build step, a timestamped json file with the compilation results is generated in the .kotlin directory below root-level.

You want to watch out for these entries in the buildOperationRecord array:

Gathering build metrics with the kotlin build report

There are a lot of steps involved in the build operation depending on your setup, but not every step is of interest. For example, in our case the OpenAPI generation will most likely not be affected by exchanging the compiler.

These were our aggregated results for the relevant build steps:

Service 1

Step: compileKotlin

  • Using K1: 14586 ms
  • Using K2: 9624 ms

So, compilation time was reduced by ~34% for this step.

Step: compileTestKotlin

  • Using K1: 36263 ms
  • Using K2: 13400 ms

Test compilation actually goes down by ~63%!

So, these are actually quite promising results for our first service!

Service 2

Step: compileKotlin

  • Using K1: 47496 ms
  • Using K2: 21529 ms

For this service, the compilation step is already down by ~55%.

Step: compileTestKotlin

  • Using K1: 124267 ms
  • Using K2: 33881 ms

Finally, for the test compilation the reduction is a whopping 73%!

Additionally, the IDE feels faster when navigating, the incremental compilation increased in performance, and starting unit and integration tests has improved.

Minor inconveniences

Compile times were faster, which is nice. However, the migration did not go completely without issues.

In the upcoming sections, I will present the issues we encountered and solutions for these problems. You may or may not run into the same problems, but maybe you’ll gather some useful information by reading the root causes for these issues.

Issue 1: Compilation fails due to long test names

So, this is one issue we encountered only in one of our services and this made me a bit clueless, because we didn’t change any code for it to happen.

As we tried to run our test suite we received the following error message:

Caused by: java.io.FileNotFoundException:
$PROJECTS_DIR\service2\build\classes\kotlin\test\de\middleware\domain\
aggregate\proposal\SomeAggregateTest$Processing$Tasks$SomeLongNested
ClassName$can_be_submitted__processing_required__confirmation_required__
single_participant_$lambda$30$$inlined$matchesAsserting$2.class
(The system cannot find the file specified)

That’s interesting, because compilation did work and we were able to run this test happily before upgrading to the K2 compiler. Now, it doesn’t seem to find the compiled test method to run. The test class and method is constructed like this:

@ExtendWith(MockKExtension::class)
class SomeAggregateTest {
@Nested
inner class Processing {
@Nested
inner class Tasks {
@Nested
inner class SomeLongNestedClassName {
@Test
fun `can be submitted, processing required, confirmation required (single participant)`() {
// ...
}
}
}
}
}

The affected test method is located in a rather large test class, which is structured via JUnit 5 @Nested annotations to build groups of test methods. Additionally, as Kotlin allows us to use backticks in method names, we’re extensively using this to have more expressive test method names. The side-effect of this is that Kotlin is generating class files for each test method named in this way. Normally, this is not an issue. It will be problematic however, when test method names exceed file system specific limitations, which interestingly did not happen with the K1 compiler.

When generating the same test method with the K1 compiler, we picked up differences in the generated test class:

// K1
SomeAggregateTest$Processing$Tasks$SomeLongNestedClassName$can be
submitted, processing required, confirmation required
(single participant)$1$invoke$$inlined$matchesAsserting$2.class

// K2
SomeAggregateTest$Processing$Tasks$SomeLongNestedClassName$can_be
_submitted__processing_required__confirmation_required
__single_participant_$lambda$30$$inlined$matchesAsserting$2.class

Some special characters, like spaces or round brackets, are converted to underscores. Additionally, there seem to be some changes regarding how lambdas are generated at all ($1$invoke vs. $lambda$30).

After some research, I found this YouTrack issue by the Kotlin Language Committee: https://youtrack.jetbrains.com/issue/KTLC-10/Generate-all-Kotlin-lambdas-via-invokedynamic-LambdaMetafactory-by-default

Starting from Kotlin 2.0, all lambdas are generated via invokedynamic and the so-called LambdaMetafactory by default instead of generating anonymous classes. So, there is some difference in generating lambdas, but that shouldn’t affect our code, right? Both class files even have around the same amount of characters.

And interestingly, when shortening the method name by some characters, the test could be executed again. More interestingly, even when lengthening the method name while using the K1 compiler, there still is no issue at all.

Although I spent quite some time trying to find further clues regarding new limitations for class file names in the K2 compiler, I couldn’t find any. I’m thankful for every hint on this topic.

However, the solution is quite simple: Reduce the amount of nested classes or shorten the method name a bit.

Issue 2: Lambdas and invokedynamic

The next issue again occurred when executing the tests. One of our tests threw the following exception:

Illegal state change detected! Property "de.service2.domain.aggregate.proposal.plugin.SomeAggregate.processingStateDocumentNames" has different value when sourcing events.
Working aggregate value: <{class de.service2.domain.value.ProcessingState$SomeState=de.service2.domain.aggregate.proposal.plugin.SomeAggregate$$Lambda$772/0x00000150c7360c10@6d820f5e}>
Value after applying events: <{class de.service2.domain.value.ProcessingState$SomeState=de.service2.domain.aggregate.proposal.plugin.SomeAggregate$$Lambda$1124/0x00000150c76490c0@66cfe66}>
at de.service2.domain.aggregate.proposal.plugin.SomeTest$Change.meter references normalized and aligned (not known)(SomeTest.kt:2529)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)

As you may have noticed, we’re working with aggregates and events here and the error states something about sourcing events. In our services, we’re using an event-based architecture with CQRS (Command Query Responsibility Segregation) and ES (Event Sourcing) as our building blocks. The exception ultimately occurs in the Axon framework, an implementation for CQRS/ES in the JVM.

The exception “Property xyz has different value when sourcing events.” roughly means: At some point you persisted an event in the event stream and when sourcing that given event, one of the values is different from before. This should clearly never happen, because that means, there’s something wrong in the event serialization or deserialization.

The field in question is the following:

override val processingStateDocumentNames: Map<KClass<out SomeState>, DocumentNamingInput.() -> String> = mapOf(
SomeState::class to { "DocumentName_$someStateValue" },
)

It persists an association of state class reference to a deferred evaluation of document names by using a lambda function as our map value. Without going into too much detail, we have various processing states, which may or may not result in document creation. The final name of the document is dependent on information not known ahead of time.

A requirement for event sourcing is that events are serializable (no suprise, yet). Class references are serializable. Lambda functions are serializable, at least up until Kotlin 2.0. If you’ve read the YouTrack issue thoroughly, you will have found the following change:

There are three “features” of this lambda class which indy-lambdas don’t have:

1. It inherits from the runtime class Lambda which inherits from java.io.Serializable, whereas lambdas created by LambdaMetafactory are not serializable by default.

(indy-lambda from “invokedynamic”)

Regardless of whether it was ever intended that Lambda were serializable, we have a field that requires lambda serialization. This is an issue, because in an event-sourcing architecture, we are required to read the event stream to build the current state of an aggregate. These events were persisted at a point in time, where lambda serialization was still available.

Luckily, the Kotlin language designers provided a feature to our rescue, so you can regain the legacy behaviour by one of the following options:

  • Use the compiler option -Xlambdas=class.
  • Annotate the specific lambda with @JvmSerializableLambda.

In our case, the annotation is absolutely sufficient, because it’s only a single place that needs to be addressed. Using this method, the field declaration will change to:

override val processingStateDocumentNames: Map<KClass<out SomeState>, DocumentNamingInput.() -> String> = mapOf(
SomeState::class to @JvmSerializableLambda { "DocumentName_$someStateValue" },
)

Now, the lambda function is serializable again and the test execution turns green again, yay!

Interlude: K2 mode

When you’re following the migration guide for Kotlin 2.0 from the official documentation, you will stumble over the K2 mode for IntelliJ IDEA. The K2 mode does not depend on the K2 compiler, however it leverages the newly introduced compiler features for faster code analysis.

It is not required to use the K2 mode, but when you’re just migrating to the new Kotlin version, you might just try out the K2 mode anyway. The new K2 mode was first introduced in IDEA 2024.2, becoming stable in IDEA 2024.3. Jetbrains did make the K2 mode activated by default in IDEA 2025.1.

By the way, you are not required to update to Kotlin 2.0 for using the K2 mode. It’s available for Kotlin 1.9 aswell as can be seen here:

Press enter or click to view image in full size
https://blog.jetbrains.com/idea/2024/11/k2-mode-becomes-stable/

To activate the K2 mode, you may go to Preferences -> Languages & Frameworks -> Kotlin and activate the check box:

Press enter or click to view image in full size

After restarting the IDE, you might receive popups about incompatible plugins. I did, unfortunately.

Issue 3: Incompatible IDE plugins (when using K2 mode)

After first activating the K2 mode, I received the message that my Kotlin Fill Class is not compatible with the K2 mode :(

That’s a neat little plugin that let’s you automatically fill functions or constructors with default values, so that you’re not required to write the parameters yourself. A really nice time-saver I must say.

Fortunately, with version 2.0.0 of this plugin, K2 mode is finally supported!

Another prominent example is the IDEA plugin for ktor, which was not compatible up until IDEA 2024.3.

In the end, most plugins hopefully will support the new K2 mode, some sooner, some later. But the K2 mode will be activated by default for IDEA 2025.1, which adds somewhat of an incentive to plugin contributors, I guess.

I wasn’t exactly sure what to expect from the K2 mode. After enabling it, I didn’t notice any differences in my existing project. It was just working like before. I guess that’s pretty much what the developers intended. Feature-wise it feels identical, it’s just optimized for the new K2 compiler, which makes code analysis noticeably faster. These minor inconveniences with incompatible plugins are a bit irritating, but that’s something that will most likely settle down once K2 mode becomes the default setting (hopefully).

This shall conclude the blog post. To summarize, I can only say that the switch from Kotlin 1 to Kotlin 2 went surprisingly smooth. Of course, there were smaller issues, but nothing that felt like a show-stopper. Furthermore, (incremental) compilation and the IDE overall is a lot faster. That leaves more time for development. And that’s the most important thing, I would say.

Thanks for reading! Feel free to comment or message me, when you have questions or suggestions. You might be interested in the other posts published in the Digital Frontiers blog, announced on our Twitter account.

--

--

Digital Frontiers — Das Blog
Digital Frontiers — Das Blog

Published in Digital Frontiers — Das Blog

Dies ist das Blog der Digital Frontiers GmbH & Co. KG (http://www.digitalfrontiers.de). Hier veröffentlichen wir zu Themen, die uns interessieren und bewegen.