Kotlin kontra Java – Part 3 – Language for Interop

Richard Gross

When you start a new project on the JVM, should you pick Java or Kotlin? Why not both?

You can use any Java library you want in Kotlin, you can even co-write your software in both languages (e.g. one team I work with writes their JUnit tests in Kotlin, their main code in Java) and migrate to Kotlin or back to Java over time. Kotlin is designed with Java interoperability in mind, because JetBrains wanted to migrate their IDEs (IntelliJ IDEA, WebStorm, etc.) incrementally to the language. But how does that work? What does Kotlin do, so that interop to this degree becomes possible?

In part 1 of this series we compared the ecosystem of both languages, and in part 2 we looked at the multiplatform capabilities. In this part we will give a general introduction to the Kotlin syntax, compare it with Java, explore how interop works and how Kotlin can provide more immutability than Java while staying interoperable. In part 4 we will see how these features impact the business modelling capabilities of Java and Kotlin.

Let us start with a general introduction to Kotlin, so we have a foundation to discuss Kotlin to Java interop.

The example domain for this part will be Martin Fowler’s video store. This is a store where you could go to rent a thing called a “DVD”, which is a shiny disc that stores videos of all kinds. These discs had to be returned in a timely manner, lest we incur “rental fees”. The fee for the rental changed depending on the video we had rented. ‘Twas the time of “Blockbuster”, long ago, before we could stream all our content.

We are not so different, you and I

Kotlin is not the only Java alternative that compiles to bytecode and runs on the Java Virtual Machine (JVM). There are also Ceylon, Clojure, Groovy, and Scala, to name only a few. What is notable about Kotlin is that it feels familiar to Java developers, like a natural evolution of Java, without overwhelming developers with too many foreign concepts. The entry point of an application is the main method, so let us start there.

Main

Ever since JDK 25 (September 2025), we have compact source files which make Java main methods quite compact, as seen below.

// Java JavaMovieSandbox.java
void main() {  
    IO.println("Java Movie Sandbox");  
}

That is valid Java syntax just for the main method. This feature was added to “pave the on-ramp” for people learning Java. The idea is to reduce the number of concepts you have to learn at the same time. public class Main, static and String[] args are still there but can be introduced gently when they are needed. Everything in Java is still a class. The language does not support top-level functions.

The Kotlin main method looks very similar.

// Kotlin KotlinMovieSandbox.kt
package de.richargh.ktkonja.feature  
  
fun main() {  
    println("Kotlin Movie Sandbox")  
}

Kotlin has top-level functions. Unlike Java, where only the main method gets special treatment, you can write top-level functions anywhere in Kotlin.

How does that work on the JVM? On the JVM, Kotlin top-level functions are compiled to classes, because the JVM only supports classes. If we look at the bytecode of this code we see the following:

public final class de/richargh/ktkonja/feature/KotlinMovieSandboxKt {

  public final static main()V
}

Our KotlinMovieSandbox.kt file became a class KotlinMovieSandboxKt and the fun main became a public final static main inside that class.

Functions

In Java we can define functions inside a class with state or as static in a utility class. The latter looks like this:

// videostore/JUsers.java
public final class JUsers {  
    private JUsers(){}  
    public static boolean isDummyUser(String name){  
	    //        ^^^^                ^^^^^^^^^^
	    //        return type         parameters
        return name.startsWith("Dummy-");
    }  
}
// -----------------
// in some other file
import static videostore.JUsers.isDummyUser;

void main(){
	var isDummy = isDummyUser("Dummy-the-great");
}

Since Kotlin supports top-level functions, we can shorten the snippet to this:

// videostore/KUsers.kt
fun isDummyUser(name: String): Boolean {  
	//          ^^^^^^^^^^     ^^^^
	//          parameters     return type
    return name.startsWith("Dummy-")
}
// -----------------
// in some other file
import videostore.isDummyUser

fun main(){
	val dummyUser = isDummyUser("Dummy-the-great")
}

In Kotlin the type is always written after the field, value or method (this will be familiar to anyone who programs in TypeScript). Other than that, the declaration and invocation are very similar between Kotlin and Java.

