Migration without carrying state
This is the simplest way to move a topology from Kafka Streams to StoatFlow: you do not carry the existing Kafka Streams state across. You point a StoatFlow build at the same source topics under a new application id (a fresh consumer group), let the stateful operators rebuild their state — from the source data or from StoatFlow's own changelog topics, never from the Kafka Streams ones — and switch downstream traffic once the new app has caught up.
It suits most cases: stateless topologies (nothing to carry), topologies whose source topics are still fully retained, and any time you can tolerate a re-read of the input. If your state can only be reconstructed by replaying years of compacted input you can't afford to re-read, see Migration with data migration instead.
When this path applies
| Your topology | This path works because |
|---|---|
Stateless (map / filter / flatMap / branching / re-routing) | There is no state to carry — the cutover is purely dependency + config. |
| Stateful, with source topics still fully retained | Stateful operators rebuild by re-reading the source from the beginning. |
| Stateful, backed by compacted input or rebuildable aggregates | StoatFlow builds its own changelog topics as it processes; a one-time reprocess re-derives the state. |
The case this path does not cover: state you can only get by replaying source you no longer have (or can't afford to re-read). StoatFlow cannot restore from a Kafka Streams changelog topic — the changelog formats are not interchangeable. That scenario is Migration with data migration.
The four-step cutover
- Swap the dependency and imports —
io.stoatflow:stoatflow-runtime, DSL imports underio.stoatflow.core.topology.*. - Swap the entry point —
KafkaStreams→StoatFlowRuntime,Properties→application.yaml. - Run the StoatFlow app alongside the old one under a fresh application id so it builds an independent consumer group and (for stateful topologies) its own state. Let it catch up to the live offset.
- Switch downstream traffic to the StoatFlow output, then retire the Kafka Streams app.
Steps 1 and 2 are the code change. Steps 3 and 4 are the operational cutover. The rest of this page walks each one, grounded in the map-filter example that ships in both flavours.
Step 1 — dependency and build
Drop the Kafka Streams dependency and add stoatflow-runtime (the batteries-included module — it transitively pulls in stoatflow-core). If you've followed Installation, the private Maven repository and the io.stoatflow Gradle plugin are already wired up.
Before — the Kafka Streams build:
// build.gradle.kts (Kafka Streams)
plugins {
kotlin("jvm")
application
}
application {
mainClass.set("com.example.MainKt")
}
dependencies {
implementation("org.apache.kafka:kafka-streams:4.1.1")
runtimeOnly("ch.qos.logback:logback-classic")
}
After — the StoatFlow build:
// build.gradle.kts (StoatFlow)
plugins {
kotlin("jvm")
id("io.stoatflow") version "<stoatflow-version>"
}
stoatflow {
mainClass.set("com.example.MainKt")
}
dependencies {
implementation("io.stoatflow:stoatflow-runtime:<stoatflow-version>")
runtimeOnly("ch.qos.logback:logback-classic")
}
The current version is 1.0.0-alpha.16 — substitute it for the <stoatflow-version> placeholder. The io.stoatflow Gradle plugin applies the JDK 25 toolchain, the --enable-preview and --enable-native-access=ALL-UNNAMED JVM flags, -XX:+UseG1GC, and a runnable fat-jar — so you don't hand-maintain any of the run wiring. See Installation for the Maven equivalent and the plugin-free option.
Step 2 — imports, entry point, and config
The topology code stays the same shape — same StreamsBuilder, same operators, same Consumed / Produced / Named. Two things change: the imports move from org.apache.kafka.streams.* to io.stoatflow.core.topology.*, and the bootstrap moves from a KafkaStreams instance + Properties to StoatFlowRuntime.fromConfig(...) + application.yaml.
Imports
| Kafka Streams import | StoatFlow import |
|---|---|
org.apache.kafka.streams.StreamsBuilder | io.stoatflow.core.topology.StreamsBuilder |
org.apache.kafka.streams.kstream.KStream | io.stoatflow.core.topology.KStream |
org.apache.kafka.streams.kstream.Consumed | io.stoatflow.core.topology.Consumed |
org.apache.kafka.streams.kstream.Produced | io.stoatflow.core.topology.Produced |
org.apache.kafka.streams.kstream.Named | io.stoatflow.core.topology.Named |
org.apache.kafka.streams.kstream.Branched | io.stoatflow.core.topology.Branched |
org.apache.kafka.streams.KeyValue | io.stoatflow.core.state.KeyValue |
org.apache.kafka.streams.state.* (Stores, KeyValueStore, …) | io.stoatflow.core.state.* |
org.apache.kafka.streams.processor.api.* (Processor, Record, …) | io.stoatflow.core.processor.* |
org.apache.kafka.streams.errors.* (exception handlers) | io.stoatflow.core.exception.* |
org.apache.kafka.streams.CloseOptions | io.stoatflow.core.CloseOptions (only if you call close(CloseOptions); see the note below) |
org.apache.kafka.common.serialization.Serdes | org.apache.kafka.common.serialization.Serdes (unchanged — Kafka serdes are reused) |
The Kafka serde classes (Serdes.String(), Serdes.Long(), your Avro/Protobuf/JSON serdes) come from the Kafka clients library and carry over verbatim — as does everything else under org.apache.kafka.common.* and org.apache.kafka.clients.* (Headers, ConsumerRecord, ProducerRecord, …). Only the Kafka Streams types move namespaces. See the KS compatibility matrix for the method-by-method DSL parity.
close(Duration) / close(CloseOptions) carry over.streams.close(Duration.ofSeconds(30)) works unchanged; close(CloseOptions...) uses the current top-level KIP-1153 shape via io.stoatflow.core.CloseOptions (CloseOptions.timeout(d).withGroupMembershipOperation(...)). If your app used the deprecated nestedKafkaStreams.CloseOptions (KIP-812, new CloseOptions().timeout(d).leaveGroup(true)), rewrite that one line to the factories. See Lifecycle.Entry point
In Kafka Streams you build a Properties, construct KafkaStreams, register a shutdown hook, and call start(). The map-filter example does exactly that:
// Before — Kafka Streams
val props = Properties().apply {
put(StreamsConfig.APPLICATION_ID_CONFIG, "map-filter-example-ks")
put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, System.getenv("KAFKA_BOOTSTRAP_SERVERS") ?: "localhost:9092")
put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String()::class.java)
put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String()::class.java)
}
val builder = StreamsBuilder()
buildTopology(builder)
val streams = KafkaStreams(builder.build(), props)
Runtime.getRuntime().addShutdownHook(Thread { streams.close() })
streams.start()
Thread.currentThread().join()
// Before — Kafka Streams
var props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "map-filter-example-ks");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
System.getenv().getOrDefault("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092"));
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
var builder = new StreamsBuilder();
buildTopology(builder);
var streams = new KafkaStreams(builder.build(), props);
Runtime.getRuntime().addShutdownHook(new Thread(streams::close));
streams.start();
Thread.currentThread().join();
On StoatFlow, StoatFlowRuntime.fromConfig(...) loads application.yaml, starts the HTTP + metrics server, installs graceful shutdown, and runs the topology until terminated. Non-serializable settings — the default serdes, exception handlers — are set in code via streamsConfigOverrides { ... }; everything else lives in YAML:
// After — StoatFlow runtime
import io.stoatflow.core.topology.StreamsBuilder
import io.stoatflow.runtime.StoatFlowRuntime
import org.apache.kafka.common.serialization.Serdes
fun main() {
val runtime = StoatFlowRuntime.fromConfig(
topologyBuilder = { buildTopology(it) },
configure = {
streamsConfigOverrides {
defaultKeySerde(Serdes.String())
defaultValueSerde(Serdes.String())
}
},
)
runtime.start()
runtime.awaitTermination()
}
// After — StoatFlow runtime
import io.stoatflow.core.topology.StreamsBuilder;
import io.stoatflow.runtime.StoatFlowRuntime;
import org.apache.kafka.common.serialization.Serdes;
public class Main {
public static void main(String[] args) {
var runtime = StoatFlowRuntime.fromConfig(
Main::buildTopology,
builder -> builder.streamsConfigOverrides(cfg -> {
cfg.defaultKeySerde(Serdes.String());
cfg.defaultValueSerde(Serdes.String());
})
);
runtime.start();
runtime.awaitTermination();
}
}
The application-id and bootstrap-servers move out of Properties and into application.yaml:
# src/main/resources/application.yaml
stoatflow:
application-id: map-filter-stoatflow # NEW id — a fresh consumer group
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:-localhost:9092}
license:
key: ${STOATFLOW_LICENSE_KEY}
runtime:
http:
enabled: true
port: ${HTTP_PORT:-8080}
metrics:
enabled: true
StoatFlow is licensed — stoatflow.license.key is required and has no Kafka Streams equivalent. See License configuration.
setStateListener. A Kafka Streams setStateListener keyed on KafkaStreams.State is usually wired for readiness or alerting. Under the runtime, that surface is the operational one — /health/ready, /health/live, and /state (see REST API) — so most such listeners retire. If you build against :core directly, StoatFlow.setStateListener(...) is the KS-exact single overload (a method reference or lambda, no cast), and StoatFlow's richer 10-state lifecycle maps back onto the KS 7-state KafkaStreams.State at the boundary via newState.toKafkaStreamsState() / app.kafkaStreamsState(). See the Lifecycle rows in the compatibility matrix.selectKey / map / filter / mapValues / peek / split / to chain — port across with only the import-namespace change. Compare the Kafka Streams example (examples-ks/map-filter) against the StoatFlow runtime example (examples/map-filter-runtime): the overlapping DSL is the same, line for line, aside from the namespace. The two examples are not identical — the StoatFlow one also exercises StoatFlow-only surface (a scheduled source, a Processor with key-based timers, an extra table(...) branch), and the Kafka Streams one keeps an explicit repartition() that StoatFlow's in-memory re-keying makes unnecessary. But that's added scope, not a rewrite of the shared chain. That's the point of the DSL parity: the operators you already wrote port mechanically.Residual manual fixes
The import swap gets a typical port compiling except for a short, enumerated set of deliberate divergences. Walk this checklist once — each is a one-line fix that surfaces at compile time (or, for the last two, at startup):
JoinWindows.beforeMs/afterMsare getters here, not public fields: replace Java field accessjw.beforeMswithjw.getBeforeMs()/jw.getAfterMs(). Kotlin property access is unaffected.VersionedRecord.validTo()returnsOptional<Long>instead oflong— use.orElse(...)/.isPresent().- Exception handlers receive a
Record, not separate key/value: readrecord.key()/record.value()inProcessingExceptionHandler.handle(...). Production serialization failures also arrive throughhandle(...)— branch on(context as ProductionContext).failedOninstead of overriding a separate method. StreamPartitionerimport is mis-routed by a bulk rewrite: it lives inio.stoatflow.core.topology.StreamPartitioner, not underprocessor.*as in Kafka Streams — fix that one import by hand. Multicast partitioners (KIP-837) are honoured on sinks but rejected onrepartition().ValueTransformerWithKey.inittakes aFixedKeyProcessorContextinstead ofProcessorContext— change the parameter type; state-store access is identical.- Custom store suppliers must come from
Stores.*factories — hand-rolled*BytesStoreSupplierimplementations are rejected.Stores.*WithHeadersBuilder(...)needs a*WithHeaders(...)supplier, and an explicitStreamJoinedstore supplier must setretainDuplicates = true. SuppressedmaxBytes/withMaxBytesthrow (suppress buffers hold objects in memory, not bytes) — usemaxRecords(...)orunbounded().KStream.transform*is absent (removed in Kafka Streams 4.x too) — useprocess/processValues.- A configured
processor.wrapper.classthrows at startup rather than being silently ignored — remove the key, or apply the wrapper logic directly in your processors.
Two behavioural shifts need no code change but are worth knowing: taskId() is always 0_0 (single instance — don't branch on it), and ProcessorContext.currentStreamTimeMs() returns the watermark — global, and generally lower than KS per-task stream time. The full catalogue, including everything that needs no change, is the KS compatibility matrix.
Step 3 — what to remove, what to keep
The bulk of a Kafka Streams Properties block carries over as stoatflow.kafka.* passthrough or has a direct stoatflow.* equivalent. A handful of properties model a multi-instance cluster and have no meaning in StoatFlow's single-instance model — drop them.
Properties? If you build against :core directly, the KS-keyed config carries over wholesale: new StreamsConfig(props) / StreamsConfig.fromProperties(props).build() accepts the full Kafka Streams key surface. Recognised-but-inapplicable cluster keys (the table below) log a WARN and are ignored, client keys pass through under their consumer. / producer. prefixes, and truly-unknown keys warn (or fail fast with stoatflow.config.strict=true). The YAML shape on this page is the recommended end state — the adapter just lets you defer the config rewrite.Remove — these model a cluster that doesn't exist
| Kafka Streams property | Why it goes |
|---|---|
num.stream.threads | There is no thread-per-task pool. Parallelism is set by stoatflow.lanes.count, which scales with cores rather than partition count. See Lanes and parallelism. |
num.standby.replicas | This KS knob speeds task hand-off during rebalancing; StoatFlow has no rebalancing, so it has no equivalent. HA is fast restart by default, or StoatFlow's own opt-in hot standby — configured under stoatflow.ha.*, not via this knob. See High availability. |
acceptable.recovery.lag, max.warmup.replicas, probing.rebalance.interval.ms | All rebalancing / task-assignment tuning — no group to rebalance. |
group.instance.id (if you set it manually) | StoatFlow forces static membership using the application id. Setting it has no effect. |
application.server | Interactive-query host discovery is a multi-instance concern; state is global in one process. |
replication.factor (KS internal-topic key) | StoatFlow uses stoatflow.changelog.replication-factor for its changelog topics. |
processing.guarantee. StoatFlow defaults to EXACTLY_ONCE (stoatflow.processing-guarantee); if your Kafka Streams app ran exactly_once_v2, you already match. Mind the default flip, though: Kafka Streams defaults to at_least_once, so a KS app that never set the key was running at-least-once — on StoatFlow it now runs under exactly-once (stronger, but transactional output and a different commit-latency profile) unless you set AT_LEAST_ONCE explicitly. See Exactly-once.Keep — these carry over
| Kafka Streams concern | Where it goes in StoatFlow |
|---|---|
| Topology code (DSL operators, processors) | Unchanged, modulo the import namespace (Step 2). |
| Topic names | Unchanged — they're string literals in the topology. |
Serdes (Serdes.*, Avro/Protobuf/JSON) | Unchanged — Kafka serde classes are reused as-is. |
application.id | Becomes stoatflow.application-id — but pick a new value (see below). |
bootstrap.servers | Becomes stoatflow.bootstrap-servers. |
default.key.serde / default.value.serde | streamsConfigOverrides { defaultKeySerde(...) } in code, or the YAML class-name keys. |
schema.registry.url | stoatflow.schema-registry-url — propagated to default serdes automatically. See Serdes. |
Raw consumer/producer tuning (max.poll.records, fetch.min.bytes, compression.type, linger.ms, acks, auto.offset.reset, security/SSL/SASL, …) | Passed straight through under stoatflow.kafka.consumer / stoatflow.kafka.producer using the exact Kafka property names. See Kafka client config. |
group.id and group.instance.id (both set to the application id, for static single-member membership) and enable.auto.commit=false (offsets are committed by StoatFlow's commit protocol). Setting them under stoatflow.kafka.consumer has no effect — full list in Kafka client config.Use a new application id
This is the one config value you must change rather than copy. The application id is StoatFlow's consumer group id (and transactional id). Reusing the Kafka Streams group id would have the StoatFlow app inherit the old committed offsets — it would resume mid-stream and skip the historical records that stateful operators need to rebuild from.
A fresh id gives the StoatFlow app an independent consumer group with no committed offsets, so it can read the source from the beginning. StoatFlow already defaults auto.offset.reset to earliest (matching Kafka Streams), so a stateful topology rebuilds from the start with no extra config — set it explicitly only to make the intent obvious or to choose otherwise:
stoatflow:
application-id: map-filter-stoatflow # NOT the old KS application.id
kafka:
consumer:
auto.offset.reset: earliest # the default; shown here to make the intent explicit
auto.offset.reset is your reprocessing choice, not a correctness requirement: earliest (StoatFlow's default) reprocesses the full history into the new output; setting latest (the raw Kafka client default) starts the StoatFlow app from the current tip, processing only new records. Pick whichever matches your cutover. For a stateful topology you almost always want earliest, so the aggregates are complete before you switch traffic over.Step 4 — run, catch up, and switch traffic
With the build and config swapped, run the StoatFlow app the same way as any StoatFlow app:
export KAFKA_BOOTSTRAP_SERVERS=localhost:9092
export STOATFLOW_LICENSE_KEY="key/...from your onboarding email..."
./gradlew run
Because the StoatFlow app uses a new application id and writes to its own state and (if stateful) its own changelog topics, it runs independently of the still-running Kafka Streams app — no coordination, no shared group. The recommended cutover:
- Start the StoatFlow app pointed at the same source topics, with the new id and
auto.offset.reset: earliest. It begins consuming from the start of the source and rebuilding state. - Write to a parallel output while validating. Either point
to(...)at new output topics, or run a side-by-side comparison against the Kafka Streams output. This is the safe default — you confirm correctness before anything downstream depends on it. - Watch it catch up. Consumer lag falls toward zero as the StoatFlow app reaches the live tip. Readiness comes up once state restoration completes. What you see:
/health/readyreturns 200, thestoatflow.consumer.lag.recordsmetric trends to ~0, and/stateshows stores fully restored. See Metrics and the REST API. - Switch downstream traffic to the StoatFlow output once lag is near zero and the outputs match.
- Retire the Kafka Streams app. Stop it; once you're confident, delete its consumer group and its internal repartition/changelog topics.
The same cutover as a timeline — the two apps overlap until the switch:
selectKey / groupBy / key-changing joins) happens in memory between lanes — there are no internal repartition topics to provision. The repartition topics your Kafka Streams app created are not used by StoatFlow and can be cleaned up when you retire the old app. See How StoatFlow differs from KS → in-memory re-keying.Verify before you cut over
A topology test driver runs the StoatFlow topology in-memory with no broker — use it to confirm the ported topology behaves identically to the Kafka Streams original before any live cutover. The harness mirrors the Kafka Streams TopologyTestDriver:
val driver = TopologyTestDriver.fromBuilder(builder)
val input = driver.createInputTopic<String, String>("input-topic", Serdes.String(), Serdes.String())
val output = driver.createOutputTopic<String, String>("output-topic", Serdes.String(), Serdes.String())
input.pipeInput("k", "streaming")
assertThat(output.readRecord()?.value).isEqualTo("STREAMING")
driver.close()
var driver = TopologyTestDriver.fromBuilder(builder);
var input = driver.createInputTopic("input-topic", Serdes.String(), Serdes.String());
var output = driver.createOutputTopic("output-topic", Serdes.String(), Serdes.String());
input.pipeInput("k", "streaming");
assertThat(output.readRecord().value()).isEqualTo("STREAMING");
driver.close();
Add the test dependency (io.stoatflow:stoatflow-test-utils, test scope — see Installation). Full coverage of the driver, time control, and state-store assertions is in Testing.
Next steps
- Migration with data migration — when you can't re-read the source and must reconstruct state another way.
- How StoatFlow differs from Kafka Streams — the conceptual deltas behind every step here.
- Configuration reference — every
stoatflow.*key with type and default. - KS compatibility matrix — method-by-method DSL parity.
- Production checklist — before the cutover goes live.
- Migrating something non-trivial? Get in touch — real people read every email during the alpha.
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 carrying state
What it means to carry state across a migration to StoatFlow — reusing changelog topics, offset handling, how restoration rebuilds local state, and what is and is not supported for in-place reuse.