Post

Kotlin vs Java in 2026: A 17-Year Java Developer's Honest Take

Not a fanboy post for either language — a practical comparison of where each genuinely wins, where Java has caught up, and how to choose for your next project

Kotlin vs Java in 2026: A 17-Year Java Developer's Honest Take

My Bias, Stated Upfront

I’ve been writing Java professionally since 2008. I started using Kotlin in 2019 for an Android project, then adopted it for backend services in 2021. I currently work with both daily — Java for legacy systems and new Spring Boot services, Kotlin for a couple of greenfield projects.

I like both languages. This isn’t a “Java is dead” post or a “Kotlin is just hype” post. It’s an honest assessment of where each language stands in 2026, based on building real systems with both.

Where Kotlin Genuinely Wins

Let me start with areas where Kotlin remains clearly superior, even against Java 21+.

Null Safety

This is still Kotlin’s biggest practical advantage. Not because Java can’t handle nulls, but because Kotlin makes null handling a compile-time concern rather than a runtime surprise.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Kotlin: the type system prevents null issues at compile time
fun processOrder(order: Order): Invoice {
    // order.customer is Customer? (nullable)
    val customerName = order.customer?.name ?: "Unknown"
    
    // This won't compile - can't call methods on nullable without ?. or !!
    // val email = order.customer.email  // Compile error!
    
    val email = order.customer?.email
        ?: throw CustomerEmailRequiredException(order.id)
    
    return createInvoice(order, customerName, email)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Java 21: you can use Optional and patterns, but it's voluntary
public Invoice processOrder(Order order) {
    // Nothing stops you from doing order.getCustomer().getName()
    // and getting an NPE at runtime
    
    String customerName = Optional.ofNullable(order.getCustomer())
        .map(Customer::getName)
        .orElse("Unknown");
    
    String email = Optional.ofNullable(order.getCustomer())
        .map(Customer::getEmail)
        .orElseThrow(() -> new CustomerEmailRequiredException(order.getId()));
    
    return createInvoice(order, customerName, email);
}

Java’s approach is opt-in. A tired developer at 6 PM can skip the null check and the compiler won’t complain. Kotlin’s compiler forces the conversation at every nullable boundary. After 3 years of Kotlin, I’ve had exactly zero NPEs in production. My Java services still get occasional ones.

Coroutines vs Virtual Threads

Both solve the “blocking I/O at scale” problem, but they solve it differently:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Kotlin coroutines: structured concurrency built into the language
suspend fun enrichOrder(order: Order): EnrichedOrder {
    // Parallel calls with structured concurrency
    return coroutineScope {
        val customerDeferred = async { customerService.getCustomer(order.customerId) }
        val inventoryDeferred = async { inventoryService.checkAvailability(order.items) }
        val pricingDeferred = async { pricingService.calculateTotal(order.items) }
        
        EnrichedOrder(
            order = order,
            customer = customerDeferred.await(),
            availability = inventoryDeferred.await(),
            pricing = pricingDeferred.await()
        )
    }
    // If any async call fails, all others are automatically cancelled
    // Structured concurrency means no resource leaks
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Java 21: virtual threads + structured concurrency (preview)
public EnrichedOrder enrichOrder(Order order) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var customerFuture = scope.fork(() -> 
            customerService.getCustomer(order.customerId()));
        var inventoryFuture = scope.fork(() -> 
            inventoryService.checkAvailability(order.items()));
        var pricingFuture = scope.fork(() -> 
            pricingService.calculateTotal(order.items()));
        
        scope.join().throwIfFailed();
        
        return new EnrichedOrder(
            order,
            customerFuture.get(),
            inventoryFuture.get(),
            pricingFuture.get()
        );
    }
}

Kotlin’s coroutines are more mature and have better library ecosystem support (Ktor, kotlinx.coroutines, Flow). Java’s structured concurrency is still in preview (as of Java 23). But Java is closing the gap — once StructuredTaskScope is finalized, the practical difference narrows significantly.

Kotlin’s edge: Coroutine support is baked into the standard library and frameworks. Suspend functions compose naturally. Java’s approach requires explicit scope management.

DSLs and Type-Safe Builders

Kotlin’s language features make internal DSLs genuinely pleasant:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Kotlin DSL for test data building
val testOrder = order {
    customer {
        name = "John Doe"
        email = "john@example.com"
        tier = CustomerTier.PREMIUM
    }
    items {
        item {
            sku = "LAPTOP-001"
            quantity = 1
            unitPrice = 1299.99
        }
        item {
            sku = "MOUSE-003"
            quantity = 2
            unitPrice = 49.99
        }
    }
    shipping = ShippingMethod.EXPRESS
}

// This reads like a specification, not code

