Guest post by Urs Peter, Senior Software Engineer and JetBrains-certified Kotlin Trainer. For readers who’d like a more structured way to build Kotlin skills, Urs also leads the Kotlin Upskill Program at Xebia Academy.
This is the second post in The Ultimate Guide to Successfully Adopting Kotlin in a Java-Dominated Environment, a series that follows how Kotlin adoption grows among real teams, from a single developer’s curiosity to company-wide transformation.
Read the first part: Getting Started With Kotlin for Java Developers
The Evaluation Stage: Beyond Kotlin as a Playground
Once you’re comfortable with Kotlin in tests, it’s time for a more substantial evaluation. You have two main approaches:
1. Build a new microservice/application in Kotlin
Starting fresh with a new application or microservice provides the full Kotlin experience without the constraints of legacy code. This approach often provides the best learning experience and showcases Kotlin’s strengths most clearly.
Pro tip: Get expert help during this stage. While developers are naturally confident in their abilities, avoiding early mistakes in the form of Java-ish Kotlin and a lack of Kotlin-powered libraries can save months of technical debt.

This is how you can avoid common pitfalls when using Kotlin from a Java background:
Pitfall: Choosing a different framework from the one you use in Java.
Tip: Stick to your existing framework.
Most likely, you were using Spring Boot with Java, so use it with Kotlin too. Spring Boot Kotlin support is first-class, so there is no additional benefit in using something else. Moreover, you are forced to learn not only a new language but also a new framework, which only adds complexity without providing any advantage.
Important: Spring interferes with Kotlin’s ‘inheritance by design’ principle, which requires you to explicitly mark classes open in order to extend them.
In order to avoid adding the open keyword to all Spring-related classes (like @Configuration, etc.), use the following build plugin: https://kotlinlang.org/docs/all-open-plugin.html#spring-support. If you create a Spring project with the well-known online Spring initializr tool, this build plugin is already configured for you.
Pitfall: Writing Kotlin in a Java-ish way, relying on common Java APIs rather than Kotlin’s standard library:
This list can be very long, so let’s focus on the most common pitfalls:
Pitfall 1: Using Java Stream rather than Kotlin Collections
Tip: Always use Kotlin Collections.
Kotlin Collections are fully interoperable with Java Collections, yet equipped with straightforward and feature-rich higher-order functions that make Java Stream obsolete.
As follows is an example that aims to pick the top 3 products by revenue (price * sold) grouped by product category:
Java
record Product(String name, String category, double price, int sold){}
List<Product> products = List.of(
new Product("Lollipop", "sweets", 1.2, 321),
new Product("Broccoli", "vegetable", 1.8, 5);
Map<String, List<Product>> top3RevenueByCategory =
products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.collectingAndThen(
Collectors.toList(),
list -> list.stream()
.sorted(Comparator.comparingDouble(
(Product p) -> p.price() * p.sold())
.reversed())
.limit(3)
.toList()
)
)
);
Kotlin
val top3RevenueByCategory: Map<String, List<Product>> =
products.groupBy { it.category }
.mapValues { (_, list) ->
list.sortedByDescending { it.price * it.sold }.take(3)
}
Kotlin Java interop lets you work with Java classes and records as if they were native Kotlin, though you could also use a Kotlin (data) class instead.
Pitfall 2: Keeping on using Java’s Optional.
Tip: Embrace Nullable types.
One of the key reasons Java developers switch to Kotlin is for Kotlin’s built-in nullability support, which waves NullPointerExceptions goodbye. Therefore, try to use Nullable types only, no more Optionals. Do you still have Optionals in your interfaces? This is how you easily get rid of them by converting them to Nullable types:
Kotlin
//Let’s assume this repository is hard to change, because it’s a library you depend on
class OrderRepository {
//it returns Optional, but we want nullable types
fun getOrderBy(id: Long): Optional<Order> = …
}
//Simply add an extension method and apply the orElse(null) trick
fun OrderRepository.getOrderByOrNull(id: Long): Order? =
getOrderBy(id).orElse(null)
//Now enjoy the safety and ease of use of nullable types:
//Past:
val g = repository.getOrderBy(12).flatMap { product ->
product.goody.map { it.name }
}.orElse("No goody found")
//Future:
val g = repository.getOrderByOrNull(12)?.goody?.name ?: "No goody found"
Pitfall 3: Continuing to use static wrappers.
Tip: Embrace Extension methods.
Extension methods give you many benefits:
- They make your code much more fluent and readable than wrappers.
- They can be found with code completion, which is not the case for wrappers.
- Because Extensions need to be imported, they allow you to selectively use extended functionality in a specific section of your application.
Java
//Very common approach in Java to add additional helper methods
public class DateUtils {
public static final DateTimeFormatter DEFAULT_DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public String formatted(LocalDateTime dateTime,
DateTimeFormatter formatter) {
return dateTime.format(formatter);
}
public String formatted(LocalDateTime dateTime) {
return formatted(dateTime, DEFAULT_DATE_TIME_FORMATTER);
}
}
//Usage
formatted(LocalDateTime.now());
Kotlin
val DEFAULT_DATE_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
//Use an extension method, including a default argument, which omits the need for an overloaded method.
fun LocalDateTime.asString(
formatter: DateTimeFormatter = DEFAULT_DATE_TIME_FORMATTER): String =
this.format(formatter)
//Usage
LocalDateTime.now().formatted()
Be aware that Kotlin offers top-level methods and variables. This implies that we can simply declare e.g. the DEFAULT_DATE_TIME_FORMATTER top level without the need to bind to an object like is the case in Java.
Pitfall 4: Relying on (clumsily) Java APIs
Tip: Use Kotlin’s slick counterpart.
The Kotlin standard library uses extension methods to make Java libraries much more user-friendly, even though the underlying implementation is still Java. Almost all major third-party libraries and frameworks, like Spring, have done the same.
Example standard library:
Java
String text;
try (
var reader = new BufferedReader(
new InputStreamReader(new FileInputStream("out.txt"),
StandardCharsets.UTF_8))) {
text = reader
.lines()
.collect(Collectors.joining(System.lineSeparator()));
}
System.out.println("Downloaded text: " + text + "\n");
Kotlin
//Kotlin has enhanced the Java standard library with many powerful extension methods, like on java.io.*, which makes input stream processing a snap due to its fluent nature, fully supported by code completion
val text = FileInputStream("path").use {
it.bufferedReader().readText()
}
println("Downloaded text: $text\n");
Example Spring:
Java
final var books = RestClient.create()
.get()
.uri("http://.../api/books")
.retrieve()
.body( new ParameterizedTypeReference<List<Book>>(){}); // ⇦ inconvenient ParameterizedTypeReference
Kotlin
import org.springframework.web.client.body
val books = RestClient.create()
.get()
.uri("http://.../api/books")
.retrieve()
.body<List<Book>>() //⇦ Kotlin offers an extension that only requires the type without the need for a ParameterizedTypeReference
Pitfall 5: Using a separate file for each public class
Tip: Combine related public classes in a single file.
This allows you to get a good understanding of how a (sub-)domain is structured without having to navigate dozens of files.
Java

