Java vs Kotlin in rel projects

A practical Java vs Kotlin guide based on real-world projects: syntax, performance, best practices, and how to decide which one to use in each scenario.

Cover for Java vs Kotlin in rel projects

When someone compares Java vs Kotlin, they almost always run into feature tables and synthetic benchmarks. But in real-world projects, what truly matters is:

  • Which language lets you deliver faster.
  • Which one gives you fewer production scares.
  • How it affects the team and long-term maintainability of the codebase.

In my case, I come from many years working with Java in all kinds of projects, and for several years now I’ve also been maintaining and developing Kotlin code in parallel. That means living daily with hybrid repositories, legacy Java modules, and new functionality already written in Kotlin. What you’re about to read comes from that constant contrast.


Big picture: why Java vs Kotlin is not a theoretical debate

More than choosing an absolute winner in Java vs Kotlin, the practical question is usually:

  • What should I keep in Java because it works and is stable?
  • Where does it make sense to introduce Kotlin to gain productivity?
  • How do I manage a hybrid codebase without driving the team crazy?

The key point: Kotlin lives in the same JVM ecosystem as Java. It integrates with the same libraries and frameworks, and you can mix both languages in the same project. That lets you think in terms of strategy instead of a “total rewrite.”


Kotlin vs Java syntax in day-to-day work

Talking about Kotlin vs Java syntax is not an aesthetic discussion. It directly affects:

  • How long it takes to implement a feature.
  • How clear pull requests are.
  • How easy refactoring becomes.

Data classes vs verbose POJOs

Typical simple model example:

Java

public class User {
    private final Long id;
    private String name;
    private String email;

    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    @Override
    public boolean equals(Object o) {
        // typical equals
    }

    @Override
    public int hashCode() {
        // typical hashCode
    }

    @Override
    public String toString() {
        return "User{id=" + id + ", name='" + name + "', email='" + email + "'}";
    }
}

Kotlin

data class User(
    val id: Long,
    var name: String,
    var email: String
)

In large codebases, this difference multiplies across hundreds of classes. After years maintaining both, you clearly notice that:

  • Kotlin drastically reduces boilerplate code.
  • PR diffs are smaller and easier to review.
  • Refactoring models is simpler and less error-prone.

Null safety: from “surprise” NPEs to compile-time errors

One of the most important differences in Kotlin vs Java syntax is how null is handled.

Java: NullPointerException anywhere

public String getUserEmail(User user) {
    return user.getProfile().getEmail().toLowerCase();
}

If user, profile, or email is null, the bug appears at runtime. You can avoid it with nested ifs or Optional, but it’s not enforced.

Kotlin: nullable types and safe operators

fun getUserEmail(user: User?): String? {
    return user
        ?.profile
        ?.email
        ?.lowercase()
}
  • User? forces you to think about whether the user can be null.
  • The ?. operator safely chains accesses.
  • If something is optional, the type makes it explicit.

After several years using Kotlin alongside Java, you can clearly see how many errors move from runtime to compile time, which is critical in projects where many developers touch the same code.


Extension functions vs static utilities

In Java, it’s common to end up with *Utils classes full of static methods.

public class PriceUtils {

    public static BigDecimal applyVat(BigDecimal basePrice, BigDecimal vatPercent) {
        if (basePrice == null || vatPercent == null) {
            return BigDecimal.ZERO;
        }
        BigDecimal multiplier = vatPercent
                .divide(BigDecimal.valueOf(100))
                .add(BigDecimal.ONE);
        return basePrice.multiply(multiplier);
    }
}

Usage:

BigDecimal finalPrice = PriceUtils.applyVat(basePrice, vatPercent);

Kotlin: extension functions

fun BigDecimal.applyVat(vatPercent: BigDecimal?): BigDecimal {
    if (vatPercent == null) return this
    val multiplier = vatPercent
        .divide(BigDecimal(100))
        .plus(BigDecimal.ONE)
    return this.multiply(multiplier)
}

Usage:

val finalPrice = basePrice.applyVat(vatPercent)

This turns internal APIs into something closer to the domain: the code almost reads like a sentence. After working with both styles, it’s clear that Kotlin makes it easier to create small DSLs that improve business logic readability.