It would be great if these examples could be written even more concisely. After all, all that the isDummyUser methods do is invoke a single line and return the result. The braces { } just add noise. Currently Java cannot improve here. There is, however, a draft proposal called concise method bodies that takes inspiration from how we declare lambdas and would add the ability to shorten the method to the following:

// videostore/JUsers.java
public final class JUsers {  
    private JUsers(){}  
    public static boolean isDummyUser(String name) ->  name.startsWith("Dummy-");
}

In Kotlin we already have a similar feature called “expression bodies”, where isDummyUser becomes:

// with explicit return type
fun isDummyUser(name: String): Boolean = name.startsWith("Dummy-")
// with return type inferred from startsWith
fun isDummyUser(name: String) = name.startsWith("Dummy-")

How does that work on the JVM? Like before, the top-level function just becomes a method public final static isDummyUser(Ljava/lang/String;)Z with a regular function body when translated to bytecode.

Kotlin runs on the JVM and is intended for interop with the platform. This is just one example of how interop is accomplished. We will see more when we talk about classes.

Classes

Java has classes. They look like this:

public class JRentalService {  
    private final Map<JRentalId, JRental> rentals; // <1>
  
    public JRentalService(
	    Map<JRentalId, JRental> rentals // <2>
	) {  
        this.rentals = rentals; // <3>
    }  
}

What is a bit tedious about Java classes is when we have multiple fields. First we have to declare them <1>, then pass their values as parameters <2> and finally assign them <3>. Kotlin benefits here from having 20 years of hindsight. These patterns were well-known when the language was designed, and Kotlin was able to make the declare-pass-assign pattern very convenient. In Kotlin the equivalent class looks like this:

class KRentalService(private val rentals: Map<KRentalId, KRental>)

We could have written public val rentals and the field would have been visible outside the class (public) but not reassignable (val). Note also that the class is not marked as public. The keyword exists, but in Kotlin public is the default visibility modifier and can be omitted, producing more compact code.

How does that work on the JVM? The compiler adds the final rentals field to the class, the constructor parameter and the assignment of the field in the constructor.

Next we want to create types to store our data, preferably as compactly as possible.

Data

Ever since JDK 16 (2021), data in Java is often stored inside records. So if we wanted to model the concept of a rental, we could write it like this:

record JRental(JRentalId id, JDay daysRented){}

In Kotlin we would model this with a data class:

data class KRental(val id: KRentalId, val daysRented: KDay)

If we wanted to ensure that the data passed to the constructor is valid, we would use the compact canonical constructor in Java:

record JDay(int rawValue){  
    JDay {  
        if(rawValue < 0)  
	        throw new IllegalArgumentException("Day must be >= 0");  
    }  
}

In Kotlin the equivalent is the init {} block:

data class KDay(val rawValue: Int){  
    init {  
        require(rawValue >= 0)  
    }  
}

How does that work on the JVM? On the JVM data classes are just classes with a constructor, constructor parameters, fields (rawValue), accessors (getRawValue()) as well as generated equals, hashCode, and toString methods. The init {} code is part of the regular constructor, right after the field assignment. This is not too different from records which in bytecode become classes that extends java/lang/Record.

It is worth noting that records and data classes are very similar but not quite the same. Both are about the data. They both provide auto-generated equals, hashCode, and toString methods. We compare them by value and they are equal if their values match:

// Java
void main() {  
    IO.println(new JDay(1).equals(new JDay(1))); // true
}

The same is true for Kotlin:

// Kotlin
fun main() {  
    println(KDay(1).equals(KDay(1))) // true
}

Records are “transparent carriers for immutable data”. Transparent, because all state has to be public. Anything we declare in the constructor is visible outside the class. Immutable, well, because we cannot change it (*unless the data we pass to the record is mutable).

These properties are true for the Kotlin data classes we have seen up to now, but we can also relax these constraints.

  • val is like final in Java: we cannot reassign the variable.
  • We also have the option of using var in Kotlin which, like Java, is a reassignable variable.
  • We can also declare a field private in Kotlin.

So we can also write mutable data classes that hide (part of) their data:

data class KRental(
	val id: KRentalId, // public, immutable
	private var daysRented: KDay // private, mutable
)

