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

ConcernJavaKotlin
Null bugsNullPointerException at runtime; Optional and @Nullable are conventions, not guaranteesNullability is in the type system — String vs String?; the compiler rejects unchecked access
Data carriersrecord helps (finally), but no copy(), no default/named args, no destructuring declarationsdata class: equals/hashCode/toString/copy()/destructuring, with defaults and named args
ConcurrencyThreads, CompletableFuture chains, or virtual threads (21+) — cancellation and scoping are manualCoroutines: sequential-looking async code, structured concurrency, first-class cancellation, Flow for streams
Domain modelingsealed interface + pattern matching arriving piecewise across versionssealed class + exhaustive when since 1.0 — the compiler tells you which case you forgot
CeremonyGetters/setters/builders/static utility classesval/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
The one-sentence pitch: Kotlin is Java with the defaults flipped to safe and concise — immutable-first, null-checked at compile time, expression-oriented — while keeping the whole Java ecosystem (libraries, JVM ops, profilers) unchanged underneath.

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) = b

Coroutines — 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

FeatureWhat it replacesSketch
Exhaustive when over sealed typesVisitor pattern / instanceof chains; forgotten-case bugs become compile errorswhen (stage) { is New -> ...; is Filled -> ...; is Canceled -> ... } // compiler: all cases covered
Extension functionsStringUtils-style static utility classesfun JsonObject.field(camel: String, pascal: String) = this[camel] ?: this[pascal] — reads like a method
String templatesString.format / concatenation chains"$topic: +$rows rows (total $total)"
Collections APIStreams API ceremony (.stream()...collect(Collectors.toList()))deltas.filter { it.weight > 0 }.groupBy { it.row["symbol"] }
Single-expression functionsMethod bodies that are one returnfun ok() = """{"status":"SUCCESS"}"""
val by defaultDiscipline-based final everywhereImmutability is the path of least resistance, not a style-guide rule
Scope functionsTemp-variable choreographyHttpServer.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

This page argues for Kotlin, not against reality. Java is an excellent language that keeps absorbing Kotlin's best ideas — records, sealed interfaces, pattern matching, var, virtual threads — and the gap is narrower on Java 21+ than folklore suggests.
  • Compile speed: javac is meaningfully faster than kotlinc on 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.