1/19/2026

Migrating from Java to Kotlin: A Layer-by-Layer Guide

 Migrating an existing JVM-based system from Java to Kotlin is rarely about rewriting everything at once. In real-world systems—especially those built with layered or hexagonal architectures—the migration tends to happen incrementally, layer by layer, module by module.

In this post, we’ll walk through a practical Java → Kotlin migration, comparing Java and Kotlin implementations across the major architectural layers:

  • API (Controllers & DTOs)

  • Service Layer

  • DTO Mapping

  • Core Domain

  • Listeners & Business Logic

  • Adapters & Ports

  • Testing

Each section shows before (Java) and after (Kotlin) code, focusing on what actually improves: readability, safety, and expressiveness—not just fewer lines of code.


1. API Layer: Concise Controllers & DTOs

The API layer is often the easiest and most rewarding place to start a migration. Java controllers tend to accumulate verbosity through constructor injection, records, Lombok, and boilerplate validation annotations.

Scenario: Handling HTTP Requests

The DepositController handles incoming deposit requests and publishes an event to Kafka.

Java (Before)


https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/payment-ingress-service/src/main/java/com/sportsbook/payment/ingress/api/DepositController.java


In Java, even with records and modern Spring, controllers typically require explicit constructors and nested DTO definitions.

@RestController

@RequestMapping("/api/deposits")

public class DepositController {


    private final KafkaTemplate<String, DepositInitiatedEvent> kafkaTemplate;

    private final String ingestTopic;


    // Verbose constructor injection

    public DepositController(

            KafkaTemplate<String, DepositInitiatedEvent> kafkaTemplate,

            @Value("${psv.topics.deposit-ingest}") String ingestTopic) {

        this.kafkaTemplate = kafkaTemplate;

        this.ingestTopic = ingestTopic;

    }


    // Java Record for DTO

    public record DepositRequest(

            @NotBlank String playerId,

            @Min(1) BigDecimal amount

    ) {}

}

Kotlin (After)


https://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/payment-ingress-service/src/main/kotlin/com/sportsbook/payment/ingress/api/DepositController.kt


Kotlin collapses the class definition and constructor into a single expression. DTOs become data classes with built-in equals, hashCode, and toString.

@RestController

@RequestMapping("/api/deposits")

class DepositController(

    private val kafkaTemplate: KafkaTemplate<String, DepositInitiatedEvent>,

    @Value("\${psv.topics.deposit-ingest}") private val ingestTopic: String,

) {


    data class DepositRequest(

        @field:NotBlank val playerId: String,

        @field:Min(1) val amount: BigDecimal,

    )

}

Key takeaways:

  • No explicit constructor body

  • DTOs are clearer and immutable by default

  • Validation annotations work cleanly with Kotlin using @field:


Kotlin vs Java: Functions vs Methods

Using the same endpoint, we can clearly see how Java methods translate into Kotlin functions.

Java Method Characteristics

In Java:

  • Methods must declare public

  • The return type is mandatory

  • Records are accessed via accessor methods (request.playerId())

  • Temporary variables often use var to reduce verbosity

Kotlin Function Equivalent

The same endpoint written in Kotlin becomes:

Key differences:

  • fun replaces the Java method declaration

  • public is implicit

  • Properties replace record accessor calls

  • val clearly communicates immutability

  • The function reads closer to the business flow


Expression-bodied Variant (Optional)

Because Kotlin functions can return expressions, this endpoint can be simplified further:

@PostMapping

fun createDeposit(@Valid @RequestBody request: DepositRequest): DepositResponse =

    DepositInitiatedEvent.newDeposit(

        request.playerId,

        request.amount,

        request.currency,

        request.paymentMethod,

        request.instrumentId,

    ).also { event ->

        kafkaTemplate.send(ingestTopic, event.playerId, event)

    }

    .let { event -> DepositResponse(event.depositId, "PENDING") }


This style is optional—but it shows how Kotlin encourages expression-oriented APIs when the intent is clear.

Why this matters in the API layer:

  • Less ceremony around visibility and accessors

  • Clearer signal about mutability and intent

  • Easier-to-read controller code during migrations




2. Service Layer: Expressive Business Logic

The service layer benefits heavily from Kotlin’s scope functions and type inference. Patterns that require Lombok or builders in Java often become unnecessary.