Kotlin vs Java performance in practice

When talking about Kotlin vs Java performance, there are two very different scenarios:

  1. Compilation performance.
  2. Runtime performance.

Compilation: fast builds vs powerful features

In large projects, especially with many modules and annotations, what you notice day to day is:

  • Java tends to compile slightly faster and more consistently.
  • Kotlin can introduce overhead when:
    • data class usage is heavy.
    • Coroutines and generics are used extensively.
    • KAPT and annotation processors come into play.

It’s not dramatic, but if you’re used to very fast Java builds, you’ll notice it when introducing Kotlin without taking structure into account.

An approach that has worked well for me:

  • Very stable “core” modules: keep them in Java if build time is a bottleneck.
  • Business logic modules that change frequently: Kotlin usually pays off in productivity.

Runtime performance: the JVM is in charge

In terms of CPU and memory, in most enterprise applications there’s no significant difference between equivalent Java and Kotlin code:

  • Both generate similar JVM bytecode.
  • The real bottlenecks are usually:
    • Database access.
    • Calls to other services.
    • Architectural design.

In performance-critical scenarios, the strategy that has worked best for me is:

  1. Write the logic normally in Kotlin (for productivity).
  2. Do real profiling under load.
  3. Optimize only the hotspots, even using more “classic Java-style” approaches when needed.

Concurrency: threads and futures vs coroutines

One of the most practical differences in Java vs Kotlin is how you model asynchronous work.

Java: ExecutorService and CompletableFuture

ExecutorService executor = Executors.newFixedThreadPool(10);

public CompletableFuture<OrderSummary> buildOrderSummary(Long orderId) {
    return CompletableFuture.supplyAsync(() -> loadOrder(orderId), executor)
        .thenCombineAsync(
            CompletableFuture.supplyAsync(() -> loadOrderLines(orderId), executor),
            (order, lines) -> new OrderSummary(order, lines),
            executor
        );
}
  • Powerful, but composing futures becomes verbose.
  • Exception handling requires careful attention.

Kotlin with coroutines:

suspend fun buildOrderSummary(orderId: Long): OrderSummary = coroutineScope {
    val orderDeferred = async { loadOrder(orderId) }
    val linesDeferred = async { loadOrderLines(orderId) }

    OrderSummary(
        order = orderDeferred.await(),
        lines = linesDeferred.await()
    )
}
  • The code looks almost synchronous.
  • suspend and coroutineScope make the flow explicit.
  • Composing concurrent work is far more readable.

After years maintaining Java services and then introducing Kotlin coroutines, it feels like moving from “fighting the concurrency API” to “expressing the logic you actually want.”


Best practices for mixed Java and Kotlin codebases

In real projects, the most common scenario is not “100% Java” vs “100% Kotlin”, but a hybrid codebase. That’s where Java and Kotlin best practices really matter.

Separate by stability and domain

A very effective approach is to organize modules based on:

  • Code stability:
    • Very stable core: can remain in Java without issues.
    • Frequently changing business layers: Kotlin provides agility.
  • Proximity to the domain:
    • Code that models business concepts → Kotlin fits especially well (data classes, sealed classes, DSLs).

Gradual migration strategy

Instead of rewriting everything, a strategy I’ve applied in several projects is:

  1. Keep existing Java code as is.
  2. Write new functionality in Kotlin when possible.
  3. Migrate to Kotlin only the Java classes where maintenance is painful:
    • Lots of business logic.
    • Recurring bugs.
    • Frequent refactors.

This avoids the shock of a full rewrite and lets the team adopt Kotlin gradually.

Writing tests in Kotlin for mostly Java projects

A very practical, low-risk way to introduce Kotlin is to start with tests:

  • This is where Kotlin’s concise syntax really shines:
    • Builders for test data.
    • Given/when/then DSLs.
    • Extension functions for assertions.

Example: Kotlin test over Java code

class OrderServiceTest {

    private val repository = InMemoryOrderRepository()
    private val service = OrderService(repository)

    @Test
    fun `should create order with lines`() {
        val request = orderRequest {
            customerId = 123L
            line {
                productId = 1L
                quantity = 2
            }
            line {
                productId = 2L
                quantity = 1
            }
        }

        val order = service.createOrder(request)

        assertThat(order.lines).hasSize(2)
        assertThat(order.customerId).isEqualTo(123L)
    }
}

