Kotlin on the JVM: An Honest Assessment - Hero image

Kotlin on the JVM: An Honest Assessment

The JVM has seen plenty of “Java killers” since the late 1990s. From the early days of Java 1.2, through the EJB dark ages, the Spring revolution, and the microservices era, languages have come and gone. Scala was going to replace Java. Groovy was going to replace Java. Clojure would bring enlightenment. Each found its niche but none displaced Java at scale.

Kotlin is different. Not because it’s technically superior to all alternatives (it isn’t), but because it solves the right problems in the right way for the largest segment of JVM developers. Here’s an honest assessment of where Kotlin genuinely excels, where it merely matches alternatives, and where it still falls short.

The Genuine Advantages

1. Null Safety That Actually Works

This is Kotlin’s single most important feature, and it’s not close. After decades of NullPointerException being Java’s billion-dollar mistake, Kotlin makes null handling explicit at the type level.

// Kotlin: The compiler enforces null safety
fun processUser(user: User?) {
    // Won't compile: user might be null
    // println(user.name)

    // Must handle null explicitly
    println(user?.name ?: "Unknown")

    // Or assert non-null (throws if null)
    println(user!!.name)

    // Or smart cast after check
    if (user != null) {
        println(user.name)  // Compiler knows user is non-null here
    }
}

Compare to Java’s approach:

// Java: Null safety is opt-in via annotations, not enforced
public void processUser(@Nullable User user) {
    // Compiles fine, crashes at runtime
    System.out.println(user.getName());

    // Optional helps but is verbose
    Optional.ofNullable(user)
        .map(User::getName)
        .ifPresent(System.out::println);
}

Java’s Optional helps, but it’s not enforced by the compiler and adds ceremony. Kotlin’s approach eliminates entire categories of bugs without runtime overhead—the null checks compile to the same bytecode you’d write manually in Java.

Scala has Option[T], which is arguably more principled (it’s a proper monad), but Kotlin’s approach is more pragmatic for developers coming from Java.

2. Data Classes Eliminate Boilerplate

Java developers have written millions of lines of getters, setters, equals(), hashCode(), and toString() methods. Lombok helped. Java 14+ records helped more. But Kotlin had this solved from day one:

// Kotlin: One line
data class Customer(
    val id: Long,
    val name: String,
    val email: String,
    val tier: CustomerTier = CustomerTier.STANDARD
)

// Automatically generates:
// - Constructor
// - Getters (properties)
// - equals() and hashCode()
// - toString()
// - copy() for immutable updates
// - componentN() for destructuring
// Java 17+ record (much improved)
public record Customer(
    Long id,
    String name,
    String email,
    CustomerTier tier
) {
    public Customer {
        if (tier == null) tier = CustomerTier.STANDARD;
    }
}

Java records have closed the gap significantly, but Kotlin’s data class still offers more flexibility—you can inherit from them (with care), add mutable properties when needed, and use copy() for immutable updates:

val updated = customer.copy(tier = CustomerTier.PREMIUM)

3. Extension Functions: Augment Without Inheriting

Extension functions let you add methods to existing classes without inheritance or wrapper patterns:

// Add a function to String
fun String.toSlug(): String =
    this.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(Regex("\\s+"), "-")
        .trim('-')

// Use it naturally
val slug = "Hello, World!".toSlug()  // "hello-world"

This isn’t magic—it compiles to a static method with the receiver as the first parameter. But the ergonomic improvement is substantial. You can extend library classes, Java classes, even nullable types:

fun String?.orEmpty(): String = this ?: ""

val name: String? = null
println(name.orEmpty())  // ""

Scala has implicit classes which achieve similar results but with more complexity. Groovy has runtime metaprogramming which is more powerful but less safe.

4. Coroutines: Structured Concurrency Done Right

Kotlin’s coroutines provide async/await-style concurrency without the callback hell or the complexity of reactive streams:

// Suspend function: can be paused and resumed
suspend fun fetchUserData(userId: Long): UserData {
    val profile = async { userService.getProfile(userId) }
    val orders = async { orderService.getOrders(userId) }
    val preferences = async { prefService.getPreferences(userId) }

    // All three requests run concurrently
    return UserData(
        profile = profile.await(),
        orders = orders.await(),
        preferences = preferences.await()
    )
}

