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
@@ -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"))
}
}