Story Opening

The store-operations team didn’t file a bug. Farah Qureshi, their lead, opened a pull request in her own repository and added Kabir as a reviewer: ShelfwisePricing.java, 140 lines, whose only job was to make his library look like Java. It hid PriceList.Companion, passed every default argument by hand, turned Rounding.INSTANCE.toRupee into a static method, and had a comment where the value-class function should have been: // TODO no way to call shelfLabel from Java, asked Kabir.

This is the code her team had been writing before the adapter:

// Fragment of client/NaiveStoreOpsClient.java
PriceList list = PriceList.Companion.forCurrency("INR"); // a method on the companion object
PriceList explicit = new PriceList("INR", 100); // default arguments don't exist in Java
list.set("SHW-1001", 16_550);
System.out.println(list.priceOf("SHW-1001", 0)); // -> 16500
System.out.println(PriceList.Companion.getSUPPORTED_CURRENCIES()); // -> [INR, NPR]
System.out.println(Rounding.INSTANCE.toRupee(14_850)); // -> 14800
System.out.println(PriceListNaiveKt.formatPaise(14_850)); // -> ₹148.50

Kabir’s first instinct was to explain how each line made sense from Kotlin’s side. Lena read the adapter and closed the tab. “Her adapter is your API’s bug report. Every line in it is something your compiler generated that you never looked at.”


Java → Kotlin: The Quick Map

This part maps in both directions: what Java sees of your Kotlin, and what fixes it.

Kotlin declarationWhat Java sees by defaultFix for Java callers
Top-level function in Pricing.ktPricingKt.shelfLabel(…)@file:JvmName("Pricing")
companion object functionPriceList.Companion.forCurrency(…)@JvmStatic
object functionRounding.INSTANCE.toRupee(…)@JvmStatic
val in a companionPriceList.Companion.getSUPPORTED_CURRENCIES()const val (primitives, String) or @JvmField
Default argumentsOne constructor/method with every parameter@JvmOverloads
Function taking a value classshelfLabel-C-dwYyA(String, long), uncallable (the hash depends on the value class’s full name)@JvmExposeBoxed (Experimental) or @JvmName
data classA final class with component1(), copy()@JvmRecord for a real java.lang.Record
Non-null parameterA check that throws on null at entryNone needed: Java gets a named NPE
suspend funObject bestQuote(String, Continuation)A CompletableFuture twin via future { }
Function-type parameter (Long) -> UnitFunction1<Long, Unit>; Java lambdas must return Unit.INSTANCEfun interface (Part 5)
A function that throws IOExceptionjavac rejects catch (IOException e) as unreachable@Throws(IOException::class) (Part 9)
internal memberA public method with a mangled nameDon’t expose it (Part 3)

Conceptual Deep-Dive

Two audiences, one bytecode

A Java library has one interface: the bytecode javac produced, which looks almost exactly like the source. A Kotlin library has two. Kotlin callers see your source-level API: default arguments, companion objects, extension functions, value classes, nullability, read-only collections. They get it through the class files plus a @kotlin.Metadata annotation the compiler writes into every class; the Kotlin compiler and kotlin-reflect read it (and through kotlin-reflect, Spring and jackson-module-kotlin), while javac ignores it. Java callers see only the class files, and the class files are a translation: every Kotlin feature without a JVM equivalent became something else.

graph LR S["PriceList.kt"] --> K["kotlinc"] K --> B["PriceList.class + @kotlin.Metadata"] B -->|"reads the metadata: defaults, companion, nullability"| KC["Kotlin caller"] B -->|"reads only the bytecode"| JC["Java caller"]

The shift: the API a Java developer uses is not the one you wrote; it’s the one the compiler generated. So you review it the way Farah does, from the bytecode. javap -p on the naive PriceList shows exactly what her team had to work with:

// javap -p com.shelfwise.part12.naive.PriceList, trimmed
public final class PriceList {
public static final PriceList$Companion Companion; // the companion is an object in a static field
public static final String DEFAULT_CURRENCY; // const val: a real constant
private static final List<String> SUPPORTED_CURRENCIES; // private: Java must go through the Companion
public PriceList(String, long);
public PriceList(String, long, int, DefaultConstructorMarker); // synthetic, for Kotlin's default arguments
public PriceList(); // all parameters have defaults: a free no-arg constructor
public final long priceOf(String, int);
public static long priceOf$default(PriceList, String, int, int, Object); // Kotlin's default-argument path
}

Two details explain most of Farah’s adapter. Default arguments exist only in Kotlin: a Kotlin call site that omits discountPercent calls priceOf$default with a bitmask of missing arguments (Part 1), and Java has no way to say “missing”. And a companion object is an ordinary object (Part 3), so forCurrency and the getter for SUPPORTED_CURRENCIES are instance methods on PriceList$Companion. There’s one gift in the listing: because every constructor parameter has a default, Kotlin also generates a public no-arg constructor, for frameworks that instantiate classes reflectively. Java gets new PriceList() and new PriceList("INR", 100), and nothing in between.