// Structured concurrency: if one fails, others are cancelled
coroutineScope {
    val users = userIds.map { id ->
        async { fetchUserData(id) }
    }
    users.awaitAll()
}

Compare to Java’s Project Loom virtual threads (Java 21+):

// Java 21: Virtual threads are simpler but less structured
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    var profileFuture = executor.submit(() -> userService.getProfile(userId));
    var ordersFuture = executor.submit(() -> orderService.getOrders(userId));
    var prefsFuture = executor.submit(() -> prefService.getPreferences(userId));

    return new UserData(
        profileFuture.get(),
        ordersFuture.get(),
        prefsFuture.get()
    );
}

Java’s virtual threads are excellent and simpler for basic cases. But Kotlin coroutines provide structured concurrency, cancellation propagation, and context preservation that require additional libraries in Java.

5. Pragmatic Functional Programming

Kotlin doesn’t force functional programming but makes it convenient:

// Collection operations are concise and readable
val activeCustomers = customers
    .filter { it.status == Status.ACTIVE }
    .sortedByDescending { it.lastOrderDate }
    .take(10)
    .map { CustomerSummary(it.id, it.name) }

// Scope functions for fluent object configuration
val client = HttpClient().apply {
    timeout = Duration.ofSeconds(30)
    retries = 3
    baseUrl = "https://api.example.com"
}

// let for null-safe transformations
val length = nullableString?.let { it.trim().length }

// Sealed classes for exhaustive when expressions
sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure(val error: Throwable) : Result<Nothing>()
}

fun handleResult(result: Result<User>) = when (result) {
    is Result.Success -> println("Got user: ${result.value}")
    is Result.Failure -> println("Error: ${result.error.message}")
    // No else needed: compiler knows these are exhaustive
}

Scala is more powerful for pure functional programming (higher-kinded types, typeclasses, effect systems). But for developers who want functional convenience without a paradigm shift, Kotlin hits a sweet spot.

Where Kotlin Matches But Doesn’t Exceed

Smart Casts

Kotlin’s smart casts are elegant:

fun process(value: Any) {
    if (value is String) {
        // Compiler knows value is String here
        println(value.uppercase())
    }
}

But Java 17+ has pattern matching that’s nearly equivalent:

void process(Object value) {
    if (value instanceof String s) {
        System.out.println(s.toUpperCase());
    }
}

Expression-Bodied Functions

Kotlin:

fun double(x: Int) = x * 2

This is nice, but Java developers using modern practices achieve similar conciseness. The advantage is modest.

String Templates

val message = "Hello, $name! You have ${orders.size} orders."

Java 21+ has string templates (preview), and even without them, String.format() or MessageFormat work fine. Convenient but not transformative.

Where Kotlin Falls Short

Compilation Speed

Kotlin compiles slower than Java. For large projects, this is noticeable. Incremental compilation helps, but clean builds take significantly longer. Gradle’s build cache and Kotlin’s incremental compilation mitigate this, but it remains a real cost.

Tooling Maturity

IntelliJ IDEA’s Kotlin support is excellent (JetBrains created both). But other tools have gaps:

  • Static analysis tools have fewer rules than for Java
  • Some annotation processors don’t work correctly with kapt
  • Build tool integration occasionally has edge cases

Library Ecosystem

Most JVM libraries are written in Java and work fine from Kotlin. But APIs designed for Java sometimes feel awkward in Kotlin:

  • Builder patterns that Kotlin’s named parameters make unnecessary
  • Null annotations that Kotlin doesn’t fully trust
  • Callbacks where coroutines would be cleaner

Libraries written for Kotlin (like Ktor and Exposed) are delightful. But you’ll often wrap or adapt Java libraries.

Learning Curve for Teams

Kotlin is easy to learn at a basic level. Experienced Java developers can be productive in days. But mastering coroutines, inline functions, reified generics, DSL construction, and delegation takes months. Teams adopting Kotlin should expect a learning investment.

Binary Compatibility

Kotlin’s standard library and compiler updates can break binary compatibility. This matters less for applications but can be painful for library authors. The Kotlin/Binary compatibility validator helps, but it’s additional overhead.