Java simply can’t do this. The builder pattern in Java is verbose:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Java: functional but not as readable
var testOrder = Order.builder()
    .customer(Customer.builder()
        .name("John Doe")
        .email("john@example.com")
        .tier(CustomerTier.PREMIUM)
        .build())
    .items(List.of(
        OrderItem.builder().sku("LAPTOP-001").quantity(1).unitPrice(1299.99).build(),
        OrderItem.builder().sku("MOUSE-003").quantity(2).unitPrice(49.99).build()
    ))
    .shipping(ShippingMethod.EXPRESS)
    .build();

For test fixtures, configuration builders, and infrastructure-as-code, Kotlin DSLs are a clear win.

Extension Functions

1
2
3
4
5
6
7
8
9
10
11
12
13
// Add functionality to existing classes without inheritance
fun String.toSlug(): String = 
    this.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(Regex("\\s+"), "-")
        .trim('-')

fun BigDecimal.formatAsCurrency(currency: Currency = Currency.SGD): String =
    "${currency.symbol}${this.setScale(2, RoundingMode.HALF_UP)}"

// Usage reads naturally
val title = "My Blog Post Title!".toSlug()  // "my-blog-post-title"
val price = order.total.formatAsCurrency()   // "S$1,299.99"

Java has no equivalent. You’d use static utility methods, which work but don’t chain as naturally.

Where Java 21+ Has Caught Up

Now let me be honest about where Java has closed the gap significantly.