The reverse direction is the easy one

Calling Java from Kotlin mostly just works, because Kotlin was designed to sit on top of Java libraries. You’ve seen the pieces in earlier parts: getters and setters become properties, Java single-method interfaces accept Kotlin lambdas (Part 5), and Java types without nullability annotations become platform types (Part 2):

// A JavaBean from the old catalog library.
public class LegacyProduct {
private String sku;
private long pricePaise;
private boolean active = true;
private String supplierCode; // may be null; nothing says so
private PriceListener listener = (oldPaise, newPaise) -> { };
public interface PriceListener {
void changed(long oldPaise, long newPaise);
}
public LegacyProduct(String sku, long pricePaise) {
this.sku = sku;
this.pricePaise = pricePaise;
}
public String getSku() { return sku; }
public long getPricePaise() { return pricePaise; }
public void setPricePaise(long pricePaise) {
long old = this.pricePaise;
this.pricePaise = pricePaise;
listener.changed(old, pricePaise);
}
public void onPriceChange(PriceListener listener) { this.listener = listener; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public String getSupplierCode() { return supplierCode; }
public static LegacyProduct sample() { return new LegacyProduct("SHW-1001", 16_500); }
}
import com.shelfwise.part12.legacy.LegacyProduct
fun main() {
val product = LegacyProduct.sample() // static methods are called on the class, as in Java
// A Java single-method interface takes a Kotlin lambda (SAM conversion, Part 5).
product.onPriceChange { old, new -> println("price $old -> $new") }
// Getters and setters become properties: this calls setPricePaise(15_900), which fires the listener.
product.pricePaise = 15_900 // -> price 16500 -> 15900
product.isActive = false // isActive/setActive become one property, 'isActive'.
println("${product.sku} ${product.pricePaise} active=${product.isActive}") // -> SHW-1001 15900 active=false
// getSupplierCode() returns a platform type (String!): Kotlin can't tell if it is nullable (Part 2).
val code: String? = product.supplierCode // declare the Kotlin type you mean
println(code ?: "no supplier") // -> no supplier
}

Assigning product.pricePaise calls the Java setter, which fired the listener the lambda became. A boolean getter named isActive keeps its name as a property (product.isActive), and the setter setActive maps onto it. supplierCode is String! to the compiler: it lets you treat it as either String or String?, and if you choose String and the value is null, the failure arrives at that assignment. Declaring the type you mean, as the example does, turns the guess into a decision. In your own Java code, JSpecify’s @NullMarked removes the guess altogether (Part 2).


Technical Explanation

Calling Kotlin from Java: the annotated library

The fix for each line of the adapter is an annotation that tells the compiler to generate an extra, Java-shaped member. Here is the same PriceList, made bilingual:

import java.util.Collections
// The same class, annotated for Java callers.
class PriceList
@JvmOverloads // generates PriceList(), PriceList(String) and PriceList(String, long) for Java
constructor(
val currency: String = DEFAULT_CURRENCY,
val roundingPaise: Long = 100,
) {
private val prices = mutableMapOf<String, Long>()
operator fun set(sku: String, pricePaise: Long) {
prices[sku] = pricePaise
}
@JvmOverloads // generates priceOf(String) and priceOf(String, int) for Java
fun priceOf(sku: String, discountPercent: Int = 0): Long {
val base = prices.getValue(sku) * (100 - discountPercent) / 100
return base / roundingPaise * roundingPaise
}
companion object {
const val DEFAULT_CURRENCY = "INR" // already a static final field
@JvmField // a static field instead of PriceList.Companion.getSUPPORTED_CURRENCIES()
val SUPPORTED_CURRENCIES: List<String> = Collections.unmodifiableList(listOf("INR", "NPR"))
@JvmStatic // a static method on PriceList, not only on PriceList.Companion
fun forCurrency(currency: String) = PriceList(currency)
}
}
// A real java.lang.Record, so Java can use record patterns on it.
@JvmRecord
data class PriceTag(val sku: String, val pricePaise: Long)
object Rounding {
@JvmStatic // without it, Java writes Rounding.INSTANCE.toRupee(...)
fun toRupee(paise: Long): Long = paise / 100 * 100
}

And what javap shows now, the lines that changed:

// javap -p com.shelfwise.part12.api.PriceList (and Rounding), trimmed
public static final List<String> SUPPORTED_CURRENCIES; // @JvmField: public, no getter
public PriceList(String); // @JvmOverloads on the constructor
public PriceList();
public final long priceOf(String); // @JvmOverloads on the method
public static final PriceList forCurrency(String); // @JvmStatic: a static bridge on PriceList...
// ...and PriceList$Companion still has forCurrency(String), for Kotlin callers.
public static final long toRupee(long); // in Rounding: @JvmStatic on an object member
  • @JvmOverloads generates one overload per trailing default parameter, priceOf(String) and priceOf(String, int). Each is a thin bridge: priceOf(String) calls priceOf$default with the bitmask 2, “second argument missing”.
  • @JvmStatic on a companion member adds a static method on the outer class that delegates to the companion instance; the companion method remains. On an object member, the method itself becomes static.
  • @JvmField exposes a property as a public field with no getter. Use const val instead whenever you can: it works only for primitives and String known at compile time, and Java callers get a constant inlined into their own bytecode. SUPPORTED_CURRENCIES is a List, so it can’t be const; and because a public static field is state shared by the whole JVM, it’s wrapped in Collections.unmodifiableList (see the Gotchas).
  • @JvmRecord compiles a data class to a real java.lang.Record, so Java gets sku() accessors, record patterns and serialisation frameworks that understand records. It requires a JVM target of 16 or later, all properties as vals in the primary constructor, and no superclass.
  • @file:JvmName("Pricing") renames the facade class of top-level functions, so Java writes Pricing.formatPaise(paise) instead of PricingKt.formatPaise(paise). An extension function becomes a static method whose first parameter is the receiver.

The Java client now reads like Java:

import com.shelfwise.part12.api.PriceList;
import com.shelfwise.part12.api.PriceTag;
import com.shelfwise.part12.api.QuoteService;
import com.shelfwise.part12.api.Pricing;
import com.shelfwise.part12.api.Rounding;
import com.shelfwise.part12.api.Sku;
// The store-operations team's Java code, using the annotated library.
public final class StoreOpsClient {
public static void main(String[] args) {
PriceList list = PriceList.forCurrency("INR"); // @JvmStatic
PriceList defaults = new PriceList(); // @JvmOverloads constructor
list.set("SHW-1001", 16_550);
System.out.println(list.priceOf("SHW-1001")); // -> 16500
System.out.println(list.priceOf("SHW-1001", 10)); // -> 14800
// Kotlin checks non-null parameters on entry: a null from Java fails at the boundary, by name.
try {
list.priceOf(null);
} catch (NullPointerException e) {
System.out.println(e.getMessage());
// -> Parameter specified as non-null is null: method com.shelfwise.part12.api.PriceList.priceOf, parameter sku
}
System.out.println(PriceList.DEFAULT_CURRENCY + " " + PriceList.SUPPORTED_CURRENCIES + " " + defaults.getCurrency()); // -> INR [INR, NPR] INR
// @JvmRecord: a real record, so record patterns work.
Object tag = new PriceTag("SHW-1001", 14_800);
if (tag instanceof PriceTag(String sku, long paise)) {
System.out.println(sku + " at " + Pricing.formatPaise(paise)); // -> SHW-1001 at ₹148.00
}
System.out.println(Rounding.toRupee(14_850)); // -> 14800
// Value classes: the boxed API validates; the @JvmName one does not.
System.out.println(Pricing.shelfLabel(new Sku("SHW-1001"), 14_800)); // -> SHW-1001 ₹148
System.out.println(Pricing.shelfLabelUnchecked("not a sku", 14_800)); // -> not a sku ₹148
try {
new Sku("not a sku");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // -> bad SKU: not a sku
}
// The constant is wrapped in Collections.unmodifiableList: set is refused too.
try {
PriceList.SUPPORTED_CURRENCIES.set(1, "USD");
} catch (UnsupportedOperationException e) {
System.out.println("set refused"); // -> set refused
}
// A suspend function is Object bestQuote(String, Continuation) to Java; the future-returning twin isn't.
try (QuoteService quotes = new QuoteService()) {
System.out.println(quotes.bestQuoteAsync("SHW-1001").join()); // -> 16000
}
}
}

The priceOf(null) call fails on entry, with method and parameter named: the Intrinsics.checkNotNullParameter check Part 2 showed, present in every non-private function. The compiler also writes @NotNull and @Nullable (from org.jetbrains.annotations) onto signatures, so IntelliJ warns Java callers before they run anything.

Suspend functions from Java

StoreOpsClient ends with the one Kotlin feature Java can’t use at all without help:

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.future.future
import java.util.concurrent.CompletableFuture
// A suspend API with a Java-friendly twin. The class owns the scope its futures run in,
// and closing it cancels whatever is still running.
class QuoteService : AutoCloseable {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
suspend fun bestQuote(sku: String): Long {
delay(10) // a supplier call
return if (sku == "SHW-1001") 16_000 else 70_000
}
fun bestQuoteAsync(sku: String): CompletableFuture<Long> = scope.future { bestQuote(sku) }
override fun close() = scope.cancel()
}
// javap -p com.shelfwise.part12.api.QuoteService
public final Object bestQuote(String, Continuation<? super Long>); // the suspend function: CPS (Part 10)
public final CompletableFuture<Long> bestQuoteAsync(String); // the Java-facing twin

A Java caller would have to implement Continuation by hand to call bestQuote. future { }, from kotlinx-coroutines-core, runs the suspend call in a coroutine and completes a CompletableFuture with its result; the scope it runs in belongs to the service, so close() cancels anything in flight, and the futures inherit structured cancellation instead of running unowned. For Reactor-based callers, mono { } from kotlinx-coroutines-reactor does the same with a Mono (Part 11).

Value classes: two escape hatches, one trap

// A value class that validates in init (Part 4). @JvmExposeBoxed (Experimental in 2.4.20) on the class
// adds a public constructor Java can call, and boxed versions of Sku's own members only.
@JvmInline
@JvmExposeBoxed
value class Sku(val value: String) {
init {
require(value.matches(Regex("SHW-\\d{4}"))) { "bad SKU: $value" }
}
override fun toString() = value
}
@file:JvmName("Pricing") // the facade class Java sees, instead of PricingKt
package com.shelfwise.part12.api
// Takes a value class: the JVM name is mangled (shelfLabel-<hash>), unusable from Java.
// Functions outside the class need their own @JvmExposeBoxed: this adds shelfLabel(Sku, long).
@JvmExposeBoxed
fun shelfLabel(sku: Sku, pricePaise: Long): String = "$sku ₹${pricePaise / 100}"
// The other fix: an explicit JVM name. Java then passes the raw String, and Sku's init never runs.
@JvmName("shelfLabelUnchecked")
fun shelfLabelRaw(sku: Sku, pricePaise: Long): String = "$sku ₹${pricePaise / 100}"
// An extension function: Pricing.formatPaise(long) from Java.
fun Long.formatPaise(): String = "₹${this / 100}.${(this % 100).toString().padStart(2, '0')}"

Part 4 showed why a function taking Sku gets a mangled JVM name (the suffix is a hash that includes the value class’s fully qualified name, which is why the naive package’s function is shelfLabel-C-dwYyA and this one shelfLabel-44fzG_E): on the JVM, Sku is a String, so shelfLabel(Sku, Long) and a hypothetical shelfLabel(String, Long) would clash, and the hash suffix keeps them apart. A hyphen is legal in a JVM method name and illegal in Java source, so Java can’t call it at all. Two fixes:

