If you already write Spring Boot in Java and someone on the team wants to try Kotlin for the next service, the pitch sounds simple: same Spring, same JVM, a language with null safety built in. The reality has three sharp edges that do not show up until you actually build and run something — a Kotlin class that is final by default and silently breaks a feature Spring relies on, a null-safety guarantee that stops at the Kotlin/Java boundary, and a suspend fun controller method that resumes on a thread you did not expect. All three are demonstrated below with real code, a real failing build, and real captured output — nothing in this post is “the docs say” without something compiled and run to back it up.
Versions verified for this post. Spring Boot 4.1.1 (Spring Framework 7.0.9), Kotlin 2.3.21 — the exact version Spring Boot 4.1.1’s own spring-boot-dependencies BOM manages, read directly from its POM on Maven Central, not the latest actual Kotlin GA (2.4.20 at the time of writing, excluding the 2.4.21-RC/2.5.0-Beta1 pre-releases). kotlinx.coroutines 1.10.2, JSpecify 1.0.0, jackson-module-kotlin 2.22.3 (not BOM-managed — pin it yourself). Built and run on GraalVM CE 25.4.4.1.1 for JDK 25.
Can you write Spring Boot in Kotlin without surprises?
Mostly yes, but “mostly” is doing real work in that sentence. Kotlin and Java both compile to JVM bytecode and both run inside the same Spring container, so a Kotlin @RestController looks, to Spring, almost exactly like a Java one. The differences that matter are not in the framework — they are in two things Kotlin changes about the class files it produces, and one thing it cannot change at all.
Kotlin classes are final unless you write open. Spring’s proxy-based features — @Transactional, @Configuration class enhancement, and anything else that needs a CGLIB subclass at runtime — need the opposite. Kotlin also has real null-safety types baked into its type system, which sounds like it should make NullPointerExceptions a thing of the past. It does, for Kotlin-to-Kotlin calls. The moment a Kotlin function calls into Java code — which in a Spring app is most of the framework underneath you — that guarantee is only as good as the annotations on the Java side, and this post shows a real Java method that lies about them.
And third: Kotlin’s coroutines look like async/await from other languages, and Spring Framework 7 lets you write suspend fun directly in a @RestController — even on a plain Tomcat servlet stack, not WebFlux. What thread actually runs the rest of your method after a suspension point is not obvious from reading the code, and this post measures it.
What Kotlin’s null safety actually buys you — and where it stops
In Kotlin, String and String? are different types, checked at compile time. A String parameter can never be null — the compiler will not let you pass one, so a function that only takes String genuinely cannot NPE on that parameter from a Kotlin caller. A String? forces you to handle the null case (with ?., ?:, or an explicit check) before you can call anything on it that assumes non-null. This is the real, compiler-enforced part of Kotlin null safety, and it works perfectly for Kotlin code calling other Kotlin code.
The catch is what happens when a Kotlin function calls a Java method. Java has no String/String? distinction in its type system, so for an ordinary, unannotated Java method, Kotlin cannot know whether the return value can be null. Rather than assume either way, it gives that value a third type: the platform type, written String! (you will see this in error messages and in the IDE, never in source — you cannot write ! yourself). A platform type lets you treat the value as non-null with zero compiler warning, which means an old Java API with no nullability metadata gives you exactly the same NPE risk in Kotlin that it gave you in Java. Kotlin’s null safety did not remove that risk; it just stopped warning you about it, because it has no information to warn with.
JSpecify exists to close that gap. It is a small, framework-neutral annotation library (org.jspecify.annotations) that Spring Framework 7 adopted wholesale, replacing the older JSR-305-based org.springframework.lang annotations from Spring 5 and 6. A Java class or package marked @NullMarked declares that every type in it is non-null unless explicitly marked @Nullable. Kotlin’s compiler understands JSpecify directly: a @NullMarked Java method’s return type becomes a real, non-platform String or String? in Kotlin, not String!. This is what makes the rest of this post worth writing — and also where the next section finds the trap.
That diagram is the entire argument of this post in one picture: the middle box is the one people get wrong, because it looks as safe as the left one.
- Kotlin’s own null-safety reference covers platform types and the
!notation in more depth than fits here: Null safety in Kotlin. - JSpecify’s full annotation set (
@NullMarked,@NullUnmarked,@Nullable) and how it interacts with generics is documented at jspecify.dev.
A Spring Data JPA service in Kotlin that starts and works
Before getting into what breaks, here is what Kotlin plus Spring Boot 4.1 looks like when it works — a tiny Book entity, a repository, a service, and a controller, backed by H2.
@Entity
data class Book(
@Id @GeneratedValue val id: Long = 0,
val title: String?,
val author: String
)
Source: Book.kt. Note title: String? — this entity genuinely allows a book with no title, and the type system makes every caller deal with that, not just the ones who remembered to check.
@Service
class BookService(private val repository: BookRepository) {
@Transactional
fun save(title: String?, author: String): Book =
repository.save(Book(title = title, author = author))
fun findAll(): List<Book> = repository.findAll()
}
Source: BookService.kt. Nothing here says open — the next section explains why that is not a mistake. A real request to GET /books against the running app:
[
{
"id": 1,
"title": "Kotlin in Action",
"author": "Jemerov & Isakova"
},
{
"id": 2,
"title": "Effective Kotlin",
"author": "Marcin Moskala"
},
{
"id": 3,
"author": "Unknown"
}
]
Captured output: 03-books-endpoint.txt. Book 3 has no title key at all in the JSON — Jackson’s non_null inclusion setting drops it rather than serializing null, which is a Jackson configuration choice (spring.jackson.default-property-inclusion=non_null), not a Kotlin one.
The endpoint itself is a Kotlin suspend fun, which is this post’s third topic and gets its own section below:
@GetMapping("/books")
suspend fun all(): List<Book> {
delay(5)
return service.findAll()
}
Source: BookController.kt.
Going deeper: the mixed Kotlin+Java Maven setup this module uses — kotlin-maven-plugin compiling both src/main/kotlin and src/main/java, with maven-compiler-plugin‘s default executions disabled and a custom java-compile execution picking up Java’s default source directory — is in the pom.xml itself; see the comments around the kotlin-maven-plugin and maven-compiler-plugin blocks.
Why a final Kotlin class does not break Spring — until it does
@Transactional needs Spring to generate a CGLIB subclass of your bean at startup, so it can wrap your method call with transaction-begin/commit logic. CGLIB subclasses cannot subclass a final class. @Configuration classes need the same thing, for a different reason: Spring enhances them so that one @Bean method calling another @Bean method inside the same class returns the already-created singleton instead of a second one. Every Kotlin class above — BookService, Application — is final in source, with no open anywhere. It starts up fine anyway, and the reason is a Maven plugin working quietly in the background.
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
</dependency>
</dependencies>
</plugin>
Source: pom.xml. The spring entry (backed by the allopen dependency) is a preset that tells the compiler: any class or member reachable from @Component, @Service, @Configuration, @Controller and a handful of other stereotypes should be rewritten as open in the compiled bytecode, as if you had typed it yourself. The jpa entry (backed by noarg) does the same job for @Entity classes, but for a different problem: a Kotlin data class with all-val constructor parameters has no no-argument constructor, and Hibernate’s reflection-based instantiation needs one. The plugin generates a synthetic one.
Going deeper on exactly which stereotypes the spring preset covers and how noarg‘s synthetic constructor interacts with val-only data classes: Kotlin’s all-open compiler plugin reference and no-arg plugin reference.
- This preset is Maven-specific syntax for what Gradle users know as
kotlin("plugin.spring")andkotlin("plugin.jpa")in the Gradle DSL — same mechanism, different build-file spelling. kotlin-reflecthas to be on the classpath for Spring’s Kotlin-aware method invocation path (InvocableHandlerMethod$KotlinDelegate) to work at all — without it, every controller method call, not just the broken ones, fails.
What breaks without the allopen plugin — two real failures, not one
The allopen/noarg setup above is easy to skip if you hand-roll a Kotlin Maven module from a tutorial that only shows the Gradle DSL. Here is exactly what happens when you do, captured from a real build of this repository with the spring compiler plugin entry removed and nothing else changed.
org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: @Configuration class 'Application' may not be final. Remove the final modifier to continue.
Offending resource: com.ankurm.kotlin.Application
Captured output: 01-build-failure-without-allopen.txt. This is the @Configuration enhancement problem, and it fails before a single bean is created — the whole context refuses to start.
One fix does not mean you are done. Manually addingopento just theApplicationclass gets past this error — and immediately hits a second, different one, because the@Beanmethod inside it also needs to not be final. Fixing the symptom you can see is not the same as fixing the cause.
org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: @Bean method 'seed' must not be private or final; change the method's modifiers to continue.
Offending resource: com.ankurm.kotlin.Application
Captured output: 01-build-failure-without-allopen.txt, second failure in the same file. BookService‘s own @Transactional method would hit the CGLIB-proxy version of this exact problem next, if you patched your way past both of the above by hand. Restoring the spring compiler plugin entry — the checked-in state of this repository — fixes all of it at once, because it rewrites every reachable class and member, not just the one that happened to fail first.
2026-10-08T10:59:57.044+05:30 INFO 1034 --- [kotlin-demo] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-10-08T11:00:02.258+05:30 INFO 1034 --- [kotlin-demo] [ main] com.ankurm.kotlin.ApplicationKt : Started ApplicationKt in 10.673 seconds (process running for 11.919)
Captured output: 02-fixed-startup.txt. No exception, same source files, one restored plugin entry.
- Both failures are
BeanDefinitionParsingException, which fires during context setup, not at request time — you will not get a half-working app, you will get an app that refuses to boot at all, which is the better failure mode to have. - If your build tool is Gradle rather than Maven, the equivalent omission is forgetting
kotlin("plugin.spring")in theplugins {}block, and it produces the identical runtime exception, because the bytecode-rewriting step is the same underneath.
JSpecify tells Kotlin the truth — unless the Java code is lying
This is the trap the earlier diagram previewed. LegacyJavaApi is a plain, unannotated Java class that returns null for one input with no nullability metadata at all:
public final class LegacyJavaApi {
public static String titleForId(int id) {
return id == 0 ? null : "Book #" + id;
}
}
Source: LegacyJavaApi.java. Called from Kotlin, its return type is the platform type String!, and nothing stops you from assigning it straight into a non-nullable val title: String:
val title: String = LegacyJavaApi.titleForId(id)
return mapOf("length" to title.length)
HTTP/1.1 500
{"timestamp":"2026-10-08T05:30:22.108Z","status":500,"error":"Internal Server Error","path":"/books/null-demo/legacy/0"}
java.lang.NullPointerException: titleForId(...) must not be null
at com.ankurm.kotlin.BookController.legacyNullDemo(BookController.kt:47) ~[!/:1.0.0]
Captured output: 06-null-demo-legacy.txt. Nothing here is surprising — it is exactly the NPE risk that unannotated Java always carried, visible in Kotlin as much as in Java.
The more interesting case is NullMarkedJavaApi, which uses the exact annotation Spring Framework 7 itself adopted:
@NullMarked
public final class NullMarkedJavaApi {
public static String titleForId(int id) {
return id == 0 ? null : "Book #" + id; // violates its own @NullMarked contract
}
public static @Nullable String titleForIdOrNull(int id) {
return id == 0 ? null : "Book #" + id;
}
}
Source: NullMarkedJavaApi.java. @NullMarked on the class declares every unannotated type in it non-null. Kotlin trusts that declaration completely: titleForId‘s return type becomes a real, non-platform String in Kotlin — the IDE shows it as safe, the compiler shows it as safe. But JSpecify is a static contract: nothing checks it at compile time unless you run a separate tool like NullAway, and plain javac happily compiles a method that violates its own declared contract. The result:
HTTP/1.1 500
{"timestamp":"2026-10-08T05:30:33.758Z","status":500,"error":"Internal Server Error","path":"/books/null-demo/jspecify/0"}
java.lang.NullPointerException: titleForId(...) must not be null
at com.ankurm.kotlin.BookController.jspecifyNullDemo(BookController.kt:57) ~[!/:1.0.0]
Captured output: 07-null-demo-jspecify.txt. Identical failure to the unannotated case — the only difference is that this one looked safe right up until it NPE’d.
Compare the honest version, titleForIdOrNull, declared @Nullable:
val title: String? = NullMarkedJavaApi.titleForIdOrNull(id)
return mapOf("length" to (title?.length ?: -1))
HTTP/1.1 200
{"length":-1}
Captured output: 08-null-demo-jspecify-honest.txt. Because the Java method is honestly annotated @Nullable, Kotlin sees String? and the compiler will not let title.length compile without a null check — ?.length ?: -1 is not a style choice here, it is the only thing that compiles. No NPE is possible from this call site, not because anyone remembered to be careful, but because the type checker would not allow the unsafe version to exist.
The fingerprint of this bug. A dependency that migrated from the old Springorg.springframework.langannotations (or from nothing) to JSpecify, where the migration added@NullMarkedat the package or class level without anyone re-auditing every method for whether it actually, truly, never returns null. The annotation changes what Kotlin believes; it does not change what the method does.
- JSpecify annotations are checkable statically with tools like NullAway or an IDE’s null-analysis inspection — this post deliberately did not run one, to show what a plain
mvn packagelets through. - Spring Framework 7’s own source is
@NullMarkedthroughout, per the Spring Framework null-safety reference; trusting it is reasonable because Spring’s own test suite is large and this exact discipline is enforced there. Trusting a smaller or less-tested dependency’s@NullMarkedclaim the same way is a different risk calculation. -Xjsr305=strict(set in this repo’s pom.xml) is the older, JSR-305-era flag for the same idea; it has no effect on JSpecify annotations, which Kotlin understands natively from 2.1 onward.
Coroutines in a servlet controller: suspend fun, Flow, and the thread you did not expect
Spring Framework 7 lets a Kotlin @RestController declare its handler methods as suspend fun or return a Flow<T>, and this works even though this repository’s app runs on plain spring-boot-starter-webmvc — Tomcat, not WebFlux. That combination raises an obvious question: Spring MVC’s servlet model is fundamentally thread-per-request, so what actually happens at a suspension point?
@GetMapping("/books/thread-check")
suspend fun threadCheck(): Map<String, String> {
val before = Thread.currentThread().name
delay(5) // a real suspension point
val after = Thread.currentThread().name
return mapOf("beforeSuspend" to before, "afterSuspend" to after)
}
Source: BookController.kt. Three real, separate requests to it:
{
"beforeSuspend": "http-nio-8090-exec-4",
"afterSuspend": "kotlinx.coroutines.DefaultExecutor"
}
{
"beforeSuspend": "http-nio-8090-exec-6",
"afterSuspend": "kotlinx.coroutines.DefaultExecutor"
}
{
"beforeSuspend": "http-nio-8090-exec-9",
"afterSuspend": "kotlinx.coroutines.DefaultExecutor"
}
Captured output: 05-thread-check.txt. Every run starts on a different Tomcat worker thread (http-nio-8090-exec-N, as expected — each HTTP request gets whatever worker is free) and resumes, every single time, on kotlinx.coroutines.DefaultExecutor — a thread that belongs to the coroutines library, not to Tomcat’s own pool.
This is the practical consequence of Spring’s roadmap note that suspend/Flow support in MVC still goes through a reactive bridge under the hood, even without WebFlux on the classpath — “coroutines support in Spring MVC without the reactive bridge” is, as of this writing, still an open item on Spring’s own roadmap, not something Framework 7 delivers. Practically, this means kotlinx-coroutines-reactor (not just kotlinx-coroutines-core) has to be on the classpath for this to work at all, regardless of whether your app ever touches WebFlux directly.
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-reactor</artifactId>
<version>1.10.2</version>
</dependency>
Source: pom.xml. Leave this dependency out and the suspend fun and Flow<T> handler methods fail to wire up at all — not a runtime surprise, a startup-time one.
The practical upshot for day-to-day code: suspend fun controller methods do not block a Tomcat worker thread for the duration of the suspension, which is a genuine concurrency win over a blocking Java method doing the equivalent Thread.sleep. But “which thread handles the rest of my method” is not something you can assume is the same thread you started on — anything that relies on thread-local state (a request-scoped bean touched after the suspension point, certain security-context propagation setups) needs to be checked, not assumed, on this stack. Spring Boot 4.1’s spring.reactor.context-propagation=auto setting (with io.micrometer:context-propagation on the classpath) exists specifically to carry that kind of context across exactly this kind of thread hop automatically.
- The
Flow<Book>endpoint in this repo (/books/flow) uses the same underlying bridge; its captured output is in 04-books-flow-endpoint.txt. - If you need request-scoped or security context to survive a suspension point reliably today, measure it for your stack rather than assume Boot 4.1’s
spring.reactor.context-propagation=autocovers your specific case — it is a recent feature and the coverage surface is still growing release to release. Spring Boot’s own Kotlin support reference covers how the Coroutines BOM andkotlinx-coroutines-reactorare managed.
Should your next Spring Boot service actually be Kotlin?
Only if your team already knows Kotlin, or wants to. Nothing in this post is a reason to avoid Kotlin with Spring — the allopen/noarg setup is two lines in a build file once you know they exist, and real null safety for Kotlin-to-Kotlin code is a genuine improvement over Java. But it is not a free safety upgrade for an existing Java codebase: the moment your Kotlin code calls into Java — your own legacy code, or a dependency that has not finished its JSpecify migration — you inherit exactly the NPE risk Java always had, with the added danger that a @NullMarked annotation can make an unsafe call look safe. If the team is Kotlin-fluent and the codebase is new or already Kotlin-heavy, this is a solid combination. If the motivation is “Kotlin eliminates null pointer exceptions,” this post is the counter-evidence to read before deciding.
Further reading: the companion repository for this post is spring-boot-demo/kotlin, including its README and full captured output directory. On JSpecify specifically, see the JSpecify documentation and Spring Framework’s null-safety reference. On the compiler plugins, all-open and no-arg. Related on this blog: Custom Validation in Spring Boot covers the Jakarta Validation side of null/required-field handling, and Spring Boot 4.1 vs Quarkus vs Micronaut is the companion benchmark post for the same Boot 4.1 release.
No Comments yet!