Automated port
Most of a port from Kafka Streams to StoatFlow is import replacement. The stoatflow-openrewrite-recipe recipe does that replacement for you — on Java and Kotlin alike — rewrites the application entry point, swaps your Maven coordinates, and then tells you, precisely, about the handful of things it refused to change on your behalf.
Before you run it
Any JDK from 17 up — 25 included — runs the migration. One version caveat, for Kotlin codebases only:
The recipe must also run against a project that still compiles against Kafka Streams. The rules match resolved types, so a source file that doesn't type-check is skipped. Migrate a green build.
Finally, you need repository access — the same credentials you use to depend on StoatFlow at all. Follow Store your credentials if you have not already. For Maven, the repository must be visible to <pluginRepositories> as well as <repositories>, because the recipe resolves as a plugin dependency:
<!-- ~/.m2/settings.xml — add a profile so the repo is visible to BOTH lists -->
<profiles>
<profile>
<id>stoatflow</id>
<repositories>
<repository>
<id>stoatflow-releases</id>
<url>https://maven.stoatflow.io/releases</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>stoatflow-releases</id>
<url>https://maven.stoatflow.io/releases</url>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>stoatflow</activeProfile>
</activeProfiles>
// rewrite.init.gradle.kts — no change to your build files at all.
initscript {
repositories { maven { url = uri("https://plugins.gradle.org/m2") } }
dependencies { classpath("org.openrewrite:plugin:7.39.0") }
}
rootProject {
apply(plugin = "org.openrewrite.rewrite")
repositories {
mavenCentral()
maven {
url = uri("https://maven.stoatflow.io/releases")
credentials {
username = providers.gradleProperty("stoatflowRepoUser").get()
password = providers.gradleProperty("stoatflowRepoToken").get()
}
}
}
dependencies { add("rewrite", "io.stoatflow:stoatflow-openrewrite-recipe:VERSION") }
configure<org.openrewrite.gradle.RewriteExtension> {
activeRecipe("io.stoatflow.rewrite.MigrateFromKafkaStreams")
}
}
Could not resolve io.stoatflow:stoatflow-openrewrite-recipe, with no mention of authentication.Run it
The recipe version is the StoatFlow version you are adopting — they ship in lockstep. The current version is 1.0.0-rc.1.
# 1. Preview. Writes target/rewrite/rewrite.patch and changes nothing.
mvn org.openrewrite.maven:rewrite-maven-plugin:dryRun \
-Drewrite.recipeArtifactCoordinates=io.stoatflow:stoatflow-openrewrite-recipe:VERSION \
-Drewrite.activeRecipes=io.stoatflow.rewrite.MigrateFromKafkaStreams
# 2. Apply.
mvn org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=io.stoatflow:stoatflow-openrewrite-recipe:VERSION \
-Drewrite.activeRecipes=io.stoatflow.rewrite.MigrateFromKafkaStreams
# 1. Preview. Writes build/reports/rewrite/rewrite.patch and changes nothing.
./gradlew --init-script rewrite.init.gradle.kts rewriteDryRun
# 2. Apply.
./gradlew --init-script rewrite.init.gradle.kts rewriteRun
What it did, and what it left you
Read the diff, then read the PortingResiduals CSV the run exported alongside it. Every ~~> marker in the diff, and every row in that table, names the entry in the KS compatibility matrix that explains what to do.
| The recipe rewrote | The recipe flagged |
|---|---|
All org.apache.kafka.streams.* imports, including the eight types a prefix sweep mis-routes (StateStore, TimestampExtractor, StreamPartitioner, …) | The exactly-once default. Kafka Streams defaults to at_least_once; StoatFlow defaults to exactly-once. If you never set processing.guarantee, your ported application silently becomes transactional. |
new KafkaStreams(topology, props) → StoatFlow.fromBuilder(new StreamsConfig(props), builder), when the builder is in reach | Types with no StoatFlow equivalent: Interactive Queries v2, StreamsMetrics, TaskMetadata, processor.To, the record-metadata accessors. |
streams.state() → streams.kafkaStreamsState(), so state comparisons keep Kafka Streams semantics | Shapes that compile but throw at runtime: Suppressed.maxBytes, multicast on repartition(), a hand-rolled BytesStoreSupplier. |
JoinWindows field access, ValueTransformerWithKey.init, StateListener.onChange parameter types | An entry point whose StreamsBuilder it could not resolve — because guessing would produce code that compiles and drops every processor. |
| Maven dependency coordinates and the repository declaration |
Gradle build files are left alone by design; apply the five-line change yourself. Swap org.apache.kafka:kafka-streams for io.stoatflow:stoatflow-core, add the repository, and move to a JDK 25 toolchain with --enable-preview — the io.stoatflow Gradle plugin does the last part for you.
Then build. Remaining compile errors should map one-to-one onto flagged rows. Anything else is a recipe bug, and worth reporting.
The one thing worth understanding
Kafka Streams has a single KafkaStreams.State. StoatFlow has two enums: a richer native StoatFlow.State, returned by state(), and a Kafka Streams-exact KafkaStreamsState mirror. The recipe maps KafkaStreams.State onto the mirror and redirects state() to kafkaStreamsState(), because either half alone is dangerous: streams.state() == KafkaStreamsState.RUNNING would then compare two unrelated enums. In Java that is a compile error. In Kotlin it compiles with only a warning and is always false at runtime — and .equals(...) is silently always false in both languages.
You do not have to think about this; the recipe handles it, and flags anything it could not. It is worth knowing because it is the one place a mechanical port could otherwise go quietly wrong.
Kotlin null-key lambdas
One Kotlin-only thing the recipe can't fix, because it is a language artifact rather than an import: over a topic that may carry null keys, declare the key parameter of a stateless operator lambda nullable — map { _: K?, v -> … }, filter { _: K?, v -> … }, selectKey { _: K?, v -> … }. A non-nullable inferred key parameter (even _) makes the Kotlin compiler insert a checkNotNullParameter null-check, so a null key throws NullPointerException: Parameter specified as non-null is null inside your compiled lambda — on Kafka Streams too, not just StoatFlow. Both frameworks invoke your lambda with the raw (null) key; the nullable declaration suppresses the check. Aggregations (groupBy(...).count/reduce/aggregate) and the stream–table INNER join already drop null-key records before your lambda runs, matching Kafka Streams — no change needed there.
After the port
You now have a compiling StoatFlow application and a decision to make about your existing state.
Without data migration
Reprocess from the input topics. The simplest path — pick it whenever you can.
With data migration
Carry your accumulated state across rather than recomputing it.
If you prefer no tooling at all, the manual import table in Without data migration remains a complete fallback.
Migrating from Kafka Streams
What carries over unchanged when you move a Kafka Streams topology to StoatFlow, what changes operationally, and how to decide between a green-field cutover and a state-carrying migration.
Migration without carrying state
Green-field cutover from Kafka Streams — point StoatFlow at the same source topics with a fresh consumer group, let stateful operators rebuild from changelog/source, then switch traffic. The dependency, build, and config swap, with the before/after grounded in the map-filter example.