  • @JvmExposeBoxed, Experimental in Kotlin 2.4.20 (it needs @OptIn(ExperimentalStdlibApi::class); this module opts in for all files through compilerOptions, and without it the build fails with “this declaration needs opt-in”). On the class, it adds a public constructor that runs init, plus boxed versions of the class’s own members; a function elsewhere needs its own annotation, which adds an overload taking the boxed Sku. Java writes Pricing.shelfLabel(new Sku("SHW-1001"), 14_800), and validation runs.
  • @JvmName("shelfLabelUnchecked") gives the mangled function a legal name. Java then passes a raw String, because that’s what the parameter is in bytecode, so Sku’s init never runs (see the Gotchas).
// javap -p com.shelfwise.part12.api.Pricing
public static final String shelfLabel-44fzG_E(String, long); // the original, for Kotlin callers
public static final String shelfLabel(Sku, long); // @JvmExposeBoxed
public static final String shelfLabelUnchecked(String, long); // @JvmName
public static final String formatPaise(long);

Compiler plugins: Spring, JPA, serialization, power-assert

Kotlin classes and their members are final by default (Part 3). Spring Boot creates AOP proxies by subclassing by default (proxyTargetClass=true), for @Transactional, @Cacheable and @Async beans, and Spring subclasses @Configuration classes regardless, to intercept @Bean methods; Hibernate creates lazy-loading proxies by subclassing entities, and instantiates them through a no-arg constructor. Kotlin’s answer is a set of compiler plugins that change what the compiler generates, configured in Gradle:

// Fragment of build.gradle.kts
// allopen: classes with this annotation (or annotated with an annotation that has it) are compiled open.
allOpen {
annotation("com.shelfwise.part12.plugins.OpenForFrameworks")
}
// noarg: classes with this annotation get a synthetic no-argument constructor for frameworks.
noArg {
annotation("com.shelfwise.part12.plugins.NeedsNoArgConstructor")
}
// Power-assert (Experimental): rewrites calls to these functions so a failure prints a diagram
// of every sub-expression. Tests only: in main, it would append diagrams to production require() messages.
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
powerAssert {
functions = listOf("kotlin.assert", "kotlin.test.assertTrue", "kotlin.test.assertEquals")
compilationFilter = PowerAssertCompilationFilter.TESTS // the default, spelled out
}
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.lang.reflect.Modifier
// Marker annotations wired to the allopen and noarg plugins in build.gradle.kts.
annotation class OpenForFrameworks
annotation class NeedsNoArgConstructor
// What plugin.spring does for @Component, @Service, @Transactional...: the class and its members are open,
// so Spring can subclass it for proxies.
@OpenForFrameworks
class PricingService {
fun price(sku: String): Long = if (sku == "SHW-1001") 16_500 else 0
}
class PlainService
// What plugin.jpa does for @Entity (since Kotlin 2.3.20, it also opens the class): a synthetic
// no-arg constructor that Kotlin code can't call, but Hibernate can, through reflection.
@NeedsNoArgConstructor
class ProductRow(val sku: String, val pricePaise: Long)
// kotlinx.serialization: the plugin generates a serializer at compile time. No reflection.
@Serializable
data class PriceUpdated(val sku: String, val pricePaise: Long, val currency: String = "INR")
fun main() {
println(Modifier.isFinal(PricingService::class.java.modifiers)) // -> false
println(Modifier.isFinal(PlainService::class.java.modifiers)) // -> true
println(ProductRow::class.java.constructors.map { it.parameterCount }.sorted()) // -> [0, 2]
val json = Json.encodeToString(PriceUpdated.serializer(), PriceUpdated("SHW-1001", 15_900))
println(json) // -> {"sku":"SHW-1001","pricePaise":15900}
val decoded = Json.decodeFromString(PriceUpdated.serializer(), """{"sku":"SHW-2040","pricePaise":69000}""")
println(decoded) // -> PriceUpdated(sku=SHW-2040, pricePaise=69000, currency=INR)
}
  • all-open makes classes with the configured annotations, and their members, open. kotlin("plugin.spring") is all-open with Spring’s preset: @Component, @Async, @Transactional, @Cacheable and @SpringBootTest, and through meta-annotations @Configuration, @Service, @Repository, @Controller and @RestController. This module uses its own marker annotation so you can watch the effect without Spring: PricingService is not final; PlainService is.
  • no-arg adds a no-argument constructor for frameworks that instantiate classes reflectively ([0, 2] above). Kotlin code can’t see it: it carries @Deprecated(level = HIDDEN). The plugin’s documentation calls it uncallable from Java too, but with Kotlin 2.4.20 it is a public constructor, and javac accepts new ProductRow() with only a deprecation warning, so don’t rely on it to stop Java callers from creating half-initialised objects. kotlin("plugin.jpa") is no-arg with the JPA preset: @Entity, @Embeddable and @MappedSuperclass. Since Kotlin 2.3.20, plugin.jpa also applies all-open with a JPA preset, so entities are open for lazy proxies without extra configuration. Older guides that add allOpen { annotation("jakarta.persistence.Entity") } by hand predate that change.
  • kotlinx.serialization generates a serializer for each @Serializable class at compile time. No reflection, and default values work: the JSON without currency decoded to currency=INR, and encodeDefaults is off, so encoding left it out. Part 13 decides between it and Jackson for the REST layer.
  • power-assert, Experimental in 2.4.20, rewrites calls to the functions you list so that a failure prints the value of every sub-expression. The Hands-On uses it.

In a single-module project, each is one line in plugins { }, for example kotlin("plugin.spring") version "2.4.20", always at the same version as Kotlin itself. The companion repo applies them through a convention plugin in build-logic, for a reason the Troubleshooting table explains.


Step-by-Step Hands-On: A Bilingual Module, End to End

Code: kotlin-for-java-survivors/language/part12-bilingual (Kotlin in src/main/kotlin, the Java clients in src/main/java, tests in src/test/kotlin).

Kabir turns the pricing library into a module he’d be happy to hand to any team: Java callers compile against it, the tests read well, and the build rejects style drift and risky code before review.

Step 1 — Kotlin and Java in one source set. Nothing to configure: the Kotlin Gradle plugin compiles src/main/kotlin first, with the Java sources visible to the Kotlin compiler, then javac compiles src/main/java against the Kotlin classes. That’s why StoreOpsClient.java can call PriceList, and JavaFromKotlin.kt can call LegacyProduct, in the same module. A Java class that calls Kotlin which calls that same Java class also works. Annotation processors are the exception: javac’s processors never see Kotlin source. kapt runs them over generated Java stubs and is in maintenance mode; KSP is the Kotlin-native replacement, but it needs processors written for KSP.

Step 2 — Compiler options.

// Fragment of build.gradle.kts
kotlin {
// explicitApi() // for published libraries: every public declaration needs an explicit visibility and type
compilerOptions {
// @JvmExposeBoxed (api/Sku.kt, api/Pricing.kt) is Experimental in 2.4.20.
optIn.add("kotlin.ExperimentalStdlibApi")
// Keep parameter names in bytecode for name-based binding. (The Spring Boot plugin sets this for you.)
javaParameters = true
}
}

compilerOptions is the typed home for compiler flags. optIn opts the module into an Experimental API once, instead of annotating every use. javaParameters keeps parameter names in the bytecode for name-based binding; the Spring Boot Gradle plugin sets it for you, so it matters in modules like this one that don’t apply Boot. Two more you’ll meet: allWarningsAsErrors = true, worth turning on once a codebase is clean, and freeCompilerArgs.add("-X…") for flags without a typed option, such as the Experimental features in Parts 4 and 6. start.spring.io still adds -Xjsr305=strict. Spring Framework 7 annotates its APIs with JSpecify, which Kotlin enforces strictly by default (Part 2), so the flag now matters only for libraries still annotated with JSR-305; it’s harmless to keep.

The commented-out explicitApi() is for published libraries. It makes the compiler require an explicit visibility modifier and an explicit return type on every public declaration, so nothing becomes public API by accident. On the naive file, it reports “visibility must be specified in explicit API mode” on every declaration without a modifier, constructor properties included, and “return type must be specified in explicit API mode” on the ones with inferred types, such as forCurrency. For an application module, the default is fine.

Step 3 — Tests that read like Kotlin.

// The code under test in this part's test suite.
interface SupplierClient {
suspend fun quote(sku: String): Long
}
class PriceChecker(private val supplier: SupplierClient, private val tolerancePercent: Int = 5) {
suspend fun isCompetitive(sku: String, ourPaise: Long): Boolean {
val theirs = supplier.quote(sku)
require(ourPaise > 0 && theirs > 0) { "prices must be positive" }
return ourPaise <= theirs * (100 + tolerancePercent) / 100
}
}
import com.shelfwise.part12.testing.PriceChecker
import com.shelfwise.part12.testing.SupplierClient
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PriceCheckerTest {
// MockK: mocks final Kotlin classes and suspend functions; no open-for-testing required.
private val supplier = mockk<SupplierClient>()
private val checker = PriceChecker(supplier)
@Test
fun `a price within 5 percent of the supplier's is competitive`() = runTest {
coEvery { supplier.quote("SHW-1001") } returns 16_000 // coEvery: stub a suspend function
checker.isCompetitive("SHW-1001", ourPaise = 16_800) shouldBe true // Kotest assertion
checker.isCompetitive("SHW-1001", ourPaise = 16_801) shouldBe false
coVerify(exactly = 2) { supplier.quote("SHW-1001") }
}
@Test
fun `non-positive prices are rejected`() = runTest {
coEvery { supplier.quote(any()) } returns 0
val error = shouldThrow<IllegalArgumentException> { checker.isCompetitive("SHW-1001", 100) }
error.message shouldContain "prices must be positive"
}
}

JUnit Jupiter works with Kotlin as it is, and backtick names turn test methods into sentences. Three Kotlin-first libraries do the rest:

