Kotlin suspend functions are invisible to Java. Calling them from Java requires hand-written adapters that return
CompletableFuture, manage CoroutineScope, and implement AutoCloseable correctly — the same boilerplate every time.
The same problem exists for Kotlin Flow<T>: Java has no native way to consume a cold flow without coroutine plumbing.
javable eliminates that boilerplate. Annotate your Kotlin class once and KSP generates a ready-to-use Java wrapper (
and optionally a Kotlin one) with the right signatures, scope lifecycle, and resource cleanup. Async functions become
CompletableFuture; flows become java.util.stream.Stream or reactive Publisher; blocking wrappers are plain method
calls.
Add the KSP plugin and the javable dependencies to your build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "2.3.6"
}
dependencies {
implementation("me.kpavlov.javable:javable-annotations:0.1.0-SNAPSHOT")
ksp("me.kpavlov.javable:javable-ksp:0.1.0-SNAPSHOT")
// coroutines runtime — required in the consuming module
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.10.2")
// For @AsyncJavaApi(wrapperType = PUBLISHER) — reactive Publisher support
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.10.2")
}The library is not yet published to Maven Central. Build and publish it locally first:
./gradlew publishToMavenLocalThen add
mavenLocal()to your repository list.
This is the most common case: a Kotlin service with suspend functions that a Java caller needs to consume
asynchronously.
@JavaApi(javaWrapper = true, autoCloseable = true)
class Calculator {
@AsyncJavaApi
suspend fun add(a: Int, b: Int): Int {
delay(10L)
return a + b
}
}javable generates CalculatorJava.java with two overloads for each @AsyncJavaApi method — one using the wrapper's
built-in scope, one accepting a caller-supplied Executor:
// Default scope — runs on Dispatchers.Default
CompletableFuture<Integer> add(int a, int b);
// Custom executor — useful with virtual threads (Java 21+)
CompletableFuture<Integer> add(int a, int b, Executor executor);Use it from Java:
void main() {
try (var calc = new CalculatorJava(new Calculator())) {
int result = calc.add(1, 2).get();
}
// With virtual threads (Java 21+):
try (var exec = Executors.newVirtualThreadPerTaskExecutor();
var calc = new CalculatorJava(new Calculator(), exec)) {
calc.add(1, 2).thenAccept(System.out::println).get();
}
}The autoCloseable = true flag makes the Java wrapper implement AutoCloseable. Its close() cancels the coroutine
scope and waits up to 5 seconds for in-flight work to finish, so try-with-resources is safe.
When Java callers are on a thread that is safe to block (a dedicated worker or a virtual thread on Java 21+), use
@BlockingJavaApi instead:
@JavaApi(javaWrapper = true)
class Calculator {
@BlockingJavaApi
suspend fun multiply(a: Int, b: Int): Int {
delay(10L)
return a * b
}
}The generated method is a plain synchronous call — no Future, no executor overload:
// Generated signature
int multiply(int a, int b) throws InterruptedException;// Usage
int product = calc.multiply(3, 4); // blocks until completeBoth annotations can coexist on the same class. The generated wrapper handles each function independently:
@JavaApi(javaWrapper = true, autoCloseable = true)
class Calculator {
@AsyncJavaApi
suspend fun add(a: Int, b: Int): Int {
delay(10); return a + b
} // → CompletableFuture<Integer>
@BlockingJavaApi
suspend fun multiply(a: Int, b: Int): Int {
delay(10); return a * b
} // → int (blocking)
}Suspend functions without either annotation are not exposed. Non-suspend public functions are always forwarded unchanged.
If your API contract should depend on the CompletionStage interface rather than the concrete CompletableFuture
class, set wrapperType:
@JavaApi(javaWrapper = true)
class EventService {
@AsyncJavaApi(wrapperType = JavaWrapperType.COMPLETION_STAGE)
suspend fun publish(event: String) {
...
}
}Generated signatures:
CompletionStage<Void> publish(String event);
CompletionStage<Void> publish(String event, Executor executor);The underlying implementation is still a CompletableFuture — no runtime overhead.
When a Kotlin function returns Flow<T>, Java callers have no clean way to consume it without coroutine knowledge.
Annotate the function with @AsyncJavaApi(wrapperType = JavaWrapperType.STREAM) — it works on both suspend and non-
suspend functions:
@JavaApi(javaWrapper = true, kotlinWrapper = true)
class StreamSubject {
@AsyncJavaApi(wrapperType = JavaWrapperType.STREAM)
fun words(): Flow<String> = flow {
delay(500)
emit("alpha")
delay(100)
emit("beta")
delay(200)
emit("gamma")
}
@AsyncJavaApi(wrapperType = JavaWrapperType.STREAM)
fun numbers(count: Int): Flow<Int> = flow {
for (i in 1..count) emit(i)
}
}javable generates a blocking Stream<T> method that collects the flow internally using runBlocking:
// Generated signatures
Stream<String> words() throws InterruptedException;
Stream<Integer> numbers(int count) throws InterruptedException;Use it from Java like any other Stream:
var subject = new StreamSubjectJava(new StreamSubject());
List<String> words = subject.words().collect(Collectors.toList());
// ["alpha", "beta", "gamma"]
List<Integer> first4 = subject.numbers(4).collect(Collectors.toList());
// [1, 2, 3, 4]Because no coroutine scope is needed — the flow is collected synchronously — the generated wrapper does not
implement AutoCloseable and has no scope field.
Note:
STREAMcollects all flow elements into memory before returning theStream. For large or infinite flows, usePUBLISHERinstead — it preserves lazy, back-pressured streaming.
STREAM materializes the entire flow before returning. When you need lazy, back-pressured delivery — streaming results
to Java as they arrive — use PUBLISHER instead. The generated wrapper returns org.reactivestreams.Publisher<T>,
which Project Reactor, RxJava, and Spring WebFlux all consume natively.
@JavaApi(javaWrapper = true, kotlinWrapper = true)
class ChatService {
@AsyncJavaApi(wrapperType = JavaWrapperType.PUBLISHER)
fun streamTokens(prompt: String): Flow<String> = flow {
emit("Hello")
delay(100)
emit(" world")
}
@AsyncJavaApi(wrapperType = JavaWrapperType.PUBLISHER)
suspend fun singleAnswer(prompt: String): String {
delay(100)
return "42"
}
}Generated Java signatures:
Publisher<String> streamTokens(String prompt); // Flow → Publisher via asPublisher()
Publisher<String> singleAnswer(String prompt); // suspend → Publisher via mono {}Use it from Java:
var chat = new ChatServiceJava(new ChatService());
// Reactive subscription — elements arrive lazily
chat.streamTokens("Hi").subscribe(new Subscriber<>() {
public void onSubscribe(Subscription s) { s.request(Long.MAX_VALUE); }
public void onNext(String token) { System.out.print(token); }
public void onError(Throwable t) { t.printStackTrace(); }
public void onComplete() { System.out.println(" [done]"); }
});
// Single-value suspend → Publisher with one element
chat.singleAnswer("What is the meaning of life?")
.subscribe(/* ... */);How it works:
- Flow-returning functions use
Flow.asPublisher()fromkotlinx-coroutines-reactive. ThePublisheris cold — elements are emitted on subscription with back-pressure support. - Single-value suspend functions use
mono {}fromkotlinx-coroutines-reactor.Mono<T>implementsPublisher<T>, so it satisfies the return type while providing a single-element reactive stream. - No
CoroutineScopeis created. NoAutoCloseable. The wrapper class has a simple delegate-only constructor.
Dependency:
PUBLISHERrequiresorg.jetbrains.kotlinx:kotlinx-coroutines-reactoron the runtime classpath.
If a class has only @BlockingJavaApi methods and no @AsyncJavaApi methods, javable generates a wrapper with no
CoroutineScope and no AutoCloseable:
@JavaApi(javaWrapper = true, kotlinWrapper = true)
class BlockingOnlySubject {
@BlockingJavaApi
suspend fun doubled(value: Int): Int {
delay(5L)
return value * 2
}
}// No scope, no close() — just a plain wrapper
var result = new BlockingOnlySubjectJava(delegate).doubled(21); // 42By default, @JavaApi generates a Kotlin wrapper (*Kotlin.kt). This is useful when you want a clean, scope-managed
Kotlin API alongside the original coroutine implementation:
@JavaApi // kotlinWrapper = true by default
class UserRepository(val generator: (Int) -> User) {
@AsyncJavaApi
suspend fun fetchAll(): List<User> {
delay(100)
return (1..100).map { generator(it) }
}
}The generated UserRepositoryKotlin class is fully usable from Java as well:
void main() {
try (var repo = new UserRepositoryKotlin(new UserRepository(i -> new User("User" + i)))) {
List<User> users = repo.fetchAll().get();
}
}@JavaApi(
kotlinWrapper = true, // generate *Kotlin.kt
javaWrapper = false, // generate *Java.java
autoCloseable = false, // Java wrapper implements AutoCloseable
)
class FooWraps a suspend function as an async call, or a non-suspend function returning Flow<T> as a blocking stream call.
The generated output depends on wrapperType:
@AsyncJavaApi(wrapperType = JavaWrapperType.COMPLETABLE_FUTURE)
suspend fun search(query: String): String
@AsyncJavaApi(wrapperType = JavaWrapperType.STREAM)
fun askAgent(prompt: String): Flow<String>
@AsyncJavaApi(wrapperType = JavaWrapperType.PUBLISHER)
fun streamResults(query: String): Flow<String>wrapperType |
Applies to | Generated return type |
|---|---|---|
COMPLETABLE_FUTURE (default) |
suspend functions |
CompletableFuture<T> |
COMPLETION_STAGE |
suspend functions |
CompletionStage<T> |
STREAM |
fun or suspend fun returning Flow<T> |
Stream<T> (blocking collect) |
PUBLISHER |
fun/suspend fun returning Flow<T>, or suspend fun returning T |
Publisher<T> (reactive) |
For COMPLETABLE_FUTURE and COMPLETION_STAGE, two overloads are always generated: one using the wrapper's built-in
scope, one accepting a caller-supplied Executor. For STREAM, a single blocking method is generated — no scope, no
executor overload. For PUBLISHER, Flow-returning functions use asPublisher() (fully reactive, no scope);
single-value suspend functions use mono {} from kotlinx-coroutines-reactor (Mono<T> implements Publisher<T>).
Wraps a suspend function as a plain synchronous call via runBlocking. No executor overload — always runs on the
calling thread. The generated method declares throws InterruptedException.
If both
@AsyncJavaApiand@BlockingJavaApiare present on the same function,@AsyncJavaApitakes precedence.
The generated wrappers manage a CoroutineScope backed by a SupervisorJob. Here's when a scope is created and when
AutoCloseable is implemented:
| Wrapper type | Condition | Scope? | AutoCloseable? |
|---|---|---|---|
| Java | autoCloseable = true |
yes | yes |
| Java | autoCloseable = false + has @AsyncJavaApi (Future/Stage) methods |
yes | no |
| Java | autoCloseable = false + only @AsyncJavaApi(STREAM), @AsyncJavaApi(PUBLISHER), or @BlockingJavaApi methods |
no | no |
| Kotlin | has @AsyncJavaApi (Future/Stage) methods |
yes | yes |
| Kotlin | only @AsyncJavaApi(STREAM), @AsyncJavaApi(PUBLISHER), or @BlockingJavaApi methods |
no | no |
The Kotlin wrapper's close() cancels the scope and waits for all child coroutines to finish:
override fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}The Java wrapper's close() does the same with a 5-second timeout, and surfaces failures instead of swallowing
them:
@Override
public void close() {
CompletableFuture<Void> done = new CompletableFuture<>();
this.scopeJob.invokeOnCompletion(cause -> {
done.complete(null);
return Unit.INSTANCE;
});
CoroutineScopeKt.cancel(this.scope, "closed", null);
try {
done.get(5L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Close interrupted", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
throw new RuntimeException(cause);
} catch (TimeoutException e) {
throw new RuntimeException("Scope did not close within 5 seconds", e);
}
}| Module | Description |
|---|---|
javable-annotations |
@JavaApi, @AsyncJavaApi, @BlockingJavaApi, JavaWrapperType |
javable-ksp |
KSP processor + JavaPoet/KotlinPoet code generators |
integration-tests |
End-to-end examples (Calculator, UserRepository, BlockingOnlySubject, StreamSubject, PublisherSubject) |
./gradlew build # compile, run KSP generation, and test
./gradlew check # all checks including lintTech stack: Kotlin 2.3.20 · KSP 2.3.6 · Java 17+ · Palantir JavaPoet · Square KotlinPoet · kotlinx.coroutines 1.10.2