Scenario: Creating and Saving Entities

The BetService creates a new Bet entity and persists it.

Java (Before)


https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/betting-service/src/main/java/com/sportsbook/bettingservice/service/BetService.java


Java often relies on the Builder pattern (commonly via Lombok) to keep object construction readable.

@Transactional

public Bet placeBet(String playerId, String marketId, double stake, double odds) {

    Bet bet = Bet.builder()

            .id(UUID.randomUUID().toString())

            .playerId(playerId)

            .marketId(marketId)

            .stake(stake)

            .status(BetStatus.PENDING)

            .build();


    betRepository.save(bet);

    return bet;

}

Kotlin (After)

https://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/betting-service/src/main/kotlin/com/sportsbook/bettingservice/service/BetService.kt


Kotlin’s apply scope function lets you configure an object immediately after creation—without generated builders.

@Transactional

fun placeBet(playerId: String, marketId: String, stake: Double, odds: Double): Bet {

    val bet = Bet().apply {

        id = UUID.randomUUID().toString()

        this.playerId = playerId

        this.marketId = marketId

        this.stake = stake

        this.status = BetStatus.PENDING

    }


    betRepository.save(bet)

    return bet

}

Key takeaways:

  • No Lombok dependency

  • Object construction stays readable

  • Business intent is clearer



3. Mappers: Translating Between Domain & Events

Mapper classes are classic boundary code: they translate between domain models and integration models (Kafka events, Avro schemas, external DTOs). They’re also where Java projects often lean on tools like MapStruct.

In this migration, the Java version used MapStruct to generate mapping code, while the Kotlin version switched to an explicit mapper implemented as a Spring component.

Scenario: Domain → Local Event → Avro (Kafka)

We have two mapping steps:

  1. Domain Bet → Local BetPlacedEvent (used by the service)

  2. Local BetPlacedEvent → Avro BetPlaced (used by BetEventProducer)

This setup keeps the domain clean while allowing Avro/Kafka concerns to live at the edge.

Java (Before)


https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/betting-service/src/main/java/com/sportsbook/bettingservice/kafka/BetEventMapper.java


Class: com.sportsbook.bettingservice.kafka.BetEventMapper (Java)

Here the mapper is a MapStruct interface. The annotations describe field renames (e.g., matchIdmarketId, selectionoutcomeId) and also generate values like eventId and timestamp.

package com.sportsbook.bettingservice.kafka;


import com.sportsbook.bettingservice.domain.Bet;

import com.sportsbook.events.BetPlaced;

import org.mapstruct.Mapper;

import org.mapstruct.Mapping;


@Mapper(componentModel = "spring")

public interface BetEventMapper {


    // existing: local event -> Avro (used by BetEventProducer)

    @Mapping(target = "eventId",   expression = "java(java.util.UUID.randomUUID().toString())")

    @Mapping(target = "betId",     source = "id")

    @Mapping(target = "playerId",  source = "playerId")

    @Mapping(target = "userId",    source = "userId")

    @Mapping(target = "marketId",  source = "matchId")     // matchId -> marketId

    @Mapping(target = "outcomeId", source = "selection")   // selection -> outcomeId

    @Mapping(target = "stake",     source = "stake")

    @Mapping(target = "odds",      source = "odds")

    @Mapping(target = "timestamp", expression = "java(System.currentTimeMillis())")

    BetPlaced toAvro(BetPlacedEvent source);


    // NEW: domain Bet -> local BetPlacedEvent (used by service)

    @Mapping(target = "id",        source = "id")

    @Mapping(target = "playerId",  source = "playerId")

    @Mapping(target = "userId",    source = "userId")

    @Mapping(target = "matchId",   source = "marketId")    // marketId -> matchId

    @Mapping(target = "selection", source = "outcomeId")   // outcomeId -> selection

    @Mapping(target = "stake",     source = "stake")

    @Mapping(target = "odds",      source = "odds")

    BetPlacedEvent toLocalEvent(Bet bet);

}

Kotlin (After)


https://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/betting-service/src/main/kotlin/com/sportsbook/bettingservice/kafka/BetEventMapper.kt


Class: com.sportsbook.bettingservice.kafka.BetEventMapper (Kotlin)

In Kotlin, we implemented the mapping explicitly. The Avro mapping uses the generated builder (BetPlaced.newBuilder()), and the local event mapping becomes a clean constructor call.

