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)
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)
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)
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)
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:
Domain Bet → Local BetPlacedEvent (used by the service)
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)
Class: com.sportsbook.bettingservice.kafka.BetEventMapper (Java)
Here the mapper is a MapStruct interface. The annotations describe field renames (e.g., matchId ↔ marketId, selection ↔ outcomeId) 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)
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 (matchId ↔ marketId, selection ↔ outcomeId) 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)
@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)
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)
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)
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)
@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)
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)
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)
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.