It depends a bit on the domain whether such a class is needed. In Domain-Driven Design, entities are often mutable, and if we make the data private, we force the business logic to be written inside the class. Other classes simply cannot see the data, so business logic cannot bleed into other classes. When everything is public, however, we can also implement business logic elsewhere.

Properties

When we declare something as val id or var daysRented it becomes a property of that (data) class. This is different from a field in Java, because properties allow us to change the implementation of that property without changing the Api of that (data) class.

For example, let us say our code should now track every day that the rental was rented (e.g. 2026-01-02, 2026-01-05, 2026-01-06) but daysRented should still return the number of consecutive days. In Kotlin we could change the constructor so it takes a list of rented days and add a property that aggregates the rented days from that list:

data class KRental(
	val id: KRentalId, 
	private val allDaysRented: List<LocalDate> // changed, now private List
){  
    val daysRented: KDay // a property
        get() = aggregateDaysRented(allDaysRented) // what to do on get
      
    private fun aggregateDaysRented(daysRented: List<LocalDate>): KDay {  
        // ...  
    }  
}

With this new design, the constructor changes, but all places that call daysRented still see the same Api and do not have to change.

We can access this property from Java like this:

Fee calculateFee(KRental rental){
	KDay daysRented = rental.getDaysRented(); // "getting" the property
	// ... 
}

In Kotlin it would look like this:

fun calculateFee(rental: KRental): Fee {
	val daysRented: KDay = rental.daysRented // "getting" the property
	// ...
}

How does that work on the JVM? Properties are auto-generated get/set methods that can set private fields (daysRented), return them, or return computed values. When the property is public and val, the compiler generates a “get” method (getDaysRented()). When it is public and var, a “set” method (setDaysRented(daysRented)) is generated in addition.

If we wanted to add a reassignable renterId that validates who can rent media, we could add a private renterId and declare a publicly settable property to change it:

data class KRental(
	val id: KRentalId, 
	val daysRented: KDay,
	private var internalRenterId: KRenterId
){  
  
    var renterId: KRenterId  
        get() = internalRenterId // "getter" returns the internal
        set(value) {  
            if(value.rawValue.startsWith("tmp"))  
                throw IllegalArgumentException("Temporary users cannot rent media")  
            // we only set the value, if all checks have passed
            internalRenterId = value  
        }
}

We can set this property from Java like this:

void changeRenter(KRental rental, KRenterId renterId){
	rental.setRenterId(renterId); // setting the property
	// ...
}

In Kotlin it would look like this:

fun changeRenter(rental: KRental, renterId: KRenterId) {
	rental.renterId = renterId // "setting" the property
	// ...
}

Syntactically, properties in Kotlin look like fields when accessed from Kotlin code, and map to getXyz and setXyz methods in Java. They provide more flexibility than fields, because we can change the implementation, take auxiliary action when a property is accessed and add validation without affecting the Api of the (data) class. So while public fields should never be used for anything but constants (see Oracle Tutorial or Effective Java 3rd Edition Item 16: In public classes, use accessor methods, not public fields), public properties can be.

In Java, the closest we get to properties are auto-generated record accessors. But records have more constraints than data classes do. While we can override the accessors of a record, we cannot change the type it returns or change how data is stored without also changing the constructor signature. The type of the internal storage and the external accessors are always in sync. The closest we can get for the daysRented get example is by adding another method that returns the old type and renaming the old parameter+accessor to allDaysRented:

record JRental(
	JRentalId id, 
	List<LocalDate> allDaysRented // change the data type in the constructor
){  
    KDay daysRented(){ // add method to return data in the old type
        return aggregateDaysRented(allDaysRented);  
    }  
  
    private KDay aggregateDaysRented(List<LocalDate> daysRented){  
        // ...  
    }  
}

Records do not allow reassignment of fields. Instead, with a record we can only return a new instance with the changed field. For the renterId set example it looks like this:

record JRental(JRentalId id, JDay daysRented, JRenterId renterId){
    JRental withRenterId(JRenterId renterId){
        return new JRental(id, daysRented, renterId); 
        //                                 ^^^^^^^^
        // return a new instance with one field changed
    }
}

We can of course also model data as regular Java classes and write the accessors ourselves, but then we lose the benefits of auto-generated equals, hashCode and toString methods, as well as record matching.

