"The Catalog" — Spring Boot 4.1, JPA and REST in Kotlin
Kabir's first entity is a data class, and his first integration test dies with a StackOverflowError. We build product-catalog-service on Spring Boot 4.1 and Kotlin 2.4.20: entities that are not data classes, Spring Data with nullable returns, validation and Jackson 3 at the REST boundary, ProblemDetail errors, transactions that roll back on checked exceptions, the Gradle build, Testcontainers 2 and virtual threads.
Story Opening
The test Lena had asked for was four lines: save a supplier and a product, load the product, print it. Kabir had written the entities the way two months of Kotlin had taught him to write anything with fields:
// Fragment of story/DataClassEntities.kt (test sources)// Kabir's first attempt: entities as data classes, the way he'd model a DTO.@Entity@Table(name = "naive_suppliers")data class NaiveSupplier( @Id @GeneratedValue var id: Long? = null, val code: String, @OneToMany(mappedBy = "supplier") val products: MutableList<NaiveProduct> = mutableListOf(),)
@Entity@Table(name = "naive_products")data class NaiveProduct( @Id @GeneratedValue var id: Long? = null, val sku: String, var pricePaise: Long, @ManyToOne(fetch = FetchType.LAZY) val supplier: NaiveSupplier,)The test ran for a second and died with java.lang.StackOverflowError, thousands of frames deep, nearly all of them toString calls. He moved the println outside the transaction, which got rid of the overflow and replaced it with LazyInitializationException: Could not initialize proxy [com.shelfwise.story.NaiveSupplier#1] - no session.
“A data class says two objects with the same fields are the same thing,” Lena said. “Hibernate says a row with an id is the same thing, even when you haven’t loaded it yet. Pick one. For entities, it’s Hibernate’s.”
Java → Kotlin: The Quick Map
| Spring/Java habit | Kotlin on Spring Boot 4.1 | Note |
|---|---|---|
@Entity POJO, no-arg constructor, getters/setters | class Product(…) + plugin.jpa | A plain class; never a data class (or Lombok @Data) |
equals/hashCode on the id | On a business key (sku) | Stable before and after persist |
Optional<Product> findById(…) | findByIdOrNull(id), or fun findBySku(…): Product? | Spring Data honours Kotlin nullability |
@RequestParam(required = false) Long max | maxPricePaise: Long? | Nullability is the “required” flag |
@NotBlank private String name; | @NotBlank val name: String | Kotlin 2.4 puts it on the field too (Part 9) |
com.fasterxml.jackson.module:jackson-module-kotlin | tools.jackson.module:jackson-module-kotlin | Jackson 3 in Boot 4 |
| MapStruct mappers | Extension functions (Product.toResponse()) | Plain code, no annotation processor |
@Transactional(rollbackFor = Exception.class) everywhere | @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) once | Kotlin has no checked exceptions to remind you |
GenericContainer + @DynamicPropertySource | @ServiceConnection on a container bean | Testcontainers 2 |
TransactionTemplate.execute(…) returning @Nullable T | Returns non-null T for a non-null lambda | JSpecify generics in Spring Framework 7 |
Conceptual Deep-Dive
The service, end to end
DTOs, @Valid"] W --> S["ProductService
@Transactional"] S --> R["ProductRepository
SupplierRepository"] R --> DB[("PostgreSQL 18
schema by Flyway")] W -.->|"exceptions"| P["ProblemHandling
RFC 9457 ProblemDetail"]
Four layers, nothing exotic: a controller that speaks DTOs, a transactional service, Spring Data repositories, and PostgreSQL with a Flyway-owned schema. What changes in Kotlin is not the shape but the seams: the entity, the boundary where JSON becomes objects, and the places where Kotlin’s type system reaches into Spring.
Entities are not values
Kotlin’s reflex for “a class with fields” is a data class, and for DTOs and events it’s the right reflex (Part 4). A JPA entity is a different kind of object, and the opening’s test fails in three distinct ways because of it:
| Data class generates | From | Why it breaks with Hibernate |
|---|---|---|
toString() | Every constructor property, associations included | Walks supplier → products → supplier… until StackOverflowError; outside a session, touching the lazy proxy throws LazyInitializationException |
equals()/hashCode() | Every constructor property, id included | The id is null until persist, so the hash changes and the entity is “lost” in a HashSet; comparing associations also loads them |
copy(), componentN() | Every constructor property | A copy of a managed entity is a second object claiming the same row |
None of this is new to a JPA developer: lazy proxies, persistent collections and late-assigned ids broke Lombok’s @Data on entities in exactly the same way. Kotlin just makes the mistake one keyword shorter, and makes it look idiomatic.
So an entity is a plain class with three deliberate choices: equals and hashCode on something stable, a toString that never touches an association, and properties Hibernate can manage. The shift: in Kotlin you model values by default; an entity is the exception, and you write its identity by hand.
Which identity? The catalog has a natural key, the SKU: immutable, unique, and known before the row exists, so equals and hashCode use it and work identically before and after persist. (One cost: reading sku from an unloaded proxy initialises it, and outside a session that throws, whereas a proxy’s id getter doesn’t.) When there’s no natural key, the two common answers are an application-assigned id (generate the UUID in the constructor, so it’s never null) or id-based equals with a constant hashCode() per class, which stays correct across persist at the cost of hashing every instance into one bucket.
Where Kotlin’s types reach into Spring
The other half is good news: Spring reads Kotlin’s metadata through kotlin-reflect, so nullability, default values and constructor parameters carry meaning in controllers, Spring Data and Jackson, as the Technical Explanation shows piece by piece. And since Spring Framework 7 annotates its APIs with JSpecify, Spring’s methods no longer return platform types (Part 2); Jakarta and Hibernate APIs, such as EntityManager.find, still do.
Technical Explanation
The entity
// Fragment of product/Product.ktenum class Category { PASTA_AND_GRAINS, DAIRY, BEVERAGES, SNACKS, BREAKFAST, PERSONAL_CARE, SMALL_APPLIANCES }
// A plain class, not a data class: an entity has an identity, a lifecycle and lazy associations.// plugin.jpa makes it open (for Hibernate's proxies) and gives it a no-arg constructor.@Entity@Table(name = "products")class Product( @Column(nullable = false, unique = true, updatable = false) val sku: String, var name: String, var description: String, @Enumerated(EnumType.STRING) var category: Category, var pricePaise: Long, @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "supplier_id") var supplier: Supplier, @ElementCollection @CollectionTable(name = "product_dietary_tags") @Column(name = "tag") var dietaryTags: MutableSet<String> = mutableSetOf(), val createdAt: Instant,) { @Id @GeneratedValue @UuidGenerator(style = UuidGenerator.Style.VERSION_7) var id: UUID? = null protected set // assigned by Hibernate on persist; nobody else should write it
@Version var version: Long = 0 protected set
override fun equals(other: Any?) = this === other || (other is Product && sku == other.sku)
override fun hashCode() = sku.hashCode()
// Never touch lazy associations here: toString runs in log lines, debuggers and test failures. override fun toString() = "Product(sku=$sku, name=$name)"}sku is the identity: a val, updatable = false, unique in the database, and the only thing equals and hashCode read. Hibernate’s proxy for a Product is a subclass, and other is Product is true for it, so the comparison works on proxies too. The id is a var with protected set: Hibernate writes the field directly (the mapping annotations are on fields), and the restricted setter only keeps Kotlin callers from assigning ids. @Version turns on optimistic locking: every update checks and increments it, so two requests that loaded the same version can’t both win. @UuidGenerator(style = VERSION_7) gives time-ordered UUIDs, which index far better than random ones. And these are java.util.UUID and java.time.Instant, not Kotlin’s Uuid and kotlin.time.Instant: Hibernate and the JDBC driver map the Java types natively, and this is the boundary where the series said it would switch.
plugin.jpa did two things you can see in the bytecode:
// javap -p com.shelfwise.catalog.product.Product, trimmedpublic class Product { // not final: all-open's JPA preset (Kotlin 2.3.20+) private final String sku; private UUID id; public String getSku(); // not final either, so the proxy can intercept it public UUID getId(); protected void setId(UUID); // protected set public Product(String, String, String, Category, long, Supplier, Set<String>, Instant); public Product(); // no-arg: hidden from Kotlin, used by Hibernate}That no-arg constructor is the source of the one Kotlin-specific JPA trap that survives a plain class; the Gotchas show it. Supplier follows the same pattern, keyed on its code. There’s no @OneToMany back from supplier to products: a bidirectional association is two things to keep in sync and one more path for a careless toString, and nothing in this service needs it.
Repositories: nullable instead of Optional
// Fragment of product/Repositories.ktinterface ProductRepository : JpaRepository<Product, UUID> { // A nullable return type is enough: Spring Data returns null instead of throwing or wrapping in Optional. @EntityGraph(attributePaths = ["supplier", "dietaryTags"]) fun findBySku(sku: String): Product?
@EntityGraph(attributePaths = ["supplier", "dietaryTags"]) fun findByCategoryAndPricePaiseLessThanEqualOrderByPricePaise(category: Category, maxPricePaise: Long): List<Product>
fun existsBySku(sku: String): Boolean}
interface SupplierRepository : JpaRepository<Supplier, UUID> { fun findByCode(code: String): Supplier?}findBySku returns Product?, and Spring Data returns null when there’s no row. For the inherited findById, which returns Optional because it’s declared in Java, spring-data-commons ships a Kotlin extension, findByIdOrNull. The @EntityGraph fetches the supplier and tags in the same query, which matters because the service sets spring.jpa.open-in-view: false. Boot’s default keeps the persistence context open until the response is written, so lazy loading during JSON rendering quietly runs extra queries; with it off, that loading fails loudly instead, and the repository method has to say what the response needs.
The service and its transactions
// Fragment of product/ProductService.kt@Service@Transactional(readOnly = true)class ProductService( private val products: ProductRepository, private val suppliers: SupplierRepository, private val clock: Clock,) { fun bySku(sku: String): Product = products.findBySku(sku) ?: throw ProductNotFound(sku)
fun search(category: Category, maxPricePaise: Long): List<Product> = products.findByCategoryAndPricePaiseLessThanEqualOrderByPricePaise(category, maxPricePaise)
@Transactional fun create(new: NewProduct): Product { if (products.existsBySku(new.sku)) throw DuplicateSku(new.sku) val supplier = suppliers.findByCode(new.supplierCode) ?: throw UnknownSupplier(new.supplierCode) return products.save( Product( sku = new.sku, name = new.name, description = new.description, category = new.category, pricePaise = new.pricePaise, supplier = supplier, dietaryTags = new.dietaryTags.toMutableSet(), createdAt = clock.instant(), ), ) }
@Transactional fun reprice(sku: String, pricePaise: Long): Product = bySku(sku).apply { this.pricePaise = pricePaise }
// Applies a supplier's "SKU,pricePaise" price list. If reading fails halfway with an IOException, a // checked exception, the lines already applied must be undone: RollbackOn.ALL_EXCEPTIONS makes it so. @Transactional fun importPriceList(priceList: Reader): Int = priceList.buffered().useLines { lines -> lines.map { it.split(',') }.onEach { (sku, paise) -> reprice(sku.trim(), paise.trim().toLong()) }.count() }}The class is @Transactional(readOnly = true), with the writes opting in, and plugin.spring makes it open so Spring can proxy it. reprice changes the managed entity with apply, and dirty checking writes it at commit. importPriceList calls reprice on this, which bypasses the proxy exactly as it would in Java; here that’s harmless, because the call already runs inside the import’s transaction.
importPriceList is also Part 9’s promise. Reading from a Reader can throw IOException, which is a checked exception to Spring, and @Transactional by default rolls back only on unchecked ones. A Java developer is reminded by the throws IOException on the method signature; a Kotlin developer isn’t. One line in the configuration changes the default for the whole application:
import org.springframework.beans.factory.BeanRegistrarDslimport org.springframework.context.annotation.Configurationimport org.springframework.context.annotation.Importimport org.springframework.transaction.annotation.EnableTransactionManagementimport org.springframework.transaction.annotation.RollbackOnimport java.time.Clock
// Spring rolls back on unchecked exceptions only. Kotlin has no checked exceptions, so a Kotlin// function can throw IOException without anyone noticing, and the transaction would commit (Part 9).@Configuration(proxyBeanMethods = false)@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)@Import(CatalogBeans::class)class CatalogConfiguration
// Programmatic bean registration with Spring Framework 7's Kotlin DSL.class CatalogBeans : BeanRegistrarDsl({ registerBean<Clock> { Clock.systemUTC() }})PriceImportTest feeds the import a reader whose connection drops after the first line, and checks that the first line’s price change was rolled back. CatalogBeans uses Spring Framework 7’s BeanRegistrarDsl, a programmatic way to register beans with conditions, loops and profiles in plain Kotlin. For a single Clock an @Bean method would do as well; either is easier to replace in tests than Instant.now() inside the entity.
The REST boundary: DTOs, validation, Jackson 3
// Fragment of web/ProductDtos.kt// Constraint annotations without a use-site target: under Kotlin 2.4's defaults they land on the// constructor parameter and the field, and Bean Validation reads the field (Part 9).data class CreateProductRequest( @Pattern(regexp = "SHW-\\d{4}", message = "must look like SHW-1234") val sku: String, @NotBlank @Size(max = 200) val name: String, val description: String = "", val category: Category, @Positive val pricePaise: Long, @NotBlank val supplierCode: String, val dietaryTags: Set<String> = emptySet(),)
data class RepriceRequest( @Positive val pricePaise: Long,)
data class ProductResponse( val sku: String, val name: String, val description: String, val category: Category, val pricePaise: Long, val price: String, val supplier: String, val dietaryTags: List<String>,)
// Mapping as extension functions: the entity knows nothing about the web layer, and no mapper library.fun CreateProductRequest.toNewProduct() = NewProduct(sku, name.trim(), description, category, pricePaise, supplierCode, dietaryTags)
fun Product.toResponse() = ProductResponse( sku = sku, name = name, description = description, category = category, pricePaise = pricePaise, price = "₹%d.%02d".format(pricePaise / 100, pricePaise % 100), supplier = supplier.name, dietaryTags = dietaryTags.sorted(),)The DTOs are data classes, correctly this time: they’re values. Mapping is two extension functions, so the entity knows nothing about JSON and there’s no mapper library to configure. The validation annotations have no use-site target, and under Kotlin 2.4’s defaults that is enough: javap -v shows @NotBlank and @Size on both the constructor parameter and the private field name, and Hibernate Validator reads the field (Part 9 has the rules). Before 2.4, the same code validated nothing unless the build opted in with -Xannotation-default-target=param-property, which is why older Kotlin code is full of @field:NotBlank.
// Fragment of web/ProductController.kt // A nullable parameter is an optional request parameter: Spring reads Kotlin's nullability. @GetMapping fun search(@RequestParam category: Category, @RequestParam maxPricePaise: Long?): List<ProductResponse> = catalog.search(category, maxPricePaise ?: Long.MAX_VALUE).map { it.toResponse() }
@PostMapping fun create(@Valid @RequestBody request: CreateProductRequest): ResponseEntity<ProductResponse> { val product = catalog.create(request.toNewProduct()) return ResponseEntity.created(URI.create("/api/products/${product.sku}")).body(product.toResponse()) }// Fragment of web/ProblemHandling.kt
// RFC 9457 problem details for every error. The base class already covers Spring MVC's own exceptions// (unreadable JSON, missing parameters, wrong types); this adds the catalog's and the field errors.@RestControllerAdviceclass ProblemHandling : ResponseEntityExceptionHandler() { @ExceptionHandler fun catalogFailure(e: CatalogException): ProblemDetail = when (e) { // exhaustive: no else branch is ProductNotFound -> problem(HttpStatus.NOT_FOUND, "Product not found", e).apply { setProperty("sku", e.sku) } is DuplicateSku -> problem(HttpStatus.CONFLICT, "Duplicate SKU", e).apply { setProperty("sku", e.sku) } is UnknownSupplier -> problem(HttpStatus.BAD_REQUEST, "Unknown supplier", e).apply { setProperty("supplierCode", e.code) } }
// Two requests changed the same product; @Version detected it, and the later one loses. @ExceptionHandler fun concurrentUpdate(e: ObjectOptimisticLockingFailureException): ProblemDetail = problem(HttpStatus.CONFLICT, "Concurrent update", e).apply { detail = "The product changed while this request ran; retry it." }
// A database constraint caught what the code didn't, such as two concurrent creates of one SKU. @ExceptionHandler fun constraintViolation(e: DataIntegrityViolationException): ProblemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The change conflicts with existing data.").apply { title = "Constraint violated" // no SQL in the response }
override fun handleMethodArgumentNotValid( ex: MethodArgumentNotValidException, headers: HttpHeaders, status: HttpStatusCode, request: WebRequest, ): ResponseEntity<Any>? { val body = ex.body.apply { setProperty("errors", ex.bindingResult.fieldErrors.associate { it.field to it.defaultMessage }) } return handleExceptionInternal(ex, body, headers, status, request) }
private fun problem(status: HttpStatus, title: String, e: Exception) = ProblemDetail.forStatusAndDetail(status, e.message).apply { this.title = title }}Errors are RFC 9457 problem details. Extending ResponseEntityExceptionHandler covers Spring MVC’s own failures, such as unreadable JSON or a missing parameter. The catalog’s failures are one sealed class, so mapping them is an exhaustive when with no else: add a fourth CatalogException and this file stops compiling until you decide its status code. An optimistic-lock failure from @Version becomes a 409 (ProductEntityTest provokes the exception with a stale copy); it catches overlapping transactions, and catching lost updates between clients would also need the version in the API, as an ETag checked against If-Match. A violated database constraint, such as two concurrent creates of one SKU slipping past the existsBySku check, becomes a 409 without SQL in the body. The override adds field errors to the 400 response, which Spring leaves out by default.
Jackson 3 is Boot 4’s default JSON library, with new packages (tools.jackson.*; only the annotations stayed in com.fasterxml.jackson.annotation) and tools.jackson.module:jackson-module-kotlin for Kotlin. The module is why description = "" applies when the field is missing, why value classes deserialise through their constructor, and why a request without category fails with a clear missing-parameter error, reported as a 400. If you’d rather use kotlinx.serialization, Boot has spring-boot-starter-kotlinx-serialization-json; with Jackson also present, kotlinx handles only @Serializable classes, so pick one per service.
Value classes at the boundary
Part 4 left a promise: a value class validates in init, but code that builds the boxed form some other way can skip it. Here are both paths:
import org.junit.jupiter.api.Testimport tools.jackson.databind.DatabindExceptionimport tools.jackson.module.kotlin.jacksonObjectMapperimport tools.jackson.module.kotlin.readValueimport kotlin.test.assertEqualsimport kotlin.test.assertFailsWithimport kotlin.test.assertTrue
// Part 4's promise: what happens to a validating value class when a framework builds it.@JvmInlinevalue class Sku(val value: String) { init { require(value.matches(Regex("SHW-\\d{4}"))) { "bad SKU: $value" } }}
data class SkuRequest(val sku: Sku)
class ValueClassBoundaryTest { @Test fun `jackson-module-kotlin runs init`() { val mapper = jacksonObjectMapper()
assertEquals(Sku("SHW-1001"), mapper.readValue<SkuRequest>("""{"sku":"SHW-1001"}""").sku) val failure = assertFailsWith<DatabindException> { mapper.readValue<SkuRequest>("""{"sku":"not a sku"}""") } assertTrue(failure.message!!.startsWith("bad SKU: not a sku")) }
@Test fun `plain reflection through box-impl does not`() { // box-impl wraps an already-validated value; frameworks that find it by reflection skip init. val boxImpl = Sku::class.java.getDeclaredMethod("box-impl", String::class.java)
val sku = boxImpl.invoke(null, "not a sku") as Sku
assertEquals("not a sku", sku.value) }
@Test fun `neither does the private JVM constructor`() { // init lives only in the static constructor-impl; the JVM constructor just stores the value. val constructor = Sku::class.java.getDeclaredConstructor(String::class.java).apply { isAccessible = true }
assertEquals("nope", constructor.newInstance("nope").value) }}jackson-module-kotlin knows about value classes and goes through the constructor, so an invalid SKU in JSON fails with the init message. The other two tests show where the check actually lives. init compiles into a static constructor-impl method that Kotlin calls before boxing; the JVM constructor, which is private, and the static box-impl both just store the value. A framework that sees a value class as a plain Java class, finds a constructor or factory by reflection and calls it, gets an Sku holding "nope". So the catalog keeps sku a String at its boundaries, checked by @Pattern on the DTO and by a check constraint in the database, and value classes belong in domain code behind them.
Step-by-Step Hands-On: Build, Run, Test
Code: kotlin-for-java-survivors/services/product-catalog-service, tag kotlin-for-java-survivors/part-13.
Step 1 — The build. start.spring.io, asked for a Kotlin project on Boot 4.1.1 with Web, JPA, PostgreSQL, Validation, Flyway, Actuator and Testcontainers, generates a working build.gradle.kts. In October 2026 it is still written for Kotlin 2.3.21, and four of its lines need a second look:
| Initializr line | On Kotlin 2.4.20 |
|---|---|
kotlin("jvm") version "2.3.21" (and the same for plugin.spring, plugin.jpa) | Change all three to 2.4.20 (the companion repo sets it once, in build-logic’s dependencies, and applies plugin.jpa through a two-line kfs.spring-jpa convention). That’s the whole “override” in Gradle: the Spring Boot plugin sets Boot’s kotlin.version to match the Kotlin plugin you apply, so kotlin-stdlib and kotlin-reflect follow. (In Maven, set the kotlin.version property.) |
-Xannotation-default-target=param-property in freeCompilerArgs | It opted in early to the annotation targets that are the default since 2.4.0; delete it. (-Xjsr305=strict next to it is harmless, Part 12.) |
allOpen { annotation("jakarta.persistence.Entity") … } | Delete it. Since Kotlin 2.3.20, plugin.jpa applies all-open with a JPA preset itself. |
implementation("org.jetbrains.kotlin:kotlin-reflect") | Keep it: Spring and Jackson read Kotlin’s metadata through it. |
The Boot plugin doesn’t align kotlinx.coroutines, which Boot 4.1.1’s BOM pins at 1.10.2; the convention moves it to 1.11.0 for Part 14, where the catalog starts using coroutines. The companion repo keeps these settings in a convention plugin shared by both services (which also passes Mockito as a -javaagent, for the reason in Part 12):
// Fragment of build-logic/src/main/kotlin/kfs.spring-service.gradle.kts// Convention: a Spring Boot 4.1 service in Kotlin. It is what start.spring.io generates for Kotlin,// minus what Kotlin 2.4.20 made redundant. Plugin versions come from build-logic's dependencies.import org.gradle.api.artifacts.VersionCatalogsExtension
plugins { id("org.jetbrains.kotlin.jvm") id("org.jetbrains.kotlin.plugin.spring") id("org.springframework.boot") id("io.spring.dependency-management")}
val libs = the<VersionCatalogsExtension>().named("libs")
// Boot 4.1.1's BOM manages Kotlin 2.3.21, but the Boot plugin aligns its kotlin.version with the Kotlin// plugin applied (2.4.20) and adds -java-parameters. kotlinx.coroutines gets no such help: the BOM pins// 1.10.2, so the property moves it to the version the series uses.extra["kotlin-coroutines.version"] = libs.findVersion("kotlinx-coroutines").get().requiredVersion
kotlin { jvmToolchain(libs.findVersion("jdk").get().requiredVersion.toInt())}The service’s own build file is just its starters. Boot 4 split its auto-configuration into modules, so the web starter is now spring-boot-starter-webmvc, and each feature has a matching test starter, such as spring-boot-starter-data-jpa-test.
Step 2 — Run it locally. infra/compose.yaml starts PostgreSQL 18; Flyway creates the schema and seeds 16 products on startup.
docker compose -f infra/compose.yaml up -d postgres./gradlew :services:product-catalog-service:bootRun
curl -s localhost:8080/api/products/SHW-1003# {"sku":"SHW-1003","name":"Gluten-Free Millet Pasta 250 g","description":"Made from millet flour and lentil flour; certified gluten-free and naturally vegan.","category":"PASTA_AND_GRAINS","pricePaise":19500,"price":"₹195.00","supplier":"Valley Fresh Producers","dietaryTags":["gluten-free","vegan"]}
curl -s localhost:8080/api/products/SHW-9999# {"detail":"No product with SKU SHW-9999","instance":"/api/products/SHW-9999","status":404,"title":"Product not found","sku":"SHW-9999"}
curl -s -X POST localhost:8080/api/products -H 'Content-Type: application/json' \ -d '{"sku":"bad","name":" ","category":"SNACKS","pricePaise":-5,"supplierCode":"SUP-001"}'# {"detail":"Invalid request content.","instance":"/api/products","status":400,"title":"Bad Request","errors":{"pricePaise":"must be greater than 0","sku":"must look like SHW-1234","name":"must not be blank"}}Step 3 — A real database in tests.
import org.springframework.boot.test.context.TestConfigurationimport org.springframework.boot.testcontainers.service.connection.ServiceConnectionimport org.springframework.context.annotation.Beanimport org.testcontainers.postgresql.PostgreSQLContainer
// One PostgreSQL container for the test context. @ServiceConnection hands its URL and credentials// to Spring Boot, so there are no datasource properties to keep in sync.@TestConfiguration(proxyBeanMethods = false)class TestcontainersConfiguration { @Bean @ServiceConnection fun postgres() = PostgreSQLContainer("postgres:18-alpine")}Testcontainers 2 renamed its artifacts (testcontainers-postgresql, testcontainers-junit-jupiter) and moved the PostgreSQL class to org.testcontainers.postgresql; it’s no longer generic, so the Kotlin workaround PostgreSQLContainer<Nothing> is gone too. @ServiceConnection on the bean replaces @DynamicPropertySource: Boot reads the container’s URL and credentials itself. The container is a bean, so there’s one per application context, and Spring caches contexts only when tests configure them identically. This suite builds four (a mock and a real web environment, and two @DataJpaTest variants), so it starts four PostgreSQL containers; a container in a companion object field shared by every configuration would cut that to one.
Step 4 — Test the API like a client. RestTestClient is new in Spring Framework 7: WebTestClient-style assertions for servlet applications, here against a real server on a random port, with Kotlin’s reified expectBody<ProductResponse>() instead of a ParameterizedTypeReference. Each boundary behaviour is one request; the validation case is the curl from Step 2, and this one is the missing category:
// Fragment of ProductApiTest.kt @Test fun `a missing non-null property is a 400, not a null in the object`() { client.post().uri("/api/products").contentType(MediaType.APPLICATION_JSON) .body("""{"sku":"SHW-1102","name":"Masala Oats","pricePaise":9000,"supplierCode":"SUP-002"}""") .exchange() .expectStatus().isBadRequest .expectBody() .jsonPath("$.detail").isEqualTo("Failed to read request") }Step 5 — Virtual threads. spring.threads.virtual.enabled: true in application.yaml runs Tomcat’s request handling, @Async methods and schedulers on virtual threads. For a JPA service that blocks on JDBC, that’s Part 11’s recommendation: the same code, with waiting that costs almost nothing, while the connection pool still caps how many requests use the database at once. A test-only endpoint proves the switch is on:
// Fragment of ProductApiTest.kt @Test fun `requests run on virtual threads`() { client.get().uri("/test/thread").exchange() .expectStatus().isOk .expectBody<String>().isEqualTo("virtual=true") }
@RestController class ThreadProbe { @GetMapping("/test/thread") fun thread() = "virtual=${Thread.currentThread().isVirtual}" }Step 6 — The opening, as a regression test. The data-class entities live in test sources, in their own package with their own @SpringBootApplication, so they never reach the real service’s schema validation. Their test pins down the three failures from the opening (and, in a fourth test, the initialiser trap from the Gotchas):
// Fragment of story/DataClassEntityTest.kt @Test fun `toString walks the bidirectional association until the stack runs out`() { val product = em.find(NaiveProduct::class.java, saveOne())
// product -> supplier (initialised by the proxy) -> products -> product -> ... val failure = assertFailsWith<StackOverflowError> { product.toString() } assertTrue(failure.stackTrace.size > 1_000) // thousands of toString frames }
@Test @Transactional(propagation = Propagation.NOT_SUPPORTED) fun `outside the transaction, toString touches a proxy with no session`() { val tx = TransactionTemplate(transactions) val id = tx.execute { saveOne() } // Spring 7 (JSpecify): a non-null T for a non-null lambda, no !! val product = tx.execute { em.find(NaiveProduct::class.java, id) }!! // Jakarta API: a platform type, so !! here
val failure = assertFailsWith<LazyInitializationException> { product.toString() } assertTrue(failure.message!!.startsWith("Could not initialize proxy [com.shelfwise.story.NaiveSupplier#")) assertTrue(failure.message!!.endsWith("] - no session")) }
@Test fun `hashCode changes when the id is assigned`() { val supplier = NaiveSupplier(code = "SUP-002") em.persist(supplier) val product = NaiveProduct(sku = "SHW-2002", pricePaise = 9_000, supplier = supplier) val inCart = hashSetOf(product)
em.persist(product) // assigns id, which is part of equals and hashCode
assertFalse(product in inCart) }ProductEntityTest runs the same checks against the real Product: it stays in the HashSet after saving, and its toString works outside the transaction while its supplier is still an unloaded proxy. The full suite, 20 tests against PostgreSQL 18 in Docker, runs in well under a minute.
Tips, Tricks & Gotchas
Gotcha — an
equalsthat compares associations loads them. Of the data-class failures, this one never throws: comparing two entities walks into their lazy associations and can load an object graph to answer==, or fill aHashSet. Hand-writtenequalsshould read only the business key.
Gotcha — Hibernate’s entities skip your initialisers. Hibernate creates entities through the no-arg constructor
plugin.jpagenerates, and that constructor doesn’t run property initialisers. Columns are filled in from the row afterwards; anything else isn’t. A non-column property isnullafter loading, although its type says it can’t be (DataClassEntityTest’s initialiser test loads one and findsnull). Keep derived values as computed properties (val label get() = …), or setnoArg { invokeInitializers = true }in Gradle.
// Fragment of story/DataClassEntities.kt// Not a data class, and still a trap: Hibernate creates entities through the no-arg constructor,// which doesn't run property initialisers unless noArg { invokeInitializers = true }.@Entity@Table(name = "naive_labels")class ShelfLabel( @Id val sku: String, var text: String,) { @Transient val printedBy: String = "label-service" // not a column, so nothing fills it in after loading}Gotcha — imports from Jackson 2 compile against nothing. Boot 4 uses Jackson 3, whose packages are
tools.jackson.*. Code and tests copied from Boot 3 projects withcom.fasterxml.jackson.module.kotlin.readValueorcom.fasterxml.jackson.databind.ObjectMapperfail with unresolved references. Only the annotations (@JsonProperty,@JsonIgnore) kept theircom.fasterxml.jackson.annotationpackage.
Gotcha — nullability decides “required”, in both directions.
@RequestParam maxPricePaise: Long?is optional;@RequestParam category: Categoryis required, and missing it is a 400. The same goes for JSON bodies: a non-null DTO property without a default must be present. Java developers who declareLongnon-null out of habit get 400s for optional parameters.
Tip — let the database enforce what Kotlin can’t see. Each rule is checked twice: the SKU by
@Patternon the DTO and byuniquepluscheck (sku ~ '^SHW-[0-9]{4}$')in the migration; the price by@Positiveandcheck (price_paise > 0). The DTO checks give good error messages; the database checks hold for migration scripts, batch jobs and the next service too.
Tip —
TransactionTemplategot Kotlin-friendlier. In Spring Framework 7,executeis declared with JSpecify generics, sotx.execute { saveOne() }returns a non-nullLongwhen the lambda returns one. The!!that Spring 6 code needed after everyexecuteis now a compiler warning.
Debugging and Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
StackOverflowError in toString/hashCode of an entity | data class entity with a bidirectional association | Plain class; toString without associations |
LazyInitializationException: Could not initialize proxy … - no session | Lazy association touched after the transaction | Fetch it in the query (@EntityGraph, join fetch) or map inside the transaction |
Entity disappears from a HashSet after save | hashCode includes the generated id | Key equals/hashCode on a business key |
A non-null property of a loaded entity is null | It isn’t a column, and the no-arg constructor skipped its initialiser | Computed property, or invokeInitializers = true |
| Validation annotations ignored on a Kotlin DTO | Kotlin before 2.4, without the opt-in flag, put them on the constructor parameter only | Kotlin 2.4, or @field:NotBlank |
| 400 “Failed to read request” for a body that looks fine | A non-null property without a default is missing from the JSON, or an enum value is misspelt | Make it nullable or give it a default; check the enum |
| Transaction committed although the method threw | A checked exception (IOException) and the default rollback rules | @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS) |
| 409 “Concurrent update” | @Version caught a write based on stale data | Retry with fresh data, or surface the conflict to the user |
Schema-validation: missing table at startup | ddl-auto: validate ran against a schema Flyway didn’t create (wrong database, or Flyway disabled) | Check the datasource and that spring-boot-starter-flyway is on the classpath |
Could not find a valid Docker environment | Testcontainers can’t reach Docker | Start Docker (or OrbStack, Colima); check DOCKER_HOST |
Key Takeaways
| Concept | Remember |
|---|---|
| Entities | Plain classes; equals/hashCode on a business key; toString without associations; no initialisers that Hibernate must run |
| Repositories | Product? returns and findByIdOrNull instead of Optional; @EntityGraph with open-in-view off |
| Transactions | rollbackOn = ALL_EXCEPTIONS, because Kotlin won’t remind you which exceptions are checked; @Version conflicts as 409 |
| DTOs | Data classes; constraints need no @field: on Kotlin 2.4; mapping with extension functions |
| JSON | Jackson 3 (tools.jackson) + jackson-module-kotlin: defaults apply, missing non-null fields are 400s |
| Errors | ProblemDetail from a ResponseEntityExceptionHandler; sealed exceptions make the mapping exhaustive |
| Value classes | Jackson’s Kotlin module runs init; box-impl doesn’t; keep them behind the boundary |
| Build | Apply the Kotlin plugins at 2.4.20 and the Boot plugin aligns the BOM; drop Initializr’s early-opt-in flag and allOpen block |
| Tests | Testcontainers 2 + @ServiceConnection; RestTestClient with expectBody<T>() |
| Virtual threads | spring.threads.virtual.enabled=true for a blocking JPA service; the pool still limits the database |
Story Closing
The catalog went to staging on Thursday: sixteen products, five suppliers, and a test suite that started its own PostgreSQL. Kabir kept DataClassEntityTest in the repository, now asserting the failures it had once produced, as a warning for whoever came next.
The planning meeting on Monday was about the assistant. The AI team needed to know about every product change as it happened: new products, price changes, products withdrawn, so the search index never answered from stale data. Someone suggested the AI service poll the catalog’s REST API every minute. Someone else suggested a shared database view.
Lena waited for both suggestions to finish. “Events, on Kafka. Then the only question is the one nobody’s asked yet: who owns the schema of the event?”
In Part 14, Kabir adds the parts of the catalog that virtual threads can’t help with: a sealed product-event hierarchy published to Kafka, a Flow endpoint that streams changes as they happen, suspend controllers that fan out to suppliers through a suspending HTTP client, and context that survives the hop between coroutines.
This is Part 13 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”