  • MockK mocks Kotlin types natively: final classes, objects, top-level and extension functions, and suspend functions. coEvery and coVerify are the suspend versions of every and verify.
  • Kotest assertions give infix matchers (shouldBe, shouldContain, shouldThrow<T>), usable from plain JUnit tests without adopting Kotest’s test framework.
  • kotlinx-coroutines-test’s runTest runs suspend code under virtual time (Part 11).

Step 4 — Power-assert: failure messages without writing them.

import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class PowerAssertTest {
@Test
fun `power-assert shows every value in a failed assertion`() {
val discount = 95
val maxDiscount = 90
val failure = assertFailsWith<AssertionError> {
assertTrue(discount in 0..maxDiscount) // a plain boolean assertion, no message written
}
assertEquals(
"""
|assertTrue(discount in 0..maxDiscount)
| | | | |
| 95 | | 90
| | 0..90
| false
""".trimMargin(),
failure.message?.trim(),
)
}
}

With power-assert configured for assertTrue (the powerAssert { } block in the build fragment above), a plain assertTrue(discount in 0..maxDiscount) fails with a diagram of every value involved, and no message string to maintain. The test asserts the diagram itself. The plugin is configured for tests only.

Step 5 — The Mockito habit. Spring Boot’s test starter brings Mockito, so Java developers reach for it first:

import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers
import org.mockito.Mockito
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
interface PriceLookup {
fun price(sku: String): Long
}
class MockitoTrapTest {
@AfterEach
fun clearMockitoState() {
runCatching { Mockito.validateMockitoUsage() } // the failed stubbing leaves a matcher behind
}
@Test
fun `Mockito's any() returns null, and Kotlin checks it before the call`() {
val lookup = Mockito.mock(PriceLookup::class.java)
val failure = assertFailsWith<NullPointerException> {
Mockito.`when`(lookup.price(ArgumentMatchers.any())).thenReturn(16_500) // `when` is a Kotlin keyword
}
assertEquals("any(...) must not be null", failure.message)
}
}

Mockito’s any() returns null once it has registered the matcher. In Java that’s harmless. In Kotlin, any() is a Java method whose return type is a platform type, passed where a non-null String is expected, so the compiler inserts a null check on the argument and the stubbing line throws NullPointerException: any(...) must not be null before Mockito sees the call. MockK’s any() is Kotlin-aware. If a codebase is committed to Mockito, the mockito-kotlin library wraps the matchers. And when is a Kotlin keyword, hence the backticks.

Mocking libraries also need an agent on modern JDKs. They instrument classes at runtime, and on JDK 21 and later, attaching an agent dynamically prints a warning, which future JDKs will turn into a failure. Mockito’s documentation recommends passing it as a -javaagent; MockK attaches ByteBuddy’s agent itself, so the flag -XX:+EnableDynamicAgentLoading states that intent and silences the warning:

// Fragment of build.gradle.kts
// Mockito's inline mock maker as a Java agent, as Mockito's docs describe: on JDK 21+, self-attaching
// at runtime prints a warning, and future JDKs will refuse it.
val mockitoAgent = configurations.create("mockitoAgent")
dependencies { mockitoAgent(libs.mockito.core) { isTransitive = false } }
tasks.test {
jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-javaagent:${mockitoAgent.asPath}") })
jvmArgs("-XX:+EnableDynamicAgentLoading") // MockK attaches ByteBuddy's agent at runtime: say so explicitly
}

Step 6 — Style and static analysis. Two tools, applied by the companion repo’s kfs.code-quality convention plugin, which also pins the ktlint engine to 1.8.0 (the Gradle plugin’s default lags behind).

ktlint checks formatting and fails the build on violations; ./gradlew ktlintFormat fixes most of them. Its default style, ktlint_official, is stricter than IntelliJ’s formatter about wrapping parameter lists, so code that Reformat Code considers clean fails the build. One .editorconfig line aligns the two:

# ktlint reads its rules from .editorconfig. The default style, ktlint_official, is stricter than
# IntelliJ's formatter; intellij_idea agrees with what Reformat Code already does.
[*.{kt,kts}]
ktlint_code_style = intellij_idea

detekt looks for code smells: complexity, naming, magic numbers, swallowed exceptions, unreachable code. On this module it flagged, among others, a val that should have been const and a file whose name didn’t match its only class, which is why Sku has its own file. Configuration overrides only what differs from the defaults; magic numbers are off here because example code is full of prices:

# Only the differences from detekt's defaults (buildUponDefaultConfig = true in build.gradle.kts).
style:
MagicNumber:
active: false # example code is full of literal prices; keep this rule on in production modules

With Kotlin 2.4, use detekt 2.0 (plugin id dev.detekt), built on K2 and still in alpha (2.0.0-alpha.6 in October 2026); the stable 1.23 line is built on an older Kotlin compiler.

Step 7 — Kabir’s Kotlin toolbelt. What he now sets up in every new module, and the part of this series where each came from:

AreaChoiceWhy
BuildGradle Kotlin DSL, version catalog, jvmToolchain(25), convention pluginsOne place for versions; IDE completion in build files (Part 1)
CompilerjavaParameters; opt-ins in compilerOptions; allWarningsAsErrors once cleanExplicit about Experimental APIs
Spring and JPAplugin.spring, plugin.jpaProxies need open classes; Hibernate needs no-arg constructors
Java callers@JvmStatic, @JvmOverloads, const/@JvmField, @file:JvmName, @Throws, @JvmExposeBoxedReview the API with javap -p before publishing
Nullability at the boundaryJSpecify @NullMarked on Java packagesNo platform types (Part 2)
TestsJUnit Jupiter, MockK, Kotest assertions, runTest, power-assertKotlin-aware mocks and readable failures
Qualityktlint (intellij_idea style), detekt 2.x, explicit API for librariesFormatting and smells caught before review

Tips, Tricks & Gotchas

Gotcha — @JvmName on a value-class function bypasses validation. Java passes a raw String, and Sku’s init never runs (not a sku ₹148). Java developers expect a typed parameter to have been constructed, and so validated. Prefer @JvmExposeBoxed, or validate inside the function.

Gotcha — all-open only looks at class annotations. plugin.spring opens a class annotated @Service or @Transactional. A class registered through a @Bean method, with @Transactional on one of its methods only, stays final, and Spring can’t proxy it. Annotate the class, or give it an interface.

Gotcha — there’s no static for @BeforeAll. Put it in a companion object with @JvmStatic, or annotate the class with @TestInstance(TestInstance.Lifecycle.PER_CLASS), which also lets it use the test’s own properties.

Gotcha — @JvmOverloads skips nothing in the middle. fun quote(sku: String, store: String = "ALL", discount: Int = 0) gets quote(String), quote(String, String) and quote(String, String, int). A Java caller who wants a discount for all stores passes "ALL" explicitly. Order default parameters from most to least likely to be overridden, or take a parameter object.

Gotcha — a public List constant is shared mutable state. A read-only List is not immutable (Part 7): listOf with two or more elements is Arrays.asList, which allows set. Behind a public static field, Java code anywhere in the JVM can change the constant; the companion’s NaiveStoreOpsClient turns [INR, NPR] into [INR, USD]. Wrap such constants in Collections.unmodifiableList (set refused).

Tip — review your API with javap -p before you publish it. It shows exactly what Farah saw: Companion, INSTANCE, $default, mangled names, getters on constants. The companion repo has a Gradle task for it: ./gradlew :language:part12-bilingual:javap -Pclass=com.shelfwise.part12.api.PriceList -PjavapFlags="-p".

Tip — let IntelliJ show you the Java. Tools → Kotlin → Show Kotlin Bytecode, then Decompile, renders any Kotlin file as the Java it compiles to. It’s an approximation, with occasional decompiler artefacts, but it’s the fastest way to answer “what will Java see?” while you’re still editing.


Debugging and Troubleshooting

SymptomLikely causeFix
”Mockito is currently self-attaching to enable the inline-mock-maker”No Mockito agent on JDK 21+The mockitoAgent configuration and -javaagent from Step 5
Spring fails to create a proxy for a Kotlin class (final class, CGLIB)plugin.spring not applied, or the class has none of its annotationsApply kotlin("plugin.spring"); check the annotation
Hibernate complains about a missing no-arg constructor, or lazy associations load eagerlyplugin.jpa not appliedApply kotlin("plugin.jpa") (2.3.20+ also opens entities)
Java can’t find shelfLabel; javap shows shelfLabel-C-dwYyAFunction takes a value class@JvmExposeBoxed or @JvmName
”The Kotlin Gradle plugin was loaded multiple times in different subprojects”A subproject applies a plugin that brings its own KGP (versioned compiler plugins, ktlint-gradle, detekt) while KGP comes from elsewherePut those plugins on one classpath: root plugins { … apply false } or build-logic dependencies, as the companion repo does
sun.misc.Unsafe::objectFieldOffset warnings during ktlint on JDK 25ktlint’s embedded Kotlin compiler uses a deprecated JDK APIHarmless; it doesn’t fail the build

Key Takeaways

ConceptRemember
Two audiencesKotlin callers read your source-level API via metadata; Java callers read only the bytecode
Facades and statics@file:JvmName, @JvmStatic (companion and object)
Constantsconst val first; @JvmField for non-constant values
Default arguments@JvmOverloads: trailing omissions only; all-default constructors get a free no-arg one
Records@JvmRecord for real java.lang.Records
Value classesMangled names; @JvmExposeBoxed (Experimental) keeps validation, @JvmName doesn’t
Null checksEvery non-private function checks non-null parameters on entry and names the culprit
Suspend functionsUncallable from Java; add a future { } (or mono { }) twin backed by a scope you own
Compiler pluginsplugin.spring (all-open), plugin.jpa (no-arg + all-open since 2.3.20), serialization, power-assert (Experimental)

Story Closing

Farah closed her pull request a week later and deleted ShelfwisePricing.java. Her team’s code now called PriceList.forCurrency("INR") and Pricing.shelfLabel(new Sku(…), …), She left one comment on Kabir’s next library PR, under the javap output he’d started pasting into descriptions: “Looks like Java to me.”

The language part of the plan was done. The platform team booked the next sprint for the first real service: product-catalog-service, the source of truth for every product the assistant would ever answer questions about. Spring Boot 4.1, JPA, REST, Testcontainers. Kabir opened start.spring.io, picked Kotlin, and reached for a data class Product with an @Entity on top.

“Before you do that,” Lena said, “write the test that loads a product with its supplier and prints it.”

In Part 13, Kabir builds the catalog service: the Gradle build with Kotlin 2.4.20 on Spring Boot 4.1, why JPA entities shouldn’t be data classes, Spring Data with Kotlin, validation under Kotlin 2.4’s annotation rules, Jackson 3, ProblemDetail errors and a Testcontainers integration test.


This is Part 12 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”