package com.sportsbook.bettingservice.kafka


import com.sportsbook.bettingservice.domain.Bet

import com.sportsbook.events.BetPlaced

import org.springframework.stereotype.Component

import java.util.UUID


@Component

class BetEventMapper {


    fun toAvro(source: BetPlacedEvent): BetPlaced =

        BetPlaced.newBuilder()

            .setEventId(UUID.randomUUID().toString())

            .setBetId(source.id)

            .setPlayerId(source.playerId)

            .setUserId(source.userId)

            .setMarketId(source.matchId)

            .setOutcomeId(source.selection)

            .setStake(source.stake)

            .setOdds(source.odds)

            .setTimestamp(System.currentTimeMillis())

            .build()


    fun toLocalEvent(bet: Bet): BetPlacedEvent =

        BetPlacedEvent(

            id = bet.id,

            playerId = bet.playerId,

            userId = bet.userId,

            matchId = bet.marketId,

            selection = bet.outcomeId,

            stake = bet.stake,

            odds = bet.odds,

        )

}

Key takeaways:

  • Java + MapStruct is great for large mapping surfaces, but it hides logic behind generated code.

  • Kotlin makes it easy to keep mapping explicit and readable, especially when you also need custom values (UUID, timestamp) or you’re using an Avro builder.

  • The field renames (matchIdmarketId, selectionoutcomeId) become obvious once they’re written out in plain code.



4. Core Domain: Removing Boilerplate

Domain entities are where Java boilerplate tends to accumulate: constructors, getters, setters, and Lombok annotations.

Scenario: The Wallet Entity

A simple JPA entity representing a player’s wallet.

Java (Before)


https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/wallet-service/src/main/java/com/sportsbook/walletservice/model/Wallet.java



@Entity

@Table(name = "wallet")

public class Wallet {


    @Id

    private String playerId;


    private BigDecimal balance;


    public Wallet(String playerId, BigDecimal balance) {

        this.playerId = playerId;

        this.balance = balance;

    }


    public BigDecimal getBalance() {

        return balance;

    }


    public void setBalance(BigDecimal balance) {

        this.balance = balance;

    }

}

Kotlin (After)


https://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/wallet-service/src/main/kotlin/com/sportsbook/walletservice/model/Wallet.kt


Kotlin properties generate getters and setters automatically. Default constructor values make JPA-friendly instantiation trivial.

@Entity

@Table(name = "wallet")

class Wallet(

    @Id

    var playerId: String? = null,

    var balance: BigDecimal = BigDecimal.ZERO,

) {

    @Version

    var version: Long = 0

}

Key takeaways:

  • Far less boilerplate

  • Safer defaults

  • Cleaner domain modeling


5. Listeners & Logic: Powerful Control Flow

Complex business rules are easier to read and maintain with Kotlin’s when expression compared to Java’s switch.

Scenario: Payment Validation Logic

The ValidationOrchestrator decides whether a deposit should be approved based on funding source type.

Java (Before)

https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/payment-validator-service/src/main/java/com/sportsbook/payment/validator/service/ValidationOrchestrator.java



FundingSourceType sourceType = creditCheckPort.checkSource(enrichedEvent);

DepositDecision decision;


switch (sourceType) {

    case DEBIT -> decision = DepositDecision.APPROVED;

    case CREDIT -> {

        decision = DepositDecision.REJECTED;

        reason = DepositRejectionReason.CREDIT_CARD;

    }

    case BNPL -> {

        decision = DepositDecision.REJECTED;

        reason = DepositRejectionReason.BNPL_PROVIDER;

    }

    default -> {

        decision = DepositDecision.REJECTED;

        reason = DepositRejectionReason.AMBIGUOUS_BIN;

    }

}


Kotlin (After)


http://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/payment-validator-service/src/main/kotlin/com/sportsbook/payment/validator/service/ValidationOrchestrator.kt



val sourceType = creditCheckPort.checkSource(enrichedEvent)


val decision: DepositDecision

var reason: DepositRejectionReason? = null