Kotlin vs The Alternatives

Kotlin vs Java (17+)

Modern Java has closed many gaps: records, sealed classes, pattern matching, virtual threads. If your team is productive in Java, switching to Kotlin provides incremental benefits, not transformative ones.

Choose Kotlin when: null safety is critical, you value conciseness, you need structured concurrency, or you’re starting a new project.

Stick with Java when: compilation speed matters, you need maximum tooling compatibility, or your team is already highly effective.

Kotlin vs Scala

Scala is more powerful: higher-kinded types, implicits (now given/using), sophisticated type system, Cats/ZIO effect systems. Scala developers solve problems in Kotlin developers’ future.

But Scala’s power comes with costs: steeper learning curve, slower compilation, complex error messages, multiple competing paradigms (Scala 2 vs 3, Cats vs ZIO vs vanilla).

Choose Kotlin when: you want functional features without paradigm shift, team onboarding time matters, or you prioritise simplicity.

Choose Scala when: you need advanced type-level programming, you’re building complex domain models, or your team embraces pure functional programming.

Kotlin vs Groovy

Groovy offers runtime metaprogramming, DSL construction, and scripting capabilities that Kotlin can’t match. Gradle build scripts, Spock testing, and Jenkins pipelines all leverage Groovy’s dynamic nature.

But Groovy’s dynamic typing means errors appear at runtime rather than compile time. Performance is harder to optimise. IDE support, while improved, doesn’t match statically-typed languages.

Choose Kotlin when: type safety is important, runtime performance matters, or you want IDE assistance.

Choose Groovy when: you need scripting, DSL construction, or integration with Gradle/Jenkins. Consider using Groovy for build scripts and Kotlin for application code.

Kotlin vs Clojure

Clojure is a different philosophy entirely: Lisp syntax, immutable data structures by default, REPL-driven development, emphasis on simplicity over ease. Clojure developers often report breakthroughs in how they think about problems.

But Clojure requires embracing a different paradigm. Hiring is harder. Interop with Java libraries requires ceremony. The ecosystem is smaller.

Choose Kotlin when: you want improved Java, gradual adoption, or mainstream tooling.

Choose Clojure when: you value simplicity, immutability is central to your domain, or you want REPL-driven development.

Real-World Adoption Considerations

Android Development

Kotlin is the default for Android. Google recommends Kotlin-first, and new APIs often appear in Kotlin before Java. If you’re doing Android development, Kotlin isn’t just advantageous—it’s effectively required.

Server-Side Development

For server-side JVM, Kotlin competes with Java directly. The Spring Framework fully supports Kotlin, as do Micronaut and Quarkus. Ktor is Kotlin-native and excellent.

Adoption is growing but Java remains dominant. Greenfield projects increasingly choose Kotlin; existing Java codebases rarely migrate wholesale.

Multiplatform

Kotlin Multiplatform allows sharing code between JVM, JavaScript, iOS, and native targets. This is genuinely unique among JVM languages. If you need to share business logic across platforms, Kotlin Multiplatform is compelling.

Conclusion

Kotlin is the best general-purpose JVM language for most teams in 2026. Not because it’s revolutionary—null safety and data classes aren’t new ideas—but because it integrates these improvements into a cohesive, pragmatic package that Java developers can adopt incrementally.

The genuine advantages are:

  1. Null safety that prevents bugs without ceremony
  2. Concise syntax that reduces boilerplate without sacrificing readability
  3. Coroutines that simplify async programming
  4. Java interoperability that makes adoption gradual

The honest caveats are:

  • Compilation is slower
  • Some tooling lags behind Java
  • Advanced features have a learning curve

For new projects, especially on Android or when null safety is critical, Kotlin is the right choice. For existing Java codebases, evaluate the migration cost against the benefits—incremental adoption through new modules is often the sensible path.

Java remains the right tool for certain situations. But for most new work, Kotlin has become the sensible default. Not because it’s perfect, but because it’s better often enough to matter.


Further Reading


Considering Kotlin for your next JVM project? Contact Steele O’Brien Consulting for an objective assessment of whether Kotlin fits your team and requirements.

Back to Blog