fun orderRequest(block: OrderRequestBuilder.() -> Unit): OrderRequest =
    OrderRequestBuilder().apply(block).build()

class OrderRequestBuilder {
    var customerId: Long = 0
    private val lines = mutableListOf<OrderLineRequest>()

    fun line(block: OrderLineRequest.() -> Unit) {
        lines += OrderLineRequest().apply(block)
    }

    fun build() = OrderRequest(customerId, lines)
}

This kind of test DSL feels much more natural in Kotlin than in Java, and it works as a gentle entry point for the team.


Typical scenarios: Android, backend, and internal libraries

Android: Kotlin as the de facto standard

On Android, the Java vs Kotlin debate is mostly settled:

  • Most examples, documentation, and modern libraries are Kotlin-first.
  • Coroutines and Flow integrate extremely well with modern UI and state handling.

If I were starting an Android app from scratch today, I wouldn’t hesitate: Kotlin would be the foundation, with Java only for legacy SDKs or older libraries.

Backend: Spring, Ktor, and friends

On the backend, the picture is more balanced:

  • If you have a large, long-lived Java + Spring platform, there’s no rush to migrate everything.
  • But Kotlin fits extremely well in:
    • New Spring Boot services.
    • Lightweight services with Ktor or similar frameworks.
    • Domain layers and DTOs.

REST controller example

Java (Spring)

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService service;

    public OrderController(OrderService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) {
        return service.findById(id)
            .map(order -> ResponseEntity.ok(toDto(order)))
            .orElse(ResponseEntity.notFound().build());
    }
}

Kotlin (Spring)

@RestController
@RequestMapping("/api/orders")
class OrderController(
    private val service: OrderService
) {

    @GetMapping("/{id}")
    fun getOrder(@PathVariable id: Long): ResponseEntity<OrderDto> =
        service.findById(id)
            ?.let { ResponseEntity.ok(it.toDto()) }
            ?: ResponseEntity.notFound().build()
}

fun Order.toDto() = OrderDto(
    id = id,
    customerId = customerId,
    total = total,
    lines = lines.map { it.toDto() }
)

fun OrderLine.toDto() = OrderLineDto(
    productId = productId,
    quantity = quantity,
    price = price
)

Same pattern, but with less cognitive overhead and heavy use of extensions.

Internal libraries and SDKs

For very low-level libraries or internal SDKs consumed by many Java projects, it often still makes sense to:

  • Stick with Java for compatibility and predictability.
  • Use Kotlin in the business code that consumes those libraries.

Practical checklist: Java, Kotlin, or both?

When deciding between Java vs Kotlin in a real project, this checklist helps:

  • Do you have a large, stable Java codebase?
    • Yes → start with Kotlin in new modules and tests; don’t rewrite everything.
    • No → Kotlin is a very strong option as a primary language.
  • What’s the team like?
    • Very classic Java profile, little time for training → gentle hybrid approach.
    • Mixed profile, eager to modernize → stronger Kotlin adoption.
  • What matters more right now: delivery speed or extreme stability?
    • Fast feature delivery → Kotlin usually boosts productivity.
    • Extremely mature, critical systems → Java remains the conservative choice.
  • What kind of project is it?
    • Android → Kotlin by default.
    • Enterprise backend → hybrid is often the most reasonable choice.
    • Internal libraries consumed by many projects → Java can remain the backbone.

So… which one should you choose?

After years maintaining Java projects, Kotlin projects, and many hybrids, the most honest conclusion is that the Java vs Kotlin debate is rarely solved with an all-or-nothing answer.

  • Java remains robust, stable, widely understood, and backed by excellent tooling.
  • Kotlin brings more expressive syntax, null safety, coroutines, and an overall more pleasant developer experience.

The good news is that they coexist perfectly well in the same project. The practical decision comes down to:

  • Using Java where you already have a solid, stable base.
  • Introducing Kotlin where change velocity and business logic readability matter most.
  • Designing a gradual strategy that considers people as much as technology.

Related Posts

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Copyright 2026 OshyTech. All Rights Reserved