Add kotlin module: null safety, coroutines, allopen/noarg, JSpecify interop (post #42)

This commit is contained in:
Claude
2026-10-08 05:35:15 +00:00
parent 83d245e726
commit f736108067
21 changed files with 659 additions and 1 deletions
+64
View File
@@ -0,0 +1,64 @@
# kotlin
Companion project for the article **[Kotlin with Spring Boot 4.1: Null Safety, Coroutines, and What Java Developers Get Wrong](https://ankurm.com/)** on **[ankurm.com](https://ankurm.com)**.
Every console block quoted in the article came out of `docs/output/`. 03-08 are raw `curl` captures
against a running instance; 01, 02 and 09 are real build/startup transcripts. `BookControllerTest.kt`
turns the null-safety claims into assertions, so a change in Spring, Kotlin or JSpecify behaviour
breaks the build, not just the prose.
There is deliberately **no `docs/NN-topic.md` chapter folder**: the deeper material lives in
collapsible "going deeper" sections inside the article itself, next to the paragraph each one
extends. `docs/output/` (captured transcripts) is the one thing that still lives under `docs/`.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| Kotlin | 2.3.21 (Spring Boot 4.1.1's own managed version; latest actual GA is 2.4.20 — see the article) |
| kotlinx.coroutines | 1.10.2 |
| jackson-module-kotlin | 2.22.3 (not BOM-managed; pin it explicitly) |
| JSpecify | 1.0.0 |
| JDK | 25 (GraalVM CE 25.4.4.1.1) |
| Maven | 3.9 |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # runs BookControllerTest, re-verifying every null-safety claim
mvn spring-boot:run # starts the app on :8090 with 3 seeded books
```
## What's demonstrated
| Area | Where |
|---|---|
| allopen/noarg via the `spring`/`jpa` kotlin compiler plugins | `pom.xml`'s `kotlin-maven-plugin` config; `BookService.kt`, `Book.kt`, `Application.kt` |
| Real build failure without the `spring` plugin (`@Configuration class may not be final`, then `@Bean method must not be private or final`) | `docs/output/01-build-failure-without-allopen.txt` |
| Clean startup with both plugins restored | `docs/output/02-fixed-startup.txt` |
| `suspend fun` / `Flow<T>` controller methods under plain `spring-boot-starter-webmvc` (Tomcat, not WebFlux) | `BookController.kt` (`/books`, `/books/flow`) |
| Which thread actually resumes a suspended controller method | `/books/thread-check`, `docs/output/05-thread-check.txt` |
| Kotlin platform types (`String!`) for un-annotated Java APIs | `legacy/LegacyJavaApi.java`, `/books/null-demo/legacy/{id}`, `docs/output/06-null-demo-legacy.txt` |
| JSpecify `@NullMarked`/`@Nullable` interop, and that it is a static contract, not a runtime guarantee | `legacy/NullMarkedJavaApi.java`, `/books/null-demo/jspecify/{id}`, `/books/null-demo/jspecify-honest/{id}`, `docs/output/07-08` |
| Mixed Kotlin+Java compilation in one Maven module | `pom.xml` (`kotlin-maven-plugin` compiles `src/main/kotlin` + `src/main/java`; `maven-compiler-plugin`'s `default-compile`/`default-testCompile` disabled, `java-compile` picks up the default `src/main/java`) |
| `TestRestTemplate` no longer shipping in Spring Boot 4.1's `spring-boot-test` module | `docs/output/09-no-testresttemplate.txt`, `BookControllerTest.kt` (plain `java.net.http.HttpClient` instead) |
## Captured output
| File | What it shows |
|---|---|
| `01-build-failure-without-allopen.txt` | both real failures when the `spring` compiler plugin is removed |
| `02-fixed-startup.txt` | clean startup once it's restored |
| `03-books-endpoint.txt` | `GET /books`, a `suspend fun` controller method, real JSON |
| `04-books-flow-endpoint.txt` | `GET /books/flow`, a `Flow<Book>` controller method |
| `05-thread-check.txt` | three runs of `/books/thread-check`: before/after thread names across a real suspension point |
| `06-null-demo-legacy.txt` | the platform-type NPE, with no compiler hint anywhere, plus its stack trace |
| `07-null-demo-jspecify.txt` | the `@NullMarked` Java method that still NPEs, plus its stack trace |
| `08-null-demo-jspecify-honest.txt` | the `@Nullable`-annotated method the Kotlin compiler forces a null check on — no NPE |
| `09-no-testresttemplate.txt` | `TestRestTemplate` verified absent from `spring-boot-test-4.1.1.jar` |
Endpoints default to `http://localhost:8090`. H2 is in-memory (`jdbc:h2:mem:kotlindemo`), reseeded
on every restart with three books, one of them with a `null` title.
@@ -0,0 +1,24 @@
Real, captured output: removing the "spring" compilerPlugin entry from pom.xml's
kotlin-maven-plugin configuration (keeping only "jpa"), then running:
mvn -q -B clean package -DskipTests && java -jar target/kotlin-demo.jar
Failure #1 -- BookService's @Transactional needs a CGLIB subclass proxy, but
BookService is a final Kotlin class (and Application, as a @Configuration class,
has the same problem even before any bean method runs):
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
Failure #2 -- after manually adding `open` to just the Application class to get
past failure #1 (sed -i 's/^class Application {/open class Application {/' on
Application.kt), rebuilding and rerunning hits the next thing the "spring"
plugin would otherwise have handled automatically: the @Bean method itself.
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
Restoring the "spring" compilerPlugin entry (alongside "jpa") and reverting
Application back to a plain, non-open `class Application` -- i.e. the
checked-in state of this repository -- builds and starts cleanly. See
02-fixed-startup.txt for that run.
+19
View File
@@ -0,0 +1,19 @@
Real, captured output of a clean startup with the checked-in pom.xml (both the
"spring" and "jpa" compilerPlugin entries present) and the checked-in
Application.kt (a plain, non-open `class Application`):
mvn -q -B clean package -DskipTests
java -jar target/kotlin-demo.jar
2026-10-08T10:59:52.685+05:30 INFO 1034 --- [kotlin-demo] [ main] com.ankurm.kotlin.ApplicationKt : Starting ApplicationKt v1.0.0 using Java 25.0.4.1.1 with PID 1034 (/home/claude/spring-boot-demo/kotlin/target/kotlin-demo.jar started by root in /home/claude/spring-boot-demo/kotlin)
2026-10-08T10:59:52.708+05:30 INFO 1034 --- [kotlin-demo] [ main] com.ankurm.kotlin.ApplicationKt : No active profile set, falling back to 1 default profile: "default"
2026-10-08T10:59:55.393+05:30 INFO 1034 --- [kotlin-demo] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-08T10:59:57.021+05:30 INFO 1034 --- [kotlin-demo] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8090 (http)
2026-10-08T10:59:57.044+05:30 INFO 1034 --- [kotlin-demo] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
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)
No BeanDefinitionParsingException, no CGLIB proxy failure: @Service-annotated
BookService and the @Configuration Application class are both final Kotlin
classes in source, but the "spring" compiler plugin (kotlin-maven-allopen under
the hood) rewrote them to be open at compile time -- before Spring ever saw them.
+18
View File
@@ -0,0 +1,18 @@
=== GET /books ===
[
{
"id": 1,
"title": "Kotlin in Action",
"author": "Jemerov & Isakova"
},
{
"id": 2,
"title": "Effective Kotlin",
"author": "Marcin Moskala"
},
{
"id": 3,
"author": "Unknown"
}
]
@@ -0,0 +1,17 @@
=== GET /books/flow ===
[
{
"id": 1,
"title": "Kotlin in Action",
"author": "Jemerov & Isakova"
},
{
"id": 2,
"title": "Effective Kotlin",
"author": "Marcin Moskala"
},
{
"id": 3,
"author": "Unknown"
}
]
+17
View File
@@ -0,0 +1,17 @@
=== GET /books/thread-check (run 1) ===
{
"beforeSuspend": "http-nio-8090-exec-4",
"afterSuspend": "kotlinx.coroutines.DefaultExecutor"
}
=== GET /books/thread-check (run 2) ===
{
"beforeSuspend": "http-nio-8090-exec-6",
"afterSuspend": "kotlinx.coroutines.DefaultExecutor"
}
=== GET /books/thread-check (run 3) ===
{
"beforeSuspend": "http-nio-8090-exec-9",
"afterSuspend": "kotlinx.coroutines.DefaultExecutor"
}
@@ -0,0 +1,11 @@
=== GET /books/null-demo/legacy/0 (expect 500, NPE) ===
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 08 Oct 2026 05:30:22 GMT
Connection: close
{"timestamp":"2026-10-08T05:30:22.108Z","status":500,"error":"Internal Server Error","path":"/books/null-demo/legacy/0"}
--- server log (stack trace) ---
java.lang.NullPointerException: titleForId(...) must not be null
at com.ankurm.kotlin.BookController.legacyNullDemo(BookController.kt:47) ~[!/:1.0.0]
@@ -0,0 +1,11 @@
=== GET /books/null-demo/jspecify/0 (expect 500, NPE despite @NullMarked non-null contract) ===
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 08 Oct 2026 05:30:33 GMT
Connection: close
{"timestamp":"2026-10-08T05:30:33.758Z","status":500,"error":"Internal Server Error","path":"/books/null-demo/jspecify/0"}
--- server log (stack trace) ---
java.lang.NullPointerException: titleForId(...) must not be null
at com.ankurm.kotlin.BookController.jspecifyNullDemo(BookController.kt:57) ~[!/:1.0.0]
@@ -0,0 +1,13 @@
=== GET /books/null-demo/jspecify-honest/0 (expect 200, no NPE -- compiler forced the null check) ===
HTTP/1.1 200
Content-Type: application/json
Content-Length: 13
Date: Thu, 08 Oct 2026 05:30:33 GMT
{"length":-1}=== GET /books/null-demo/jspecify-honest/5 (non-null id, expect real length) ===
HTTP/1.1 200
Content-Type: application/json
Content-Length: 12
Date: Thu, 08 Oct 2026 05:30:33 GMT
{"length":7}
@@ -0,0 +1,25 @@
Real, verified absence: Spring Boot 4.1.1's spring-boot-test module does not
ship org.springframework.boot.test.web.client.TestRestTemplate at all.
Resolved test-scope dependency tree (mvn dependency:tree), relevant slice:
[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:4.1.1:test
[INFO] | +- org.springframework.boot:spring-boot-test:jar:4.1.1:test
[INFO] | +- org.springframework.boot:spring-boot-test-autoconfigure:jar:4.1.1:test
...
[INFO] | +- org.springframework:spring-test:jar:7.0.9:test
Checked directly against the jar itself (not docs, not a blog post):
$ unzip -l spring-boot-test-4.1.1.jar | grep -i "resttemplate\|restclient\|webtestclient"
(no output -- none of those classes are in the jar)
Confirmed via javac/kotlinc failing to resolve it: a first draft of
BookControllerTest.kt that imported
org.springframework.boot.test.web.client.TestRestTemplate failed to compile with
"Unresolved reference 'TestRestTemplate'" and "Unresolved reference
'getForEntity'" -- not a missing dependency, a genuinely absent class.
This repository's tests use plain java.net.http.HttpClient against
@LocalServerPort instead. The direct replacement in Spring Framework 7 is
RestTestClient, covered in its own post.
+175
View File
@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>kotlin</artifactId>
<version>1.0.0</version>
<name>kotlin</name>
<description>Kotlin with Spring Boot 4.1: null safety/JSpecify interop, allopen/noarg, and coroutines in controllers</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<!-- Spring Boot 4.1.1's own managed version (spring-boot-dependencies POM), not the latest
Kotlin GA (2.4.20 at time of writing): see the post for why that distinction matters. -->
<kotlin.version>2.3.21</kotlin.version>
<kotlin.compiler.jvmTarget>25</kotlin.compiler.jvmTarget>
<kotlinx-coroutines.version>1.10.2</kotlinx-coroutines.version>
<jackson-module-kotlin.version>2.22.3</jackson-module-kotlin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>${jackson-module-kotlin.version}</version>
</dependency>
<!-- Coroutines: needed on the classpath even under plain Tomcat/MVC; see the post
for why suspend/Flow controller methods still go through this reactive bridge. -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core</artifactId>
<version>${kotlinx-coroutines.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-reactor</artifactId>
<version>${kotlinx-coroutines.version}</version>
</dependency>
<!-- JSpecify, explicitly, so the legacy/annotated Java interop demo compiles against the
exact same annotations Spring Framework 7 itself uses. -->
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>kotlin-demo</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals><goal>compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals><goal>test-compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals><goal>compile</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,17 @@
package com.ankurm.kotlin.legacy;
/**
* Deliberately NOT annotated with JSpecify -- this is what every pre-JSpecify Java library
* (and most of the Java ecosystem, still) looks like from Kotlin's point of view: a
* "platform type". See NullMarkedJavaApi for the JSpecify-annotated version of the same idea.
*/
public class LegacyJavaApi {
/** Looks like it always returns a title. In this demo, it lies on purpose. */
public static String titleForId(int id) {
if (id == 0) {
return null; // the lie: nothing in the Java signature says this is possible
}
return "Book #" + id;
}
}
@@ -0,0 +1,29 @@
package com.ankurm.kotlin.legacy;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
/**
* Same shape as LegacyJavaApi, but annotated with JSpecify. @NullMarked makes every
* unannotated type usage in this class non-null by default; @Nullable marks the exception.
* This is exactly the convention Spring Framework 7 itself uses internally.
*/
@NullMarked
public class NullMarkedJavaApi {
/** Declared non-null by the absence of @Nullable -- and, in this demo, lies anyway. */
public static String titleForId(int id) {
if (id == 0) {
return null; // violates the method's own JSpecify contract on purpose
}
return "Book #" + id;
}
/** The honest version: nullability is part of the signature, so Kotlin sees String?. */
public static @Nullable String titleForIdOrNull(int id) {
if (id == 0) {
return null;
}
return "Book #" + id;
}
}
@@ -0,0 +1,23 @@
package com.ankurm.kotlin
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Bean
@SpringBootApplication
class Application {
@Bean
fun seed(repository: BookRepository): CommandLineRunner = CommandLineRunner {
if (repository.count() == 0L) {
repository.save(Book(title = "Kotlin in Action", author = "Jemerov & Isakova"))
repository.save(Book(title = "Effective Kotlin", author = "Marcin Moskala"))
repository.save(Book(title = null, author = "Unknown"))
}
}
}
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
@@ -0,0 +1,25 @@
package com.ankurm.kotlin
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
/**
* A Kotlin data class as a JPA entity. Hibernate needs a no-arg constructor to instantiate
* this via reflection/proxying -- a data class with all-val constructor params doesn't have
* one on its own. The `jpa` compiler plugin (kotlin-maven-noarg, preset "jpa") generates a
* synthetic no-arg constructor for every class annotated @Entity, without you writing one.
*
* `title` is nullable on purpose: it demonstrates how a Kotlin-native nullable property maps
* straight onto a nullable DB column, no JSpecify or platform-type question involved at all --
* that question only shows up at the Java/Kotlin boundary, which this class isn't part of.
*/
@Entity
data class Book(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val title: String?,
val author: String,
)
@@ -0,0 +1,68 @@
package com.ankurm.kotlin
import com.ankurm.kotlin.legacy.LegacyJavaApi
import com.ankurm.kotlin.legacy.NullMarkedJavaApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
@RestController
class BookController(private val service: BookService) {
@GetMapping("/books")
suspend fun all(): List<Book> {
// a real, awaitable suspension point -- not just a function keyword
delay(5)
return service.findAll()
}
@GetMapping("/books/flow")
fun flow(): Flow<Book> = flow {
for (book in service.findAll()) {
delay(5)
emit(book)
}
}
/**
* This app runs on plain spring-boot-starter-webmvc (Tomcat), not WebFlux. The question
* this endpoint answers by actually running: which thread handles a `suspend fun`
* controller method under the servlet stack? See docs/output for the real answer.
*/
@GetMapping("/books/thread-check")
suspend fun threadCheck(): Map<String, String> {
val before = Thread.currentThread().name
delay(5) // the suspension point -- execution may resume on a different thread
val after = Thread.currentThread().name
return mapOf("beforeSuspend" to before, "afterSuspend" to after)
}
@GetMapping("/books/null-demo/legacy/{id}")
fun legacyNullDemo(@PathVariable id: Int): Map<String, Any> {
// LegacyJavaApi.titleForId returns a Kotlin "platform type" (String!): Kotlin lets us
// treat it as non-null with zero warning, exactly like every un-annotated Java API.
val title: String = LegacyJavaApi.titleForId(id)
return mapOf("length" to title.length) // NPEs for id=0, with no compiler hint anywhere
}
@GetMapping("/books/null-demo/jspecify/{id}")
fun jspecifyNullDemo(@PathVariable id: Int): Map<String, Any> {
// NullMarkedJavaApi.titleForId is JSpecify-annotated non-null: Kotlin infers a real,
// non-platform String here -- but JSpecify is a contract, not a runtime guarantee.
// javac happily compiles a method that violates its own @NullMarked contract, so this
// still NPEs for id=0, exactly like the legacy endpoint above.
val title: String = NullMarkedJavaApi.titleForId(id)
return mapOf("length" to title.length)
}
@GetMapping("/books/null-demo/jspecify-honest/{id}")
fun jspecifyHonestNullDemo(@PathVariable id: Int): Map<String, Any> {
// titleForIdOrNull is declared @Nullable in JSpecify, so Kotlin sees String? here --
// the compiler *forces* a null check before .length compiles at all.
val title: String? = NullMarkedJavaApi.titleForIdOrNull(id)
return mapOf("length" to (title?.length ?: -1))
}
}
@@ -0,0 +1,5 @@
package com.ankurm.kotlin
import org.springframework.data.jpa.repository.JpaRepository
interface BookRepository : JpaRepository<Book, Long>
@@ -0,0 +1,26 @@
package com.ankurm.kotlin
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* An ordinary Kotlin class -- note: no `open` keyword anywhere. Kotlin classes are final by
* default, unlike Java classes. @Transactional below needs Spring to generate a CGLIB subclass
* proxy at runtime, and CGLIB cannot subclass a final class.
*
* This compiles and starts up fine ONLY because the kotlin-maven-plugin's "spring" compiler
* plugin (backed by kotlin-maven-allopen) sees @Service on this class and rewrites it to be
* open automatically, as if every class and member reachable from @Component, @Service,
* @Configuration, @Controller and a handful of other stereotypes had `open` written on it.
* Remove the "spring" compilerPlugin entry from pom.xml and this exact file produces a real
* context-startup failure -- see the post for the verbatim error.
*/
@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()
}
@@ -0,0 +1,5 @@
spring.application.name=kotlin-demo
server.port=8090
spring.datasource.url=jdbc:h2:mem:kotlindemo
spring.jpa.hibernate.ddl-auto=update
spring.jackson.default-property-inclusion=non_null
@@ -0,0 +1,65 @@
package com.ankurm.kotlin
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* These tests exist to keep the repository honest: every number in the post's
* null-safety and coroutines sections is one of the assertions below, not a
* hand-typed guess. If Spring, Kotlin or JSpecify ever change this behaviour,
* this test (not just the post) breaks.
*
* Plain java.net.http.HttpClient, not TestRestTemplate: Spring Boot 4.1's
* spring-boot-test module does not ship a TestRestTemplate class at all (see
* docs/output/09-no-testresttemplate.txt) -- Spring Framework 7's RestTestClient
* is the replacement, and it gets its own post.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookControllerTest {
@LocalServerPort
private var port: Int = 0
private val client = HttpClient.newHttpClient()
private fun get(path: String): HttpResponse<String> {
val request = HttpRequest.newBuilder(URI.create("http://localhost:$port$path")).GET().build()
return client.send(request, HttpResponse.BodyHandlers.ofString())
}
@Test
fun `books endpoint returns the three seeded rows`() {
val response = get("/books")
assertEquals(200, response.statusCode())
assertTrue(response.body().contains("Kotlin in Action"))
assertTrue(response.body().contains("Effective Kotlin"))
}
@Test
fun `legacy platform-type NPE has no compiler hint, blows up at runtime`() {
val response = get("/books/null-demo/legacy/0")
assertEquals(500, response.statusCode())
}
@Test
fun `NullMarked Java API lies about non-null, still NPEs at runtime`() {
// javac compiles this Java method despite it violating its own @NullMarked
// contract; JSpecify is a static contract, not a runtime guarantee.
val response = get("/books/null-demo/jspecify/0")
assertEquals(500, response.statusCode())
}
@Test
fun `honest Nullable-annotated API forces a null check, never NPEs`() {
val response = get("/books/null-demo/jspecify-honest/0")
assertEquals(200, response.statusCode())
assertTrue(response.body().contains("\"length\":-1"))
}
}