Automated port

Migrate a Kafka Streams application's imports, entry point and build to StoatFlow with a single OpenRewrite command, across Java and Kotlin, with every judgment call flagged rather than guessed.

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:

On JDK 25, use a current OpenRewrite plugin. Plugin releases from before February 2026 bundle a Kotlin compiler that throws on Java 25 — every Kotlin source then fails to parse, and because the migration rules match on resolved types they skip those files without an error: the run reports success and changes nothing. The plugin versions in the snippets below are past the fix. The recipe reports each unparsed file either way, so a silent skip is never invisible.

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>
If resolution fails, check the credentials before the coordinates. A 401 from a plugin repository surfaces as a generic 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

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 rewroteThe 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 reachTypes 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 semanticsShapes that compile but throw at runtime: Suppressed.maxBytes, multicast on repartition(), a hand-rolled BytesStoreSupplier.
JoinWindows field access, ValueTransformerWithKey.init, StateListener.onChange parameter typesAn 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.

If you prefer no tooling at all, the manual import table in Without data migration remains a complete fallback.