when (sourceType) {

    FundingSourceType.DEBIT -> decision = DepositDecision.APPROVED

    FundingSourceType.CREDIT -> {

        decision = DepositDecision.REJECTED

        reason = DepositRejectionReason.CREDIT_CARD

    }

    FundingSourceType.BNPL -> {

        decision = DepositDecision.REJECTED

        reason = DepositRejectionReason.BNPL_PROVIDER

    }

    else -> {

        decision = DepositDecision.REJECTED

        reason = DepositRejectionReason.AMBIGUOUS_BIN

    }

}

Key takeaways:

  • when is an expression, not just a statement

  • Branches are explicit and readable

  • Fewer accidental fall-through bugs


6. Adapters & Ports: Handling Nullability Explicitly

Adapters talk to external systems—and external systems fail. Kotlin forces you to model that reality explicitly.

Scenario: Calling an External Bank API

The NordicApiAdapter performs a blocking HTTP call.

Java (Before)


https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/payment-validator-service/src/main/java/com/sportsbook/payment/validator/adapters/NordicApiAdapter.java



@Override

public FundingSourceType checkSource(DepositEnrichedEvent depositEnriched) {

    return webClient.post()

            .uri("/funding-source/check")

            .bodyValue(depositEnriched)

            .retrieve()

            .bodyToMono(FundingSourceType.class)

            .block(Duration.ofSeconds(2));

}

Kotlin (After)


https://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/payment-validator-service/src/main/kotlin/com/sportsbook/payment/validator/adapters/NordicApiAdapter.kt


The Elvis operator (?:) guarantees a fallback value if the API returns null.

override fun checkSource(depositEnriched: DepositEnrichedEvent): FundingSourceType =

    webClient.post()

        .uri("/funding-source/check")

        .bodyValue(depositEnriched)

        .retrieve()

        .bodyToMono(FundingSourceType::class.java)

        .block(Duration.ofSeconds(2))

        ?: FundingSourceType.UNKNOWN

Key takeaways:

  • Nullability is explicit, not implicit

  • Fewer runtime NullPointerExceptions

  • Safer adapter boundaries


7. Testing: Declarative & Readable

Kotlin tests often read closer to natural language thanks to lambdas, property access, and reduced ceremony.

Scenario: Awaiting Asynchronous Results

Using Awaitility to wait for a database update.

Java (Before)


https://github.com/dhanuka84/sports-booking-domain/blob/main/sportsbook-app/payment-validator-service/src/test/java/com/sportsbook/payment/validator/PaymentValidatorIntegrationTest.java



await().atMost(Duration.ofSeconds(15))

        .untilAsserted(() -> {

            var decisionOpt = decisionRepository.findById(initiated.depositId());

            assertThat(decisionOpt).isPresent();

            var decision = decisionOpt.get();

            assertThat(decision.getDecision())

                    .isEqualTo(DepositDecision.APPROVED);

        });

Kotlin (After)


https://github.com/dhanuka84/sports-booking-domain/blob/kotlin/sportsbook-app/payment-validator-service/src/test/kotlin/com/sportsbook/payment/validator/PaymentValidatorIntegrationTest.kt



await().atMost(Duration.ofSeconds(15))

    .untilAsserted {

        val decisionOpt = decisionRepository.findById(initiated.depositId)

        assertThat(decisionOpt).isPresent

        val decision = decisionOpt.get()

        assertThat(decision.decision)

            .isEqualTo(DepositDecision.APPROVED)

    }

Scenario: Sharing Testcontainers Configuration

Java (Before)

public class Containers implements BeforeAllCallback {

    public static final KafkaContainer KAFKA = new KafkaContainer(...);

}

Kotlin (After)

class Containers : BeforeAllCallback {

    companion object {

        val KAFKA: KafkaContainer = KafkaContainer(

            DockerImageName.parse("confluentinc/cp-kafka:7.7.1"),

        ).withNetwork(NET)

    }

}

Key takeaways:

  • Cleaner lambdas

  • No getters in assertions

  • Companion objects replace static clutter


Final Thoughts

Migrating from Java to Kotlin isn’t about rewriting everything or chasing conciseness for its own sake. The real gains show up when you:

  • Remove accidental complexity

  • Make nullability explicit

  • Let the language express intent more clearly

A layer-by-layer migration keeps risk low while delivering immediate improvements. Kotlin integrates seamlessly with Spring, JPA, Kafka, and existing Java code—making it a pragmatic evolution, not a rewrite.

If you’re maintaining a large Java codebase, Kotlin isn’t a leap. It’s a series of small, compounding wins.