Use when you have a written implementation plan to execute in a separate session with review checkpoints
npx skills add iceflower/opencode-agents-and-skills --skill "java-kotlin-interop"
Install specific skill from multi-skill repository
# Description
>-
# SKILL.md
name: java-kotlin-interop
description: >-
Java-Kotlin interoperability guide covering platform types, null safety with JSpecify,
JVM annotations (@JvmStatic, @JvmOverloads, @JvmExposeBoxed, @Throws), collection interop,
coroutine-Java bridging, SAM conversion, and Spring-specific patterns for mixed projects.
Java-Kotlin Interoperability Rules
1. Platform Types and Null Safety
The Core Problem
Java types without nullability annotations are treated as platform types (T!) in Kotlin — neither nullable nor non-nullable. This bypasses Kotlin's null-safety guarantees.
// Java method: String getName() — no annotation
val name = javaObject.name // Type: String! (platform type)
name.length // Compiles, but may throw NPE at runtime
Solution: Nullability Annotations
// Java — annotate all public API boundaries
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked // All types non-null by default in this class
public class UserService {
public User findById(long id) { ... } // Non-null return
public @Nullable User findByEmail(String email) { ... } // Nullable return
}
// Kotlin — now properly typed
val user: User = userService.findById(1L) // Non-null
val maybeUser: User? = userService.findByEmail(email) // Nullable
Annotation Priority (Kotlin Recognition Order)
| Priority | Annotation Source | Package |
|---|---|---|
| 1 | JSpecify | org.jspecify.annotations |
| 2 | JetBrains | org.jetbrains.annotations |
| 3 | Android | androidx.annotation |
| 4 | JSR-305 | javax.annotation |
| 5 | FindBugs | edu.umd.cs.findbugs.annotations |
| 6 | Eclipse | org.eclipse.jdt.annotation |
| 7 | Lombok | lombok |
Spring Framework 7 / Spring Boot 4: JSpecify Integration
Spring Framework 7+ uses JSpecify annotations throughout the entire codebase with @NullMarked on all packages.
// Before (Spring 6 / Boot 3): platform types everywhere
val user = userRepository.findById(id) // User! — platform type
user.name // May NPE
// After (Spring 7 / Boot 4 + Kotlin 2.1+): proper null safety
val user = userRepository.findById(id) // User — non-null
val maybe = userRepository.findByEmail(email) // User? — nullable
maybe?.name // Compiler-enforced safety
- Kotlin 2.2+ automatically translates JSpecify annotations to Kotlin nullability
- Platform types (
T!) are eliminated for Spring APIs - Generic types and arrays also carry nullability:
List<String>notList<String!>!
Rules
- Java side: Always annotate public APIs with
@NullMarked(class/package level) and@Nullable(specific fields/returns) - Kotlin side: Never use
!!on platform types — assign to explicitly typed variable first - Mixed project: Use JSpecify over JSR-305 for new code (better Kotlin integration)
- Spring Boot 4: No extra work needed — Spring APIs are already fully annotated
2. Calling Kotlin from Java
Companion Object Members
class UserService {
companion object {
@JvmStatic
fun defaultInstance(): UserService = UserService()
@JvmField
val MAX_USERS = 1000
const val VERSION = "1.0" // Inlined at compile time
}
}
// Java — clean access with @JvmStatic and @JvmField
UserService service = UserService.defaultInstance(); // Static call
int max = UserService.MAX_USERS; // Direct field
String version = UserService.VERSION; // Constant
// Without annotations: requires .Companion
UserService service = UserService.Companion.defaultInstance();
Default Parameters
class Circle @JvmOverloads constructor(
val centerX: Int,
val centerY: Int,
val radius: Double = 1.0
) {
@JvmOverloads
fun draw(color: String = "black", filled: Boolean = true) { ... }
}
// Java — overloaded constructors and methods generated
new Circle(10, 20); // radius defaults to 1.0
new Circle(10, 20, 5.0); // explicit radius
circle.draw(); // both defaults
circle.draw("red"); // filled defaults to true
circle.draw("red", false); // all explicit
Checked Exceptions
// Kotlin — no checked exceptions by default
@Throws(IOException::class) // Required for Java interop
fun readFile(path: String): String {
return File(path).readText()
}
// Java — can now catch the declared exception
try {
String content = FileUtilKt.readFile("data.txt");
} catch (IOException e) {
// Handle exception
}
Value Classes (Kotlin 2.2+)
@JvmInline
@JvmExposeBoxed // Expose boxed constructors and methods to Java
value class UserId(val value: Long)
@JvmInline
@JvmExposeBoxed
value class Email(val value: String)
// Function using value classes
@JvmExposeBoxed
fun findUser(id: UserId): User? = ...
// Java — boxed variants available
UserId id = new UserId(42L); // Constructor accessible
User user = UserServiceKt.findUser(id); // Boxed parameter accepted
- Without
@JvmExposeBoxed: value classes are unboxed (mangled names, unusable from Java) - Module-wide option: compile with
-Xjvm-expose-boxed
Package-Level Functions
// FileUtils.kt
@file:JvmName("FileUtils") // Custom class name for Java
package com.example.util
fun readContent(path: String): String = ...
// Java
String content = FileUtils.readContent("data.txt");
// Without @JvmName: FileUtilsKt.readContent(...)
Property Access
class User(
val name: String, // Java: getName()
var email: String, // Java: getEmail(), setEmail()
@get:JvmName("isVerified")
val verified: Boolean // Java: isVerified()
)
Annotation Summary
| Annotation | Purpose | When to Use |
|---|---|---|
@JvmStatic |
Generate static method | Companion object functions |
@JvmField |
Expose as public field (no getter/setter) | Companion object properties |
@JvmOverloads |
Generate overloads for default parameters | Functions called from Java |
@JvmName |
Specify JVM method/class name | Name clashes, file-level funcs |
@Throws |
Declare checked exceptions | Functions throwing exceptions |
@JvmExposeBoxed |
Expose boxed value class for Java | Value classes used from Java |
@JvmWildcard |
Force wildcard in generated Java signature | Generic variance control |
@JvmSuppressWildcards |
Suppress wildcard in generated Java signature | Generic variance control |
3. Collection Interop
Read-Only vs Mutable Mapping
| Java Type | Kotlin Read-Only | Kotlin Mutable |
|---|---|---|
java.util.List |
kotlin.List |
kotlin.MutableList |
java.util.Set |
kotlin.Set |
kotlin.MutableSet |
java.util.Map |
kotlin.Map |
kotlin.MutableMap |
java.util.Collection |
kotlin.Collection |
kotlin.MutableCollection |
Common Pitfalls
// Pitfall 1: Java can mutate Kotlin's read-only collection
fun getNames(): List<String> = listOf("Alice", "Bob")
// Java code can cast and mutate:
// List<String> names = getNames();
// ((java.util.ArrayList<String>) names).add("Charlie"); // UnsupportedOperationException
// Pitfall 2: Platform type collections — mutability unknown
fun processJavaList(list: MutableList<String>) {
list.add("item") // OK — explicitly mutable
}
fun processJavaList(list: List<String>) {
// Cannot add — Kotlin treats as read-only
}
Rules
- Return
List(read-only) from Kotlin APIs — Java consumers cannot mutate accidentally - Accept
MutableListin Kotlin parameters when Java caller needs to mutate - Defensive copy when receiving collections from Java:
list.toList()orlist.toMutableList() - Use
List.copyOf()in Java when passing to Kotlin to ensure immutability
4. Generics and Variance
Declaration-Site Variance → Use-Site Wildcards
// Kotlin: declaration-site variance
class Box<out T>(val value: T) // Covariant
class Consumer<in T> { fun consume(item: T) {} } // Contravariant
// Generated Java:
// Box<? extends T> for out
// Consumer<? super T> for in
Controlling Wildcard Generation
// Force wildcard
fun boxDerived(value: Derived): Box<@JvmWildcard Derived> = Box(value)
// Java: Box<? extends Derived>
// Suppress wildcard
fun unboxBase(box: Box<@JvmSuppressWildcards Base>): Base = box.value
// Java: Box<Base> (no wildcard)
Reified Type Parameters
// Kotlin — inline + reified preserves type at runtime
inline fun <reified T> parseJson(json: String): T {
return objectMapper.readValue(json, T::class.java)
}
// Cannot be called from Java — reified is a Kotlin-only feature
// Java alternative: pass Class<T> explicitly
fun <T> parseJson(json: String, type: Class<T>): T {
return objectMapper.readValue(json, type)
}
Rules
- Provide non-reified overload with
Class<T>parameter for Java consumers - Use
@JvmWildcard/@JvmSuppressWildcardsto control Java signature when needed - Java raw types become
Any!in Kotlin — avoid raw types in interop boundaries
5. Coroutines and Java Interop
Exposing Suspend Functions to Java
// Kotlin suspend function
suspend fun fetchUser(id: Long): User { ... }
// Java cannot call suspend functions directly
// Solution 1: Provide a CompletableFuture wrapper
fun fetchUserAsync(id: Long): CompletableFuture<User> =
CoroutineScope(Dispatchers.IO).future { fetchUser(id) }
// Solution 2: Provide a blocking wrapper (for simple cases)
@JvmStatic
fun fetchUserBlocking(id: Long): User = runBlocking { fetchUser(id) }
Flow to Java
// Kotlin Flow
fun userStream(): Flow<User> = ...
// Java-friendly wrapper using Reactor or RxJava
fun userFlux(): Flux<User> = userStream().asFlux()
// Or using Publisher
fun userPublisher(): Publisher<User> = userStream().asPublisher()
Rules
- Never expose raw
suspend funas public API consumed by Java — wrap inCompletableFuture - Use
kotlinx-coroutines-jdk8forfuture {}builder - Use
kotlinx-coroutines-reactorforFlow.asFlux()/Flow.asPublisher()conversion runBlockingwrappers are acceptable for CLI tools but NOT for server request handlers
6. SAM Conversion
Java SAM Interface in Kotlin
// Java interface
// public interface Predicate<T> { boolean test(T t); }
// Kotlin — automatic SAM conversion
val isAdult = Predicate<User> { it.age >= 18 }
users.stream().filter { it.age >= 18 }
Kotlin Fun Interface in Java
// Kotlin — fun interface enables SAM conversion
fun interface Validator<T> {
fun validate(value: T): Boolean
}
// Java — lambda works with fun interface
Validator<String> notEmpty = s -> !s.isEmpty();
Rules
- Use
fun interfacein Kotlin when Java consumers should use lambdas - Regular Kotlin interfaces do NOT support SAM conversion from Java — must use
fun interface - If a Kotlin interface has multiple abstract methods, Java must use anonymous class
7. Keyword and Name Conflicts
Kotlin Keywords as Java Identifiers
// Java method named with Kotlin keyword — use backticks
javaObject.`is`(value)
javaObject.`when`
javaObject.`object`
javaObject.`in`(collection)
internal Visibility from Java
internal fun processInternal() { ... }
// Java can access (public in bytecode) but name is mangled:
// processInternal$module_name()
// This is intentional — discourages accidental use from Java
Rules
- Avoid using Kotlin keywords (
is,when,object,in,fun,val,var) as Java identifiers in interop boundaries internalKotlin members are accessible from Java but name-mangled — do not depend on them from Java- Use
@JvmNameto provide clean Java-friendly names when needed
8. Spring-Specific Interop Patterns
Kotlin Extensions Used from Java
// Kotlin extension function
fun User.toResponse(): UserResponse = UserResponse(id, name, email)
// Java — called as static method on the generated Kt class
UserResponse response = UserMappingsKt.toResponse(user);
- Extension functions compile to static methods with receiver as first parameter
- Use
@file:JvmName("UserMappings")for cleaner Java access
Spring Configuration in Mixed Projects
// Kotlin @Configuration is open by default (allopen plugin)
@Configuration
class AppConfig {
@Bean
fun userService(repo: UserRepository): UserService = UserService(repo)
}
// Java @Configuration must use CGLIB proxying
@Configuration
public class JavaConfig {
@Bean
public PaymentService paymentService() { return new PaymentService(); }
}
- Kotlin's
allopenplugin makes@Configuration,@Service, etc. automatically open - Java classes must be non-final for CGLIB proxying (or use
proxyBeanMethods = false)
JPA Entities in Mixed Projects
// Kotlin entity — requires allopen + noarg plugins
@Entity
class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
var name: String,
var email: String
)
// Java entity — works without plugins
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// JPA requires no-arg constructor — Java generates implicitly with default visibility
}
- Kotlin JPA entities need
kotlin-jpa(noarg) plugin for no-arg constructor generation - Kotlin JPA entities need
kotlin-allopenplugin to make classes non-final
9. Build Configuration for Mixed Projects
Gradle (Kotlin DSL)
// build.gradle.kts
plugins {
java
kotlin("jvm") version "2.3.0"
kotlin("plugin.spring") version "2.3.0" // allopen for Spring
kotlin("plugin.jpa") version "2.3.0" // noarg for JPA
}
// Source directories
sourceSets {
main {
java.srcDirs("src/main/java")
kotlin.srcDirs("src/main/kotlin")
}
}
// Ensure Java and Kotlin compile together
tasks.withType<JavaCompile> {
sourceCompatibility = "21"
targetCompatibility = "21"
}
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
Compilation Order
- Kotlin compiler runs first — can see Java sources
- Java compiler runs second — can see Kotlin compiled classes
- Circular dependencies between Java and Kotlin files are supported (both compilers handle cross-references)
10. Migration Strategy
Gradual Java → Kotlin Migration
| Step | Action | Risk Level |
|---|---|---|
| 1 | Add Kotlin plugin to build | Low |
| 2 | Write new test code in Kotlin | Low |
| 3 | Write new utility/extension classes | Low |
| 4 | Convert data classes (DTOs, responses) | Low |
| 5 | Convert service layer classes | Medium |
| 6 | Convert controller layer | Medium |
| 7 | Convert entity classes (requires plugins) | Medium |
Migration Rules
- Convert bottom-up (least-depended-on classes first)
- Add nullability annotations to Java code before converting callers to Kotlin
- Use IntelliJ's "Convert Java File to Kotlin" as starting point, then refine
- Keep test coverage high — run tests after each conversion
- Do not convert and refactor simultaneously — convert first, then refactor
11. Anti-Patterns
- Using
!!on platform types — assign to typed variable or add Java annotation instead - Exposing
suspend fundirectly to Java consumers — wrap inCompletableFuture - Exposing
Flowto Java — wrap inFluxorPublisher - Using regular Kotlin interfaces for Java SAM consumption — use
fun interface - Relying on Kotlin
internalvisibility for encapsulation from Java — it ispublicin bytecode - Mixing
javaxandjakartaannotations in the same project - Not adding
@JvmStatic/@JvmOverloads/@Throwson Kotlin code called from Java - Using
data classfor JPA entities without understandingequals/hashCodeimplications - Not using
@JvmExposeBoxedfor value classes consumed from Java - Ignoring platform types — always determine proper nullability at interop boundaries
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.