That concludes our introduction to both languages. Stylistically, Java and Kotlin are not that different, as if the latter was inspired by the former. There are some syntax things that might still trip people up, so let us address the syntax particularities quickly.

Syntax interlude

In the previous chapter we glossed over some syntax particularities of Kotlin to get through the introduction faster. Let us take a breather and address the most obvious ones.

  • var and val: class fields or inline variables can be declared as var (~= variable) or val (~= value). val is preferred over var because immutable data is easier to understand. If we want, we can also declare the type inline and override the type inference, e.g. val myList: List<String> = arrayListOf(...).
  • new: Kotlin does not have the new keyword. Instances are created by calling the constructor as usual but we cannot write new before the call. This will be familiar to anyone who programs for example in Python.
  • fun: in Kotlin methods need to be declared via the keyword fun(ction). TypeScript uses function, Rust uses fn and Swift uses func. All of them are type-postfix languages.
  • semicolons: in Kotlin, semicolons are optional. You can write ; at the end of a line but do not have to, so it is idiomatic to leave them out.
  • public is the default visibility modifier and can be omitted.
  • Unit: Kotlin does not have the keyword void. Instead, it has the type Unit. Both compile to the Void type in bytecode.
  • Implicits: when a function returns Unit, we can omit the type. When a (data) class has nothing in the body, we can omit { }. When a lambda only has one parameter, we do not have to declare it explicitly but can access it via the implicit variable it.
  • Int not int: in Kotlin, primitive types are declared with an upper case letter. Still compiles to i(nt) in bytecode most of the time. The boxed Integer type is automatically used only where necessary: for nullable ints or generics like List<Integer>.
  • fun over static: since Kotlin has top-level functions, the language makes heavy use of them. In Java we would create a list via the static method List.of(1, 2, 3), in Kotlin we use listOf(1, 2, 3).
  • override is a keyword: in Java we mark methods we override with an optional @Override annotation. In Kotlin it is a required keyword.

Are any of these a deal-breaker? I hope not. It is just something to get familiar with, and in my experience, Java developers get the hang of it pretty quickly.

Are any of these a reason to pick Kotlin? Not by themselves. These particularities blend nicely with other language features such as null-safety. They also interop with the platform quite nicely.

Interop

Kotlin has great (dare I say near-perfect) interop with the Java platform. Typically, we put Kotlin into src/main/kotlin and we can then call any src/main/java code. The Java code can also call Kotlin code. One team I know rewrote their domain-heavy modules in Kotlin, and left supporting or generic modules in Java. One reason that this works so well (besides all the work the compiler does behind the scenes) is that Kotlin builds on the Java standard library.

Standard Library

A List<> in Kotlin is a List<> in Java, a Kotlin Map<> is a Java Map<>. We can return it from Kotlin methods or pass it to Java methods, and no conversion is necessary.

Extension functions

