Kotlin for Java Survivors: Life After Semicolons
A 16-part series for senior Java and Spring developers learning Kotlin 2.4. Follow Kabir, a 14-year Java veteran, from culture shock to shipping two Spring Boot 4.1 services with Kafka, Qdrant, Spring AI and LangChain4j.
Kotlin for Java Survivors: Life After Semicolons
A field guide for experienced Java developers who have to ship Kotlin, not just read it.
About This Series
Kotlin runs on your JVM, calls your libraries and compiles to ordinary JVM bytecode, so the first week feels easy. Then your instincts start misfiring: a smart cast that won’t happen, a return inside a lambda that leaves the wrong function, a JPA entity that quietly misbehaves because it is a data class.
This series is about those misfires. It skips “what is a variable” and spends its time on what is different in Kotlin and why the designers chose it. Where it helps, it shows what the compiler actually generates, as decompiled Java, so you can reason about Kotlin with the JVM knowledge you already have.
Why not just Java 25?
It’s a fair question. Records, sealed interfaces, pattern-matching switch and virtual threads have closed much of the gap. What Java still doesn’t have is nullability in the type system, expression-oriented control flow everywhere, extension functions, declaration-site variance, and coroutines with structured concurrency. Spring Boot 4 has Kotlin-specific APIs, JSpecify-annotated nullability and coroutine support throughout. The costs are real too: slower compilation, a standard library on the classpath, interop seams from Java, and friction with JPA. The series covers both sides, and the last part weighs them.
Everything targets Kotlin 2.4.20, JDK 25 and Spring Boot 4.1. Features that are not Stable are labelled with their status and the compiler flag they need.
Meet Kabir
Kabir Sen has written Java for fourteen years and is a principal engineer at Shelfwise, a grocery chain with about 400 stores. He wrote the pricing engine that every till in the chain calls, and he can tell you from memory which Reactor operator swallows which error.
In October, Shelfwise commits to a Product Knowledge Assistant: store staff and customers ask questions in plain language, such as “gluten-free pasta under ₹200?” or “what’s the return policy on small appliances?”, and get answers grounded in the live catalog. A twelve-store pilot is due at the end of the quarter. In the same meeting, the platform team announces that new services are Kotlin-first.
So Kabir has a deadline and a language he has never shipped. He owns two services:
product-catalog-service, the source of truth for products, prices and stock.product-ai-service, which handles semantic search and question answering over the catalog.
Every one of his pull requests goes to Lena Fischer, the staff engineer who built Shelfwise’s Android app in Kotlin and now sets backend platform standards. Her review comments rarely run past one line.
Prerequisites
| Skill | Level expected |
|---|---|
| Java 17+ | Proficient: you ship production code |
| Generics, Streams, lambdas | Comfortable |
| Spring Boot, Spring Data JPA | Comfortable (Act III) |
| Kafka, WebFlux/Reactor | Helpful for Parts 11 and 14 |
| Vector search, RAG | Not required; Part 15 explains what it uses |
| Kotlin | None assumed |
Tools you will use:
| Tool | Purpose | Notes |
|---|---|---|
| JDK 25 | Runtime and toolchain | LTS. The build asks Gradle for a JDK 25 toolchain and downloads one if none is installed |
| IntelliJ IDEA | IDE | Effectively required. K2 mode (the IDE on the new compiler frontend) is the default since 2025.1; as of 2026, other editors’ Kotlin support is much weaker |
| Gradle | Build | The wrapper pins 9.7.1. Kotlin DSL (build.gradle.kts) throughout |
| Docker Desktop | Postgres, Kafka, Qdrant, Ollama | Act III only |
| Kotlin Playground | Quick experiments in the browser | Fine for Stable features; it can’t take -X compiler flags |
Tip — Maven works fine with Kotlin through
kotlin-maven-plugin, and Kotlin 2.4 made it easier by aligning the JVM target and supporting Maven toolchains. The series uses Gradle because Spring’s Kotlin samples and Kotlin’s own tooling assume it.
The Companion Repository
All the code lives in one companion workspace, one self-contained Gradle build per series:
git clone https://github.com/phoenixtb/companion-workspaces.gitcd companion-workspaces/kotlin-for-java-survivors
./gradlew build # compile every part, run every test./gradlew :language:part01-life-after-semicolons:test # one part- Parts 1–12 each have a module under
language/, such aslanguage/part07-collections-without-collectors. Every example is a runnable file with amain(), and a test runs each one and checks its output against the// ->comments in the source. The code in the posts is copied from these files after they pass. - Parts 13–16 build
services/product-catalog-service,services/product-ai-serviceand the shared event model inshared/product-events.infra/compose.yamlstarts Postgres, Kafka (KRaft), Qdrant and Ollama. Tags such askotlin-for-java-survivors/part-13mark the state at the end of each part. - Each “under the hood” listing comes from real compiler output. Reproduce any of them with
./gradlew :language:<module>:javap -Pclass=<fully.qualified.ClassName>.
Tip — Open the
kotlin-for-java-survivorsfolder in IntelliJ, not the repository root.
Most teams don’t adopt Kotlin with a clean rewrite. They add Kotlin files to an existing Java module, one class at a time. That works because both compile into the same module. Part 12 shows a Java source set calling Kotlin, and what to annotate so Java callers don’t suffer.
Series Table of Contents
Act I — Rewiring the Java Brain (Parts 1–4)
Part 1: “Life After Semicolons” — Syntax, Control Flow and the Build
The build, top-level functions and the FileNameKt classes they compile to, no primitives in source, if/when/try as expressions, ranges and loops.
Part 2: “The Billion-Dollar Fix” — Null Safety and the Type System
Nullable types and the safe-call operators, smart casts and when they refuse, platform types and JSpecify, lateinit vs lazy, Nothing, and == vs ===.
Part 3: “Classes Without Ceremony” — Constructors, Properties and Objects
Primary constructors and init order, properties instead of fields, explicit backing fields, final by default, Kotlin’s different protected and internal, objects, companions and enums.
Part 4: “Data, Sealed and Value” — Modelling the Domain
Data classes and what they generate, sealed hierarchies with exhaustive when, value classes and when they box, all applied to Product, Price and Sku.
Act II — Thinking in Kotlin (Parts 5–12)
Part 5: “Functions Are Values” — Lambdas, References and Inline
Function types, closures that can mutate, SAM conversions, and why inline lets return escape a lambda.
Part 6: “Extend, Scope, Delegate” — Kotlin’s Power Tools Extension functions and static dispatch, scope functions, context parameters, delegation, operators and a type-safe product-query DSL.
Part 7: “Collections Without Collectors” — Collections, Sequences and Strings
Read-only is not immutable; the operations you used Collectors for; sequences vs Streams; Duration and Uuid.
Part 8: “Generics Without Wildcards” — Variance, Reified and Constraints
out and in instead of PECS, star projection, reified type parameters and their limits, and a typed event envelope.
Part 9: “Errors Without Checked Exceptions” — Failure Modelling, Annotations and Reflection
@Throws for Java callers, Result and its CancellationException trap, sealed domain errors, the Experimental unused-return-value checker, and annotation targets under 2.4’s new defaults.
Part 10: “Suspend Your Disbelief” — Coroutines Fundamentals
What suspend compiles to, structured concurrency, dispatchers, cancellation, and how exceptions travel.
Part 11: “Flowing Data” — Flow, Channels and Virtual Threads Cold and hot flows, channels, Reactor interop, virtual-time tests, and when virtual threads are the better answer.
Part 12: “Bilingual” — Java Interop, Build Plugins, Testing and Tooling Making Kotlin pleasant to call from Java, the compiler plugins Spring needs, MockK and Kotest, and linting with ktlint and detekt.
Act III — Shipping with Spring Boot 4.1 (Parts 13–16)
Part 13: “The Catalog” — Spring Boot 4.1, JPA and REST in Kotlin
product-catalog-service: why entities aren’t data classes, validation, Jackson 3, ProblemDetail, Testcontainers 2 and virtual threads.
Part 14: “Reactive and Events” — Coroutines, Streaming and Kafka
A sealed event hierarchy published to Kafka after commit, changes streamed as server-sent events from a Flow, suspend controllers with a suspending HTTP client, and request context across coroutines, all on Spring MVC.
Part 15: “The Assistant” — Spring AI 2.0, Qdrant and RAG
product-ai-service: indexing product events into Qdrant, grounded answers with ChatClient, a tool for live prices, and local models with Ollama.
Part 16: “The Second Opinion” — LangChain4j, Side by Side The same ports implemented again in LangChain4j, one test suite for both, and an honest comparison.
How Each Part Is Structured
Story Opening — the problem Kabir runs into at Shelfwise. Java → Kotlin: The Quick Map — what you know, and what replaces it. Conceptual Deep-Dive — the why: the design decision, and the mental model that replaces your Java one. Technical Explanation — the how: compiler output, bytecode and edge cases. Step-by-Step Hands-On — runnable code that builds a piece of the Shelfwise codebase. Tips, Tricks & Gotchas — with the traps that catch Java developers specifically. From Part 10 on, a Debugging section follows. Key Takeaways and a Story Closing that sets up the next part.
Conventions used in code
In Parts 1–12 every Kotlin block is self-contained and runnable: paste it into a .kt file or the Playground and run main(). Deliberately partial blocks are marked as fragments. Expected output appears as a trailing comment, and a … in it stands for text that changes between runs, such as a timing:
fun main() { val total = listOf(10, 20, 30).sum() println(total) // -> 60}Intentional compile errors are commented out, with the compiler’s actual message:
// val sku: String = null // error: null cannot be a value of a non-null type 'String'.Anything that is not Stable in Kotlin 2.4.20 carries its status and flag, for example // Experimental in 2.4.20 — requires -Xcollection-literals.
Gotcha — Older tutorials use context receivers (
context(Logger) fun …with no parameter name). They were removed in Kotlin 2.3.20 in favour of context parameters, which Part 6 covers. Paste that code into 2.4.20 and the compiler answerscontext parameters must be named. Use '_' to declare an anonymous context parameter., and the receiver’s members are no longer in scope either.
Start with Part 1, even if you have read some Kotlin before: it sets up the build and the habits the rest of the series relies on.
This is Part 0 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”