diff --git a/README.md b/README.md index 9cdd835..9a79fce 100644 --- a/README.md +++ b/README.md @@ -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` 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/`, diff --git a/kotlin/README.md b/kotlin/README.md new file mode 100644 index 0000000..94889ca --- /dev/null +++ b/kotlin/README.md @@ -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` 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` 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. diff --git a/kotlin/docs/output/01-build-failure-without-allopen.txt b/kotlin/docs/output/01-build-failure-without-allopen.txt new file mode 100644 index 0000000..a9279b7 --- /dev/null +++ b/kotlin/docs/output/01-build-failure-without-allopen.txt @@ -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. diff --git a/kotlin/docs/output/02-fixed-startup.txt b/kotlin/docs/output/02-fixed-startup.txt new file mode 100644 index 0000000..1a6ac47 --- /dev/null +++ b/kotlin/docs/output/02-fixed-startup.txt @@ -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. diff --git a/kotlin/docs/output/03-books-endpoint.txt b/kotlin/docs/output/03-books-endpoint.txt new file mode 100644 index 0000000..57a46e8 --- /dev/null +++ b/kotlin/docs/output/03-books-endpoint.txt @@ -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" + } +] + diff --git a/kotlin/docs/output/04-books-flow-endpoint.txt b/kotlin/docs/output/04-books-flow-endpoint.txt new file mode 100644 index 0000000..44550b9 --- /dev/null +++ b/kotlin/docs/output/04-books-flow-endpoint.txt @@ -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" + } +] diff --git a/kotlin/docs/output/05-thread-check.txt b/kotlin/docs/output/05-thread-check.txt new file mode 100644 index 0000000..04dcba1 --- /dev/null +++ b/kotlin/docs/output/05-thread-check.txt @@ -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" +} diff --git a/kotlin/docs/output/06-null-demo-legacy.txt b/kotlin/docs/output/06-null-demo-legacy.txt new file mode 100644 index 0000000..9227e1e --- /dev/null +++ b/kotlin/docs/output/06-null-demo-legacy.txt @@ -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] diff --git a/kotlin/docs/output/07-null-demo-jspecify.txt b/kotlin/docs/output/07-null-demo-jspecify.txt new file mode 100644 index 0000000..00a6e24 --- /dev/null +++ b/kotlin/docs/output/07-null-demo-jspecify.txt @@ -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] diff --git a/kotlin/docs/output/08-null-demo-jspecify-honest.txt b/kotlin/docs/output/08-null-demo-jspecify-honest.txt new file mode 100644 index 0000000..413a782 --- /dev/null +++ b/kotlin/docs/output/08-null-demo-jspecify-honest.txt @@ -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} \ No newline at end of file diff --git a/kotlin/docs/output/09-no-testresttemplate.txt b/kotlin/docs/output/09-no-testresttemplate.txt new file mode 100644 index 0000000..bdecef7 --- /dev/null +++ b/kotlin/docs/output/09-no-testresttemplate.txt @@ -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. diff --git a/kotlin/pom.xml b/kotlin/pom.xml new file mode 100644 index 0000000..cb09dae --- /dev/null +++ b/kotlin/pom.xml @@ -0,0 +1,175 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + kotlin + 1.0.0 + kotlin + Kotlin with Spring Boot 4.1: null safety/JSpecify interop, allopen/noarg, and coroutines in controllers + jar + + + 25 + + 2.3.21 + 25 + 1.10.2 + 2.22.3 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + com.h2database + h2 + runtime + + + + org.jetbrains.kotlin + kotlin-reflect + + + org.jetbrains.kotlin + kotlin-stdlib + + + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson-module-kotlin.version} + + + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + ${kotlinx-coroutines.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + ${kotlinx-coroutines.version} + + + + + org.jspecify + jspecify + 1.0.0 + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.jetbrains.kotlin + kotlin-test-junit5 + test + + + + + kotlin-demo + + + org.springframework.boot + spring-boot-maven-plugin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + -Xjsr305=strict + + + spring + jpa + + + + + compile + compile + compile + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + test-compile + test-compile + + + ${project.basedir}/src/test/kotlin + + + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-maven-noarg + ${kotlin.version} + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + none + + + default-testCompile + none + + + java-compile + compile + compile + + + + + + + diff --git a/kotlin/src/main/java/com/ankurm/kotlin/legacy/LegacyJavaApi.java b/kotlin/src/main/java/com/ankurm/kotlin/legacy/LegacyJavaApi.java new file mode 100644 index 0000000..f147fe2 --- /dev/null +++ b/kotlin/src/main/java/com/ankurm/kotlin/legacy/LegacyJavaApi.java @@ -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; + } +} diff --git a/kotlin/src/main/java/com/ankurm/kotlin/legacy/NullMarkedJavaApi.java b/kotlin/src/main/java/com/ankurm/kotlin/legacy/NullMarkedJavaApi.java new file mode 100644 index 0000000..4a236e2 --- /dev/null +++ b/kotlin/src/main/java/com/ankurm/kotlin/legacy/NullMarkedJavaApi.java @@ -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; + } +} diff --git a/kotlin/src/main/kotlin/com/ankurm/kotlin/Application.kt b/kotlin/src/main/kotlin/com/ankurm/kotlin/Application.kt new file mode 100644 index 0000000..ffc3b06 --- /dev/null +++ b/kotlin/src/main/kotlin/com/ankurm/kotlin/Application.kt @@ -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) { + runApplication(*args) +} diff --git a/kotlin/src/main/kotlin/com/ankurm/kotlin/Book.kt b/kotlin/src/main/kotlin/com/ankurm/kotlin/Book.kt new file mode 100644 index 0000000..0b056a9 --- /dev/null +++ b/kotlin/src/main/kotlin/com/ankurm/kotlin/Book.kt @@ -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, +) diff --git a/kotlin/src/main/kotlin/com/ankurm/kotlin/BookController.kt b/kotlin/src/main/kotlin/com/ankurm/kotlin/BookController.kt new file mode 100644 index 0000000..5e72b31 --- /dev/null +++ b/kotlin/src/main/kotlin/com/ankurm/kotlin/BookController.kt @@ -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 { + // a real, awaitable suspension point -- not just a function keyword + delay(5) + return service.findAll() + } + + @GetMapping("/books/flow") + fun flow(): Flow = 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 { + 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 { + // 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 { + // 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 { + // 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)) + } +} diff --git a/kotlin/src/main/kotlin/com/ankurm/kotlin/BookRepository.kt b/kotlin/src/main/kotlin/com/ankurm/kotlin/BookRepository.kt new file mode 100644 index 0000000..baf83d4 --- /dev/null +++ b/kotlin/src/main/kotlin/com/ankurm/kotlin/BookRepository.kt @@ -0,0 +1,5 @@ +package com.ankurm.kotlin + +import org.springframework.data.jpa.repository.JpaRepository + +interface BookRepository : JpaRepository diff --git a/kotlin/src/main/kotlin/com/ankurm/kotlin/BookService.kt b/kotlin/src/main/kotlin/com/ankurm/kotlin/BookService.kt new file mode 100644 index 0000000..9a69c73 --- /dev/null +++ b/kotlin/src/main/kotlin/com/ankurm/kotlin/BookService.kt @@ -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 = repository.findAll() +} diff --git a/kotlin/src/main/resources/application.properties b/kotlin/src/main/resources/application.properties new file mode 100644 index 0000000..35b1490 --- /dev/null +++ b/kotlin/src/main/resources/application.properties @@ -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 diff --git a/kotlin/src/test/kotlin/com/ankurm/kotlin/BookControllerTest.kt b/kotlin/src/test/kotlin/com/ankurm/kotlin/BookControllerTest.kt new file mode 100644 index 0000000..6da9025 --- /dev/null +++ b/kotlin/src/test/kotlin/com/ankurm/kotlin/BookControllerTest.kt @@ -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 { + 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")) + } +}