Kotlin
//For domain classes consider data classes - see why below
data class User(val email: String,
//Use nullable types for safety and expressiveness
val avatarUrl: URL? = null,
var isEmailVerified: Boolean)
data class Account(val user:User,
val address: Address,
val mfaEnabled:Boolean,
val createdAt: Instant)
data class Address(val street: String,
val city: String,
val postalCode: String)
Pitfall 6: Relying on the mutable programming paradigm
Tip: Embrace immutability – the default in Kotlin
The trend across many programming languages – including Java – is clear: immutability is winning over mutability.
The reason is straightforward: immutability prevents unintended side effects, making code safer, more predictable, and easier to reason about. It also simplifies concurrency, since immutable data can be freely shared across threads without the risk of race conditions.
That’s why most modern languages – Kotlin among them – either emphasize immutability by default or strongly encourage it. In Kotlin, immutability is the default, though mutability remains an option when truly needed.
Here’s a quick guide to Kotlin’s immutability power pack:
1. Use val over var
Prefer val over var. IntelliJ IDEA will notify you if you used a var, for which a val could be used.
2. Use (immutable) data classes with copy(...)
For domain-related classes, use data classes with val. Kotlin data classes are often compared with Java records. Though there is some overlap, data classes offer the killer feature copy(...), whose absence makes transforming record – which is often needed in business logic – so tedious:
Java
//only immutable state
public record Person(String name, int age) {
//Lack of default parameters requires overloaded constructor
public Person(String name) {
this(name, 0);
}
//+ due to lack of String interpolation
public String sayHi() {
return "Hello, my name is " + name + " and I am " + age + " years old.";
}
}
//Usage
final var jack = new Person("Jack", 42);
jack: Person[name=Jack, age=5]
//The issue is here: transforming a record requires manually copying the identical state to the new instance ☹️
final var fred = new Person("Fred", jack.name);
Kotlin
//also supports mutable state (var)
data class Person(val name: String,
val age: Int = 0) {
//string interpolation
fun sayHi() = "Hi, my name is $name and I am $age years old."
}
val jack = Person("Jack", 42)
jack: Person(name=Jack, age=42)
//Kotlin offers the copy method, which, due to the ‘named argument’ feature, allows you to only adjust the state you want to change 😃
val fred = jack.copy(name = "Fred")
fred: Person(name=Fred, age=42)
Moreover, use data classes for domain-related classes whenever possible. Their immutable nature ensures a safe, concise, and hassle-free experience when working with your application’s core.
Tip: Prefer Immutable over Mutable Collections
Immutable Collections have clear benefits regarding thread-safety, can be safely passed around, and are easier to reason about. Although Java collections offer some immutability features for Collections, their usage is dangerous because it easily causes exceptions at runtime:
Java
List.of(1,2,3).add(4); ❌unsafe 😬! .add(...) compiles, but throws UnsupportedOperationException
Kotlin
//The default collections in Kotlin are immutable (read-only) listOf(1,2,3).add(4); //✅safe: does not compile val l0 = listOf(1,2,3) val l1 = l0 + 4 //✅safe: it will return a new list containing the added element l1 shouldBe listOf(1,2,3,4) //✅
The same applies for using Collections.unmodifiableList(...), which is not only unsafe, but also requires extra allocation:
Java
class PersonRepo {
private final List<Person> cache = new ArrayList<>();
// Java – must clone or wrap every call
public List<Person> getItems() {
return Collections.unmodifiableList(cache); //⚠️extra alloc
}
}
//Usage
personRepo.getItems().add(joe) ❌unsafe 😬! .add(...) can be called but throws UnsupportedOperationException
Kotlin
class PersonRepo {
//The need to type ‘mutable’ for mutable collections is intentional: Kotlin wants you to use immutable ones by default. But sometimes you need them:
private val cache: MutableList<Person> = mutableListOf<Person>()
fun items(): List<Person> = cache //✅safe: though the underlying collection is mutable, by returning it as its superclass List<...>, it only exposes the read-only interface
}
//Usage
personRepo.items().add(joe) //✅safe:😬! Does not compile
When it comes to concurrency, immutable data structures, including collections, should be preferred. In Java, more effort is required with special Collections that offer a different or limited API, like CopyOnWriteArrayList. In Kotlin, on the other hand, the read-only List<...> does the job for almost all use cases.
If you need mutable, Thread-Safe Collections, Kotlin offers Persistent Collections (persistentListOf(...), persistentMapOf(...)), which all share the same powerful interface.
Java
ConcurrentHashMap<String, Integer> persons = new ConcurrentHashMap<>();
persons.put("Alice", 23);
persons.put("Bob", 21);
//not fluent and data copying going on
Map<String, Integer> incPersons = new HashMap<>(persons.size());
persons.forEach((k, v) -> incPersons.put(k, v + 1));
//wordy and data copying going on
persons
.entrySet()
.stream()
.forEach(entry ->
entry.setValue(entry.getValue() + 1));
Kotlin
persistentMapOf("Alice" to 23, "Bob" to 21)
.mapValues { (key, value) -> value + 1 } //✅same rich API like any other Kotlin Map type and not data copying going on
Pitfall 7: Keeping on using builders (or even worse: trying to use Lombok)
Tip: Use named arguments.
Builders are very common in Java. Although they are convenient, they add extra code, are unsafe, and increase complexity. In Kotlin, they are of no use, as a simple language feature renders them obsolete: named arguments.
Java
public record Person(String name, int age) {
// Builder for Person
public static class Builder {
private String name;
private int age;
public Builder() {}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Person build() {
return new Person(name, age);
}
}
}
//Usage
new JPerson.Builder().name("Jack").age(36).build(); //compiles and succeeds at runtime
new JPerson.Builder().age(36).build(); //❌unsafe 😬: compiles but fails at runtime.
Kotlin
data class Person(val name: String, val age: Int = 0) //Usage - no builder, only named arguments. Person(name = "Jack") //✅safe: if it compiles, it always succeeds at runtime Person(name = "Jack", age = 36) //✅
2. Extend/convert an existing Java application
If you have no greenfield option for trying out Kotlin, adding new Kotlin features or whole Kotlin modules to an existing Java codebase is the way to go. Thanks to Kotlin’s seamless Java interoperability, you can write Kotlin code that looks like Java to Java callers. This approach allows for:
- Gradual migration without big-bang rewrites
- Real-world testing of Kotlin in your specific context
- Building team confidence with production Kotlin code
Rather than starting somewhere, consider these different approaches:
Outside-in:
Start in the “leaf” section of your application, e.g. controller, batch job, etc. and then work your way towards the core domain. This will give you the following advantages:
- Compile-time isolation: Leaf classes rarely have anything depending on them, so you can flip them to Kotlin and still build the rest of the system unchanged.
- Fewer ripple edits. A converted UI/controller can call existing Java domain code with almost no changes thanks to seamless interop.
- Smaller PRs, easier reviews. You can migrate file-by-file or feature-by-feature.
Inside-out:
Starting at the core and then moving to the outer layers is often a riskier approach, as it compromises the advantages of the outside-in approach mentioned above. However, it is a viable option in the following cases:
- Very small or self-contained core. If your domain layer is only a handful of POJOs and services, flipping it early may be cheap and immediately unlock idiomatic constructs (data class, value classes, sealed hierarchies).
- Re-architecting anyway. If you plan to refactor invariants or introduce DDD patterns (value objects, aggregates) while you migrate, it’s sometimes cleaner to redesign the domain in Kotlin first.
- Strict null-safety contracts. Putting Kotlin at the center turns the domain into a “null-safe fortress”; outer Java layers can still send null, but boundaries become explicit and easier to police.
Module by module
- If your architecture is organized by functionality rather than layers, and the modules have a manageable size, converting them one by one is a good strategy.
Language features for converting Java to Kotlin
Kotlin offers a variety of features – primarily annotations – that allow your Kotlin code to behave like native Java. This is especially valuable in hybrid environments where Kotlin and Java coexist within the same codebase.
Kotlin
class Person @JvmOverloads constructor(val name: String,
var age: Int = 0) {
companion object {
@JvmStatic
@Throws(InvalidNameException::class)
fun newBorn(name: String): Person = if (name.isEmpty())
throw InvalidNameException("name not set")
else Person(name, 0)
@JvmField
val LOG = LoggerFactory.getLogger(KPerson.javaClass)
}
}
Java
//thanks to @JvmOverloads an additional constructor is created, propagating Kotlin’s default arguments to Java
var john = new Person("John");
//Kotlin automatically generates getters (val) and setters (var) for Java
john.setAge(23);
var name = ken.getName();
//@JvmStatic and @JvmField all accessing (companion) object fields and methods as statics in Java
//Without @JvmStatic it would be: Person.Companion.newBorn(...)
var ken = Person.newBorn("Ken");
//Without @JvmField it would be: Person.Companion.LOG
Person.LOG.info("Hello World, Ken ;-)");
//@Throws(...) will put the checked Exception in the method signature
try {
Person ken = Person.newBorn("Ken");
} catch (InvalidNameException e) {
//…
}
Kotlin
@file:JvmName("Persons")
package org.abc
@JvmName("prettyPrint")
fun Person.pretty() =
Person.LOG.info("$name is $age old")
Java
//@JvmName for files and methods makes accessing static fields look like Java: without it would be: PersonKt.pretty(...) Persons.prettyPrint(ken)
IntelliJ IDEA’s Java to Kotlin Converter
IntelliJ IDEA offers a Java to Kotlin Converter, so theoretically, the tool can do it for you. However, the resulting code is far from perfect, so use it only as a starting point. From there, convert it to a more Kotlin-esque representation. More on this topic will be discussed in the final section of this blog post series: Success Factors for Large-Scale Kotlin Adoption.
Taking Java as a starting point will most likely make you write Java-ish Kotlin, which gives you some benefits, but will not unleash the power of Kotlin’s potential. Therefore, writing a new application is the approach I prefer.
Next in the series
This installment in our Ultimate Guide to Successfully Adopting Kotlin in a Java-Dominated Environment series of blog posts demonstrated how Kotlin experiments can evolve into production code. Our next post focuses on the human side of adoption: convincing your peers. It explains how to present clear, code-driven arguments, guide new developers, and create a small but lasting Kotlin community within your team.