"Bilingual" — Java Interop, Build Plugins, Testing and Tooling
The Java team wraps Kabir's Kotlin library in a 140-line adapter, and he learns what his API looks like from the other side. We cover calling Java from Kotlin and Kotlin from Java (@JvmStatic, @JvmOverloads, @JvmField, @JvmName, @JvmRecord, @JvmExposeBoxed), the compiler plugins Spring and JPA need, compilerOptions, testing with JUnit, MockK, Kotest and power-assert, and code quality with ktlint, detekt and explicit API mode.
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.50Kabir’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 declaration | What Java sees by default | Fix for Java callers |
|---|---|---|
Top-level function in Pricing.kt | PricingKt.shelfLabel(…) | @file:JvmName("Pricing") |
companion object function | PriceList.Companion.forCurrency(…) | @JvmStatic |
object function | Rounding.INSTANCE.toRupee(…) | @JvmStatic |
val in a companion | PriceList.Companion.getSUPPORTED_CURRENCIES() | const val (primitives, String) or @JvmField |
| Default arguments | One constructor/method with every parameter | @JvmOverloads |
| Function taking a value class | shelfLabel-C-dwYyA(String, long), uncallable (the hash depends on the value class’s full name) | @JvmExposeBoxed (Experimental) or @JvmName |
data class | A final class with component1(), copy() | @JvmRecord for a real java.lang.Record |
| Non-null parameter | A check that throws on null at entry | None needed: Java gets a named NPE |
suspend fun | Object bestQuote(String, Continuation) | A CompletableFuture twin via future { } |
Function-type parameter (Long) -> Unit | Function1<Long, Unit>; Java lambdas must return Unit.INSTANCE | fun interface (Part 5) |
A function that throws IOException | javac rejects catch (IOException e) as unreachable | @Throws(IOException::class) (Part 9) |
internal member | A public method with a mangled name | Don’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.
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, trimmedpublic 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 Javaconstructor( 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.@JvmRecorddata 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), trimmedpublic static final List<String> SUPPORTED_CURRENCIES; // @JvmField: public, no getterpublic PriceList(String); // @JvmOverloads on the constructorpublic PriceList();public final long priceOf(String); // @JvmOverloads on the methodpublic 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@JvmOverloadsgenerates one overload per trailing default parameter,priceOf(String)andpriceOf(String, int). Each is a thin bridge:priceOf(String)callspriceOf$defaultwith the bitmask2, “second argument missing”.@JvmStaticon a companion member adds a static method on the outer class that delegates to the companion instance; the companion method remains. On anobjectmember, the method itself becomes static.@JvmFieldexposes a property as a public field with no getter. Useconst valinstead whenever you can: it works only for primitives andStringknown at compile time, and Java callers get a constant inlined into their own bytecode.SUPPORTED_CURRENCIESis aList, so it can’t beconst; and because a public static field is state shared by the whole JVM, it’s wrapped inCollections.unmodifiableList(see the Gotchas).@JvmRecordcompiles a data class to a realjava.lang.Record, so Java getssku()accessors, record patterns and serialisation frameworks that understand records. It requires a JVM target of 16 or later, all properties asvals in the primary constructor, and no superclass.@file:JvmName("Pricing")renames the facade class of top-level functions, so Java writesPricing.formatPaise(paise)instead ofPricingKt.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.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.SupervisorJobimport kotlinx.coroutines.cancelimport kotlinx.coroutines.delayimport kotlinx.coroutines.future.futureimport 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.QuoteServicepublic final Object bestQuote(String, Continuation<? super Long>); // the suspend function: CPS (Part 10)public final CompletableFuture<Long> bestQuoteAsync(String); // the Java-facing twinA 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@JvmExposeBoxedvalue 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).@JvmExposeBoxedfun 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 throughcompilerOptions, and without it the build fails with “this declaration needs opt-in”). On the class, it adds a public constructor that runsinit, plus boxed versions of the class’s own members; a function elsewhere needs its own annotation, which adds an overload taking the boxedSku. Java writesPricing.shelfLabel(new Sku("SHW-1001"), 14_800), and validation runs.@JvmName("shelfLabelUnchecked")gives the mangled function a legal name. Java then passes a rawString, because that’s what the parameter is in bytecode, soSku’sinitnever runs (see the Gotchas).
// javap -p com.shelfwise.part12.api.Pricingpublic static final String shelfLabel-44fzG_E(String, long); // the original, for Kotlin callerspublic static final String shelfLabel(Sku, long); // @JvmExposeBoxedpublic static final String shelfLabelUnchecked(String, long); // @JvmNamepublic 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.Serializableimport kotlinx.serialization.json.Jsonimport 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.@OpenForFrameworksclass 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.@NeedsNoArgConstructorclass ProductRow(val sku: String, val pricePaise: Long)
// kotlinx.serialization: the plugin generates a serializer at compile time. No reflection.@Serializabledata 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,@Cacheableand@SpringBootTest, and through meta-annotations@Configuration,@Service,@Repository,@Controllerand@RestController. This module uses its own marker annotation so you can watch the effect without Spring:PricingServiceis not final;PlainServiceis. - 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, andjavacacceptsnew 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,@Embeddableand@MappedSuperclass. Since Kotlin 2.3.20,plugin.jpaalso applies all-open with a JPA preset, so entities are open for lazy proxies without extra configuration. Older guides that addallOpen { annotation("jakarta.persistence.Entity") }by hand predate that change. - kotlinx.serialization generates a serializer for each
@Serializableclass at compile time. No reflection, and default values work: the JSON withoutcurrencydecoded tocurrency=INR, andencodeDefaultsis 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.ktskotlin { // 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.PriceCheckerimport com.shelfwise.part12.testing.SupplierClientimport io.kotest.assertions.throwables.shouldThrowimport io.kotest.matchers.shouldBeimport io.kotest.matchers.string.shouldContainimport io.mockk.coEveryimport io.mockk.coVerifyimport io.mockk.mockkimport kotlinx.coroutines.test.runTestimport 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.coEveryandcoVerifyare the suspend versions ofeveryandverify. - Kotest assertions give infix matchers (
shouldBe,shouldContain,shouldThrow<T>), usable from plain JUnit tests without adopting Kotest’s test framework. - kotlinx-coroutines-test’s
runTestruns suspend code under virtual time (Part 11).
Step 4 — Power-assert: failure messages without writing them.
import org.junit.jupiter.api.Testimport kotlin.test.assertEqualsimport kotlin.test.assertFailsWithimport 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.AfterEachimport org.junit.jupiter.api.Testimport org.mockito.ArgumentMatchersimport org.mockito.Mockitoimport kotlin.test.assertEqualsimport 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_ideadetekt 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 modulesWith 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:
| Area | Choice | Why |
|---|---|---|
| Build | Gradle Kotlin DSL, version catalog, jvmToolchain(25), convention plugins | One place for versions; IDE completion in build files (Part 1) |
| Compiler | javaParameters; opt-ins in compilerOptions; allWarningsAsErrors once clean | Explicit about Experimental APIs |
| Spring and JPA | plugin.spring, plugin.jpa | Proxies need open classes; Hibernate needs no-arg constructors |
| Java callers | @JvmStatic, @JvmOverloads, const/@JvmField, @file:JvmName, @Throws, @JvmExposeBoxed | Review the API with javap -p before publishing |
| Nullability at the boundary | JSpecify @NullMarked on Java packages | No platform types (Part 2) |
| Tests | JUnit Jupiter, MockK, Kotest assertions, runTest, power-assert | Kotlin-aware mocks and readable failures |
| Quality | ktlint (intellij_idea style), detekt 2.x, explicit API for libraries | Formatting and smells caught before review |
Tips, Tricks & Gotchas
Gotcha —
@JvmNameon a value-class function bypasses validation. Java passes a rawString, andSku’sinitnever 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.springopens a class annotated@Serviceor@Transactional. A class registered through a@Beanmethod, with@Transactionalon 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
staticfor@BeforeAll. Put it in acompanion objectwith@JvmStatic, or annotate the class with@TestInstance(TestInstance.Lifecycle.PER_CLASS), which also lets it use the test’s own properties.
Gotcha —
@JvmOverloadsskips nothing in the middle.fun quote(sku: String, store: String = "ALL", discount: Int = 0)getsquote(String),quote(String, String)andquote(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
Listconstant is shared mutable state. A read-onlyListis not immutable (Part 7):listOfwith two or more elements isArrays.asList, which allowsset. Behind a public static field, Java code anywhere in the JVM can change the constant; the companion’sNaiveStoreOpsClientturns[INR, NPR]into[INR, USD]. Wrap such constants inCollections.unmodifiableList(set refused).
Tip — review your API with
javap -pbefore 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
| Symptom | Likely cause | Fix |
|---|---|---|
| ”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 annotations | Apply kotlin("plugin.spring"); check the annotation |
| Hibernate complains about a missing no-arg constructor, or lazy associations load eagerly | plugin.jpa not applied | Apply kotlin("plugin.jpa") (2.3.20+ also opens entities) |
Java can’t find shelfLabel; javap shows shelfLabel-C-dwYyA | Function 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 elsewhere | Put 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 25 | ktlint’s embedded Kotlin compiler uses a deprecated JDK API | Harmless; it doesn’t fail the build |
Key Takeaways
| Concept | Remember |
|---|---|
| Two audiences | Kotlin callers read your source-level API via metadata; Java callers read only the bytecode |
| Facades and statics | @file:JvmName, @JvmStatic (companion and object) |
| Constants | const 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 classes | Mangled names; @JvmExposeBoxed (Experimental) keeps validation, @JvmName doesn’t |
| Null checks | Every non-private function checks non-null parameters on entry and names the culprit |
| Suspend functions | Uncallable from Java; add a future { } (or mono { }) twin backed by a scope you own |
| Compiler plugins | plugin.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.”