Add kotlin module: null safety, coroutines, allopen/noarg, JSpecify interop (post #42)
This commit is contained in:
@@ -39,8 +39,9 @@ files.
|
||||
| [`multi-datasource/`](multi-datasource) | [Multiple DataSources in Spring Boot 4 with Spring Data JPA](https://ankurm.com/multiple-datasources-spring-boot-4-spring-data-jpa/) | two H2 databases each with its own `DataSource`, Flyway, `EntityManagerFactory` and transaction manager, six ways of getting the wiring wrong, plain vs named `@Transactional`, and `ChainedTransactionManager` under a failing commit |
|
||||
| [`i18n/`](i18n) | [Internationalization (i18n) in Spring Boot 4: MessageSource, LocaleResolver and Localized ProblemDetail](https://ankurm.com/spring-boot-4-internationalization-messagesource-localeresolver-problemdetail/) | English, Marathi and Hindi message bundles, `Accept-Language` vs a cookie resolver, apostrophes and Devanagari digits in `MessageFormat`, the JVM default locale as a hidden fallback, localized validation messages and `ProblemDetail` titles |
|
||||
| [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic |
|
||||
| [`kotlin/`](kotlin) | [Kotlin with Spring Boot 4.1: Null Safety, Coroutines, and What Java Developers Get Wrong](https://ankurm.com/) | Kotlin 2.3.21 (Boot 4.1.1's own managed version, not the latest GA), the real `@Configuration class may not be final` and `@Bean method must not be private or final` failures without the allopen/noarg `spring`/`jpa` compiler plugins, `suspend fun`/`Flow<T>` controller methods proven to still resume off a Tomcat thread under plain `spring-boot-starter-webmvc`, a JSpecify `@NullMarked` Java method that still NPEs at runtime because the contract isn't runtime-checked, and `TestRestTemplate` confirmed absent from Boot 4.1's `spring-boot-test` jar |
|
||||
|
||||
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache` and `hexagonal` are the exception: they have **no `docs/` folder**. Their deeper material lives in
|
||||
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin` and `hexagonal` are the exception: they have **no `docs/` folder**. Their deeper material lives in
|
||||
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/`
|
||||
directory instead of `docs/output/` (`hexagonal` is also the one multi-module reactor in this
|
||||
repo, so its output sits one level deeper still, under each module: `core/output/`,
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user