Polyglot processing · JVM track
Kotlin vs Java
StreamsForge's Dapr flavor invites JVM teams to write stream processors against its pub/sub contract — the repo ships a working Java consumer. This page is the case for writing the next one in Kotlin: what the language buys you, shown on this platform's own code, with the honest counter-column included.
Executive summary
| Concern | Java | Kotlin |
|---|---|---|
| Null bugs | NullPointerException at runtime; Optional and @Nullable are conventions, not guarantees | Nullability is in the type system — String vs String?; the compiler rejects unchecked access |
| Data carriers | record helps (finally), but no copy(), no default/named args, no destructuring declarations | data class: equals/hashCode/toString/copy()/destructuring, with defaults and named args |
| Concurrency | Threads, CompletableFuture chains, or virtual threads (21+) — cancellation and scoping are manual | Coroutines: sequential-looking async code, structured concurrency, first-class cancellation, Flow for streams |
| Domain modeling | sealed interface + pattern matching arriving piecewise across versions | sealed class + exhaustive when since 1.0 — the compiler tells you which case you forgot |
| Ceremony | Getters/setters/builders/static utility classes | val/var properties, top-level and extension functions, string templates |
| Risk of adopting | — | Near zero: 100% two-way Java interop, mixed-source builds, migrate one file at a time |
Null safety — the billion-dollar mistake, fixed in the type system
Kotlin types are non-nullable unless you say otherwise. A String? must be checked before use, and the compiler smart-casts it to String inside the check — no casts, no Optional wrapping, no NPE at 3am from a field someone forgot to guard.
Java
// Compiles fine. Explodes at runtime
// when payload has no "user" node.
String tier = event.getPayload()
.get("user")
.get("tier")
.getAsString(); // NPE somewhere here
// The defensive version — verbose,
// and nothing forces you to write it:
JsonObject user = payload.getAsJsonObject("user");
String tier2 = (user != null && user.has("tier"))
? user.get("tier").getAsString()
: "unknown";Kotlin
// '?.' short-circuits on null, '?:' supplies
// the fallback. One line, and the compiler
// REQUIRES you to deal with nullability.
val tier = event.payload["user"]
?.get("tier")?.asString ?: "unknown"
// Smart cast: inside the check, `user`
// IS non-null — no cast, no warning.
val user = payload["user"]
if (user != null) {
println(user.asJsonObject) // typed access
}Less boilerplate — data classes, properties, defaults
Java 17's record closed part of this gap; Kotlin's data class still does more: copy() for immutable updates, default parameter values, named arguments (no telescoping constructors, no builders), and destructuring.
Java (with records!)
record Subscription(
String pubsubname, String topic, String route) {}
// No defaults — overload or builder:
Subscription a = new Subscription(
"pubsub", "sf-table-delta", "/sf-table-delta");
// "Change one field" = re-list every field:
Subscription b = new Subscription(
a.pubsubname(), "sf-pipeline-out",
"/sf-pipeline-out");Kotlin
data class Subscription(
val pubsubname: String = "pubsub",
val topic: String,
val route: String = "/$topic")
// Defaults + named args:
val a = Subscription(topic = "sf-table-delta")
// Immutable update:
val b = a.copy(topic = "sf-pipeline-out",
route = "/sf-pipeline-out")
// Destructuring:
val (pubsub, topic, route) = bCoroutines — async that reads like sync
Stream processors are concurrency-shaped: consume, fan out, batch, retry, shut down cleanly. Kotlin coroutines make that sequential-looking code with structured concurrency — child tasks live inside a scope, cancellation propagates automatically, and Flow gives you backpressured streams with operators. Java's virtual threads (21+) solve thread cost, but scoping/cancellation remain manual, and CompletableFuture chains remain callback algebra.
Java
// CompletableFuture composition:
CompletableFuture
.supplyAsync(() -> fetchDelta())
.thenCompose(d -> enrich(d))
.thenAccept(r -> publish(r))
.exceptionally(e -> { log(e); return null; });
// Periodic counter (real code from our
// java-consumer): executor + daemon thread
// wiring, torn down by JVM exit only.
ScheduledExecutorService timer =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "counter-printer");
t.setDaemon(true);
return t;
});
timer.scheduleAtFixedRate(
Main::printCounters, 10, 10, TimeUnit.SECONDS);Kotlin
// Same logic, sequential shape; cancellable
// at every suspension point:
suspend fun process() = try {
val delta = fetchDelta()
val result = enrich(delta)
publish(result)
} catch (e: Exception) { log(e) }
// Periodic counter: a coroutine in the app's
// scope — cancelled automatically when the
// scope closes (structured concurrency).
scope.launch {
while (isActive) {
delay(10.seconds)
printCounters()
}
}The expressiveness toolbox
| Feature | What it replaces | Sketch |
|---|---|---|
Exhaustive when over sealed types | Visitor pattern / instanceof chains; forgotten-case bugs become compile errors | when (stage) { is New -> ...; is Filled -> ...; is Canceled -> ... } // compiler: all cases covered |
| Extension functions | StringUtils-style static utility classes | fun JsonObject.field(camel: String, pascal: String) = this[camel] ?: this[pascal] — reads like a method |
| String templates | String.format / concatenation chains | "$topic: +$rows rows (total $total)" |
| Collections API | Streams API ceremony (.stream()...collect(Collectors.toList())) | deltas.filter { it.weight > 0 }.groupBy { it.row["symbol"] } |
| Single-expression functions | Method bodies that are one return | fun ok() = """{"status":"SUCCESS"}""" |
val by default | Discipline-based final everywhere | Immutability is the path of least resistance, not a style-guide rule |
| Scope functions | Temp-variable choreography | HttpServer.create(addr, 0).apply { start() } |
Our own consumer, both ways
The repo's dapr/processors/java-consumer (JDK HttpServer + Gson, 259 lines) subscribes to sf-table-delta/sf-pipeline-out behind a Dapr sidecar. Here is its subscription handshake verbatim, next to the idiomatic Kotlin equivalent — same JDK server, same wire contract, no framework on either side:
Java — actual repo code
private static JsonObject subscription(
String pubsubName, String topic, String route) {
JsonObject o = new JsonObject();
o.addProperty("pubsubname", pubsubName);
o.addProperty("topic", topic);
o.addProperty("route", route);
return o;
}
private static HttpHandler subscribeHandler(
String pubsubName) {
return exchange -> {
if (!"GET".equalsIgnoreCase(
exchange.getRequestMethod())) {
sendJson(exchange, 405, "{}");
return;
}
JsonArray subs = new JsonArray();
subs.add(subscription(pubsubName,
"sf-table-delta", "/sf-table-delta"));
subs.add(subscription(pubsubName,
"sf-pipeline-out", "/sf-pipeline-out"));
sendJson(exchange, 200, GSON.toJson(subs));
};
}Kotlin — the same handshake
val topics = listOf(
"sf-table-delta", "sf-pipeline-out")
fun subscriptions(pubsub: String) =
topics.map {
mapOf("pubsubname" to pubsub,
"topic" to it,
"route" to "/$it")
}
fun subscribeHandler(pubsub: String) =
HttpHandler { ex ->
if (ex.requestMethod != "GET")
return@HttpHandler ex.sendJson(405, "{}")
ex.sendJson(200,
GSON.toJson(subscriptions(pubsub)))
}
// `sendJson` is an extension function on
// HttpExchange — the Java version threads it
// through as a static helper instead.Nothing here is exotic Kotlin — it's the everyday defaults (expressions, extensions, mapOf, templates) compounding into roughly half the code with the failure modes (missed 405 return, mutable accumulation) squeezed out structurally.
Interop & the adoption path
- 100% two-way Java interop. Kotlin compiles to the same bytecode; call any Java library (Gson, Dapr SDK, Spring, anything on Maven Central) with no wrappers, and Java code calls Kotlin back. Mixed Kotlin/Java source sets build together — migrate one file at a time, or never migrate and only write new code in Kotlin.
- Same JVM underneath. GC, JIT, profilers, APM agents, container images, ops runbooks — all unchanged. A Kotlin StreamsForge processor deploys exactly like the Java one (same Gradle, same
dapr run). - First-class ecosystem support. Google made it the default for Android (2019); Spring, Gradle (Kotlin DSL is the default build script language), Quarkus, and Micronaut ship first-class Kotlin support; JetBrains — the IntelliJ people — build the language and its tooling as one product.
- Beyond the JVM when needed. Kotlin Multiplatform shares logic with iOS/JS/native targets — irrelevant to a stream processor today, a real option for shared client models tomorrow.
What Java still has going for it — the honest column
var, virtual threads — and the gap is narrower on Java 21+ than folklore suggests.- Compile speed:
javacis meaningfully faster thankotlincon large modules (the Kotlin K2 compiler narrowed, not closed, this). - Hiring surface: more Java developers exist, and every one of them can read idiomatic Java; Kotlin fluency is common but not universal on JVM teams.
- One fewer moving part: no Kotlin compiler/runtime-stdlib version axis in the build; language evolution comes only from the JDK's conservative cadence — a feature for risk-averse platform teams.
- Virtual threads (Java 21) remove coroutines' main performance argument for IO-bound servers — Kotlin's remaining edge there is the programming model (structured concurrency,
Flow, cancellation), not throughput.
The verdict this platform's polyglot story supports: for new JVM processors, Kotlin's compile-time null safety and lower ceremony are worth it on day one and cost nothing to adopt; existing Java code needs no migration to justify itself.
Official material
kotlinlang.org · official "Comparison to Java" page · coroutines overview · github.com/JetBrains/kotlin · dev.java (official Java portal — read both sides)