Records vs Data Classes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Java 21: Records are excellent
public record OrderDto(
    Long id,
    String customerName,
    BigDecimal total,
    OrderStatus status,
    LocalDateTime createdAt
) {
    // Compact constructor for validation
    public OrderDto {
        Objects.requireNonNull(customerName, "customerName cannot be null");
        if (total.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("total cannot be negative");
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
// Kotlin: Data classes
data class OrderDto(
    val id: Long,
    val customerName: String,
    val total: BigDecimal,
    val status: OrderStatus,
    val createdAt: LocalDateTime
) {
    init {
        require(total >= BigDecimal.ZERO) { "total cannot be negative" }
    }
}

These are roughly equivalent now. Java records are immutable by design. Kotlin data classes offer copy() for modified copies. Both generate equals(), hashCode(), and toString(). The gap that existed in Java 8-16 is gone.

Sealed Classes and Pattern Matching

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Java 21: Sealed classes + pattern matching
public sealed interface PaymentResult permits 
    PaymentSuccess, PaymentFailed, PaymentPending {
}

public record PaymentSuccess(String transactionId, BigDecimal amount) 
    implements PaymentResult {}
public record PaymentFailed(String errorCode, String message) 
    implements PaymentResult {}
public record PaymentPending(String referenceId, Duration estimatedWait) 
    implements PaymentResult {}

// Exhaustive pattern matching
public String handlePayment(PaymentResult result) {
    return switch (result) {
        case PaymentSuccess s -> "Paid: " + s.transactionId();
        case PaymentFailed f -> "Failed: " + f.message();
        case PaymentPending p -> "Pending: check back in " + p.estimatedWait();
        // Compiler ensures all cases are covered!
    };
}
1
2
3
4
5
6
7
8
9
10
11
12
// Kotlin: sealed classes + when
sealed interface PaymentResult {
    data class Success(val transactionId: String, val amount: BigDecimal) : PaymentResult
    data class Failed(val errorCode: String, val message: String) : PaymentResult
    data class Pending(val referenceId: String, val estimatedWait: Duration) : PaymentResult
}

fun handlePayment(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Paid: ${result.transactionId}"
    is PaymentResult.Failed -> "Failed: ${result.message}"
    is PaymentResult.Pending -> "Pending: check back in ${result.estimatedWait}"
}

Kotlin had this first, and the syntax is slightly more concise. But Java’s implementation is solid and the compiler enforces exhaustiveness. For practical purposes, both accomplish the same goal equally well.

Text Blocks and String Handling

1
2
3
4
5
6
7
8
// Java: Text blocks (since Java 15)
String query = """
    SELECT o.id, c.name, o.total
    FROM orders o
    JOIN customers c ON o.customer_id = c.id
    WHERE o.status = ?
    ORDER BY o.created_at DESC
    """;

Kotlin still wins with string templates for dynamic content:

1
2
3
4
5
6
7
8
// Kotlin: string templates are more flexible
val query = """
    SELECT o.id, c.name, o.total
    FROM orders o
    JOIN customers c ON o.customer_id = c.id
    WHERE o.status = '${status.name}'
    ORDER BY o.created_at DESC
""".trimIndent()

Java’s string templates (JEP 459) were withdrawn and are being redesigned. Until they land, Kotlin has the edge for string interpolation.

The Hiring Reality

This matters more than language features in most business decisions.

Java developers in Singapore (2026):

  • Abundant supply across all experience levels
  • Average 2-3 weeks to fill a senior role
  • Salary range well-established
  • Every developer has at least basic Java exposure

Kotlin developers in Singapore (2026):

  • Growing but still a subset of Java developers
  • Average 4-6 weeks to fill a senior role
  • Salary premium of 10-15% (supply/demand)
  • Most come from Android background, fewer with backend Kotlin experience

My experience: For my current team of 6, finding strong Java developers took 2-3 weeks each. When we tried to hire specifically for Kotlin backend, it took 8 weeks and we had 60% fewer applicants.

If you’re a startup that needs to scale the team quickly, Java’s hiring pool is a genuine advantage. If you’re a smaller team that can be selective, Kotlin’s hiring pool tends to be more experienced (self-selected for curiosity and growth mindset).

Team Dynamics and Mixed Codebases

Here’s something nobody talks about in language comparison posts: the reality of mixed teams.

Scenario 1: Java team adopting Kotlin

I’ve seen this twice. The pattern is predictable:

  • Senior developers love it (less boilerplate, more expressive)
  • Mid-level developers struggle for 2-3 months (new idioms, coroutines confusion)
  • Junior developers are overwhelmed (learning two languages simultaneously)

Ramp-up cost: 2-3 months of reduced productivity per developer.

Scenario 2: Kotlin team maintaining Java legacy

Also predictable:

  • Everyone can read Java (it’s a subset of what they know)
  • Nobody wants to write new Java code (feels regressive)
  • Legacy Java services become “orphaned” — nobody volunteers for maintenance

Scenario 3: Mixed codebase in one project

This works only if you have clear boundaries. Kotlin for new modules, Java for existing. Never mix in the same module — the build complexity and IDE experience degrades.

1
2
3
4
5
6
7
8
9
10
11
12
13
// build.gradle.kts - mixed project setup
plugins {
    kotlin("jvm") version "2.1.0"
    kotlin("plugin.spring") version "2.1.0"
    id("org.springframework.boot") version "3.4.0"
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}

// This works, but you're carrying both compilation pipelines

My Decision Framework

After using both in production, here’s when I choose each:

Choose Kotlin When:

  • Greenfield project with a team that knows or wants to learn Kotlin
  • Android development — Kotlin is the clear winner, no debate
  • Heavy async/concurrent workloads where coroutines shine
  • DSL-heavy projects (test frameworks, configuration, infrastructure)
  • Small, experienced team that values expressiveness over familiarity

Choose Java When:

  • Large team (15+) where hiring speed matters
  • Existing Java codebase that’s well-maintained (don’t rewrite for the sake of it)
  • Enterprise environment with strict technology governance
  • Team with mixed experience levels — Java’s explicitness helps juniors
  • Spring Boot primary stack — Spring’s Java support is always first-class (Kotlin support is excellent but sometimes lags by a release)
  • GraalVM native images — Java has fewer gotchas with native compilation than Kotlin

Choose Either When:

  • Spring Boot backend services (both have excellent support)
  • Microservices (both work equally well)
  • Library development (both compile to JVM bytecode)

The Features I Miss in Each

When writing Java, I miss from Kotlin:

  • Null safety (the biggest one)
  • Extension functions
  • when expressions (Java’s switch is close but less elegant)
  • Coroutines (virtual threads are coming, but not as polished yet)
  • Named arguments and default parameters

When writing Kotlin, I miss from Java:

  • Faster compilation (Kotlin is noticeably slower to compile)
  • Simpler build setup (no kotlin-reflect, no allopen plugin for Spring)
  • Better static analysis tooling (SpotBugs, Error Prone have no Kotlin equivalents)
  • Slightly better IDE performance (IntelliJ is optimized for Java first)
  • Clearer stack traces (Kotlin coroutines produce confusing stack traces)

The 2026 Reality Check

Java in 2026 is not the Java of 2015. Records, sealed classes, pattern matching, virtual threads, and text blocks have closed the expressiveness gap significantly. The argument “Java is too verbose” is largely outdated for modern Java.

Kotlin in 2026 is mature and stable. Kotlin 2.x brought the new K2 compiler with faster builds. The Kotlin Multiplatform story is compelling for teams targeting multiple platforms.

The uncomfortable truth: For most backend Spring Boot applications, both languages produce similar quality results with similar developer productivity. The choice often comes down to team preference, hiring pool, and organizational momentum rather than technical superiority.

My Personal Take

If I’m starting a new project today with a team I get to pick, I’ll choose Kotlin for the null safety alone. The compile-time guarantee against NPEs saves enough debugging time to justify the learning curve.

If I’m advising a company with an existing Java team and a product to ship, I tell them to stay with Java 21. It’s excellent. The productivity gap isn’t large enough to justify the migration cost and hiring friction.

The best language is the one your team knows well and uses effectively. A well-written Java codebase will always outperform a poorly-written Kotlin one, and vice versa.

Stop arguing about languages. Start shipping products.

This post is licensed under CC BY 4.0 by the author.