Kotlin can even add features to the standard library through a feature called extension functions, which allow adding functions to any type (C# developers might be familiar with this). Lists for example have various convenience methods like .map or flatMap in Kotlin that they do not have in Java. We can even define them ourselves for example to map an internal rental object to an external Dto (data transfer object).

fun KRental.toDto(): KRentalDto { // <1>
    return KRentalDto(  
        this.id.rawValue,  
        this.daysRented.rawValue  
    )  
}

fun KRentalDto.toDomain(): KRental { 
    return KRental(  
        KRentalId(this._id),  
        KDay(this.daysRented)  
    )  
}

fun getRental(/*...*/): KRentalDto {
	// ... even more code here
	val dto = aRental.toDto() // <2>
	return dto
}


KRental lives in the domain layer and KRentalDto in the api layer, and should not have any dependencies on each other. Since the mapping functions need to know about both types, they have to live on a layer further up that can see both domain and api. Extension functions <1> let us define in the mapping layer that toDto is a new function on the type KRental, and then we can <2> use it as if it was part of the type from the beginning.

The experience in an IDE is the same. We can type . after aRental and it shows us all the functions that are possible on the type. We can discover the method, even if we did not know about it before.

How does that work on the JVM? Extension functions are, like top-level functions, just regular static functions in bytecode. The object they extend is passed as the first argument.

We can even add extension functions to primitive types. For example, we can add the .days() function to any Int, which will transform it into the type Duration (val limit: Duration = 10.days()). The resulting Duration can only be compared with other Durations as seen in the following expressive and type-safe code:

fun isOverdue(duration: Duration): Boolean = duration > 10.days()

The previous example compares objects via operator > as if they were primitive numbers. This is possible because Kotlin supports operator overloading.

Operator overloading

Any type that implements Comparable<T> can be compared with <, >, ==, <=, >=, which means we can also compare custom types like KDay, if we implement compareTo:

data class KDay(val rawValue: Int): Comparable<KDay> {  
    override operator fun compareTo(other: KDay): Int {  
        return rawValue.compareTo(other.rawValue)  
    }
    // ...
}

fun test(){
	KDay(0) > KDay(1) // false
	KDay(42) <= KDay(42) // true
}

Operator overloading in Kotlin works with the keyword operator and a semantic name like compareTo or plus. We can also add a plus function.

data class KDay(val rawValue: Int): Comparable<KDay> {  
	// ...
    operator fun plus(other: KDay): KDay {  
        return KDay(this.rawValue + other.rawValue)  
    }
    // ...
}

// and somewhere else
fun totalRentedDays(rental1: KRental, rental2: KRental): KDay {  
    return rental1.daysRented + rental2.daysRented  
}

We can call that function from Java as well:

var oneDay = new KDay(0);
var otherDay = new KDay(1);
var result = oneDay.plus(otherDay);

How does that work on the JVM? Operators are regular methods on a class. If we define the operator plus() then the class KDay gets a method plus(). Additional metadata in the bytecode declares plus() as an operator to other Kotlin code.

Now, operator overloading often gets a bad rap. Java famously does not have it because James Gosling (creator of Java) knew from C++ that it was easy to misuse (it might come back with Project Valhalla though, as it is useful to compare primitive value types).

I think it works well in Kotlin because we do not directly overload the operator, we overload a semantic name. We overload div not /. That gives us a semantic test: We can speak the code and check if it makes sense. rental divided by rental makes no sense and should not use operator-overloading. day plus day however is a candidate.

One aspect of operator-overloading is that == maps to the .equals method in Java. So in Kotlin we always compare with double-equals, even when we compare Java records:

fun isSame(oneDay: JDay, otherDay: JDay): Boolean {  
    return oneDay == otherDay // works even for comparing J(ava) Day objects
}

Kotlin takes the same approach for List<>.contains/Map<>.containsKey which map to the operator in and !in respectively. So in Kotlin we can write:

fun isReturned(
	rentalId: KRentalId, 
	allRentals: Map<KRentalId, KRental>
): Boolean {  
    return rentalId !in allRentals  
    // equivalent of:
    // !allRentals.contains(rentalId)
}

I find rentalId is not in allRentals reads better than the Java equivalent of not allRentals contains rentalId. The same benefit applies to the other operator overloads. The operators offer less cluttered and clearer code, provided they make semantic sense.

The operators also show how interop with Java is handled. Wherever possible, the language builds on the existing language features and does not do “its own thing”. This is true also for how the language handles immutability.

This covers the most common interop situations. There are of course also features that do not map so well between the languages, but they do not come up often for application developers. One that stands out is that Kotlin does not have static methods, so any framework that expects static methods needs a workaround with a so called companion object and a @JvmStatic annotation. Another is when we utilise serialisation libraries that are not Kotlin-aware, because they do not respect Kotlin nullability annotations (see part 4). There are more things to be aware of as a library developer, but they are beyond the scope of this article.

Immutability

Languages can be made safer by providing support for immutability, specifically the immutability of data references. That is, if we mutate the data, we get back a new reference, while code that has the old reference sees no changes. Collections in the standard library of Clojure (a JVM language), for example, always return an efficient and performant copy upon modification. That makes it easier to reason about code because separate code elements cannot modify each other’s data, and we only need to reason about one control flow, not all branches as well.

Interestingly, immutability is not only good for programmers but also for the JVM. If all data is mutable, then we either cannot do aggressive bytecode optimisations (e.g. escape analysis, constant folding), or we need to periodically check if the optimisations are still valid for the current data. This is even more problematic in highly concurrent applications where different (virtual) threads can potentially modify each other’s data.

It is thus no surprise that Java has introduced records, where all constructor parameters are final, meaning they cannot be reassigned. For primitive types, that means the values are also immutable. For reference types (classes, records, collections), actual immutability depends on (1) the final guarantees that the JVM provides and (2) the implementation of that type.

  1. A class with a private final int number field can actually be reassigned via deep reflection (see “Destructuring is the future of Java’s Encapsulation”, to be published in May 2026 at JavaPro.io) because final does not always mean final on the JVM (right now).
  2. A record with a non-reassignable parameter of type List<T> is not itself immutable, because its parameter List<T> can be mutated with (amongst others) the add() method.

Kotlin cannot change either of these because it runs on the JVM and has decided to adopt the Java standard library. Yet it still provides a lot of support when it comes to collections.

Collections

We often have the case where we construct a List in one place and pass it to other places in the code. Along the way, the contents should not be mutated. In Java we can use List.of (or Collections.unmodifiableList(aList)) to create unmodifiable collections to model this situation, and they do their job at runtime. Any mutation of such a list will result in an UnsupportedOperationException. But this can lead to some surprising situations, because there is nothing in the type system to indicate that we are not allowed to modify the list. The add, remove etc. methods all still exist.

List<String> aList = List.of("a", "b"); 
// ... pass list to other places in the code
aList.add("c");
//    ^^^
//    still exists and will throw at runtime

Kotlin takes a different approach and separates mutating actions from reading actions directly in the interface. In Kotlin there is List which has reading actions like get, and MutableList which has mutating actions like add. In Java they both map to the java.util.List interface, so once you pass it to Java code, all the actions are visible again. But in Kotlin you now get the benefit of compile-time safety for immutable lists:

val immutableList: List<String> = listOf("a", "b")  
// ... pass list to other places in the code
immutableList.add("c")
//            ^^^
//            does not compile

If we want a mutable list, we have to be explicit about it.

val mutableList: MutableList<String> = mutableListOf("a", "b") // <1>
// ... pass list to other places in the code
mutableList.add("c") // compiles

We have to use mutableListOf for construction <1> and the result will be a list of type MutableList which has the mutating add method.

Because MutableList extends List we can even pass a mutableListOf() where a List is required. But in that case there is nothing preventing other Kotlin code from casting the List back to MutableList and using the add() method. Here we do not have runtime safety (the underlying implementation allows add()) and can only signal our intent to other code elements, which is still very valuable.

How does that work on the JVM? The bytecode shows regular java/util/List types both for immutableList and mutableList. The Kotlin compiler stores the additional type info in binary format with a @Lkotlin/Metadata annotation at the top of the class file, so other Kotlin code knows the first list is immutable. The annotation format seems to be Protobuf and can be parsed with the kotlin-metadata-jvm library. At runtime the immutableList is actually immutable — even to Java code — because listOf() returns java.util.Arrays$ArrayList, which is fixed size and throws when calling add(). mutableListOf() returns a java.util.ArrayList which has an implemented add() method.

Language for Interop Summary

Should we pick Java or Kotlin? Why not both? In the opening chapter we saw that Kotlin’s syntax is close enough to Java’s that most concepts — top-level functions, data classes, properties — feel like natural extensions rather than foreign ideas. The chapter on interop took a deep dive into how Kotlin interops with the Java platform and showed that most new concepts map to regular classes and (static) methods in bytecode. The chapter on immutability showed how Kotlin can have immutable collections, even though it uses Java’s standard library, by splitting reading and mutating actions at the metadata type level.

In short, there is little technical preventing us from trying Kotlin in a codebase, even having it interact with existing Java code, and then either migrating more code (as needed) to Kotlin or going back to Java completely. “Why not both” is a genuine option, either as a fixed choice for some Kotlin modules (e.g. to share them with another platform, see part 2 of this series) or as an iterative migration path for large codebases. But if we do not need Kotlin’s multiplatform capabilities, why would we migrate to Kotlin at all? That is what part 4 aims to answer, by exploring the business modelling capabilities of Java and Kotlin.

Total
0
Shares
Previous Post

Java 26 in Practice: How the JVM Is Changing the Way We Write Code

Next Post

Java Developers, You’re Already Ready for Blockchain — You Just Don’t Know It Yet

Related Posts