KStream and KTable operations

Stateless stream transforms (map, filter, flatMap, selectKey, branch, merge) and KTable basics — with the rules for when a key change forces records onto a different lane.

KStream<K, V> is an unbounded stream of records; KTable<K, V> is a changelog table holding one value per key. This page covers the stateless KStream transforms and the basic KTable operations. Aggregations, joins, windowing, and the Processor API have their own pages.

Both types are created through the StreamsBuilder, never constructed directly. The operators are Kafka Streams compatible — Java code written against the KS DSL compiles against StoatFlow with no changes (see the Kafka Streams compatibility matrix).

Two kinds of operator: value-preserving vs key-changing

The single most important distinction in this API is whether an operator changes the key. StoatFlow routes every record to a processing lane by key affinity — same key, same lane, in order. An operator that changes the key forces the record to be re-routed to whichever lane owns the new key. (This is StoatFlow's in-memory equivalent of a Kafka Streams repartition topic — no broker round-trip. See Lanes and parallelism and Architecture.)

OperatorChanges key?Effect
mapValuesNoStays on the same lane
filter / filterNotNoStays on the same lane
flatMapValuesNoAll output records keep the input key
peekNoPure side effect
mergeNoRecords keep their original key affinity
splitNoRouting only — keys unchanged
mapYesRe-routed by new key
flatMapYesEach output re-routed by its key
selectKeyYesRe-routed by new key
groupByYesRe-keys, then groups

Prefer the value-only operators (mapValues, flatMapValues) when you don't need to change the key — they avoid re-routing entirely.

Naming operators

Every operator accepts an optional trailing Named parameter. StoatFlow uses these stable names for the topology graph, metrics, and (for stateful operators) state-store identity. In Kotlin as is a soft keyword, so escape it with backticks: Named.`as`("uppercase"). In Java it's a plain method: Named.as("uppercase").

import io.stoatflow.core.topology.Named

Naming is optional but recommended — unnamed nodes get auto-generated names that change as you edit the topology.

Value transforms — mapValues

mapValues transforms each value and keeps the key. There's a value-only form and a key-aware form (the key is read-only — it is not changed).

import io.stoatflow.core.topology.Named

// value-only
val upper = stream.mapValues({ v -> v.uppercase() }, Named.`as`("uppercase"))

// key-aware (key is read-only)
val tagged = stream.mapValues({ k, v -> "$k:$v" }, Named.`as`("tag-with-key"))
Values may be null (a tombstone). Your mapper, predicate, and action functions must handle null values — StoatFlow does not filter them out for you.

Key + value transform — map

map returns a KeyValue<KR, VR>, replacing both key and value. This is a key-changing operation — the output is re-routed to the lane that owns the new key.

import io.stoatflow.core.state.KeyValue
import io.stoatflow.core.topology.Named

val rekeyed = stream.map(
    { key, value -> KeyValue(value.substring(0, 3), "$key:$value") },
    Named.`as`("rekey-by-prefix"),
)

Filtering — filter and filterNot

filter keeps records that match the predicate; filterNot keeps records that don't. Neither changes the key.

import io.stoatflow.core.topology.Named

val longEnough = stream.filter({ _, v -> v.length > 5 }, Named.`as`("keep-long"))
val notEmpty = stream.filterNot({ _, v -> v.isEmpty() }, Named.`as`("drop-empty"))

One-to-many — flatMapValues and flatMap

flatMapValues turns each value into zero or more values, all keeping the input key — value-only, so no re-routing. There's also a key-aware form that reads (but does not change) the key.

import io.stoatflow.core.topology.Named

val whitespace = "\\s+".toRegex()
val words = lines.flatMapValues(
    { line -> line.lowercase().split(whitespace).filter { it.isNotBlank() } },
    Named.`as`("split-words"),
)

flatMap returns an Iterable<KeyValue<KR, VR>> — each output record carries its own key, so this is a key-changing operation and each output is re-routed independently.

import io.stoatflow.core.state.KeyValue
import io.stoatflow.core.topology.Named

val perTag = stream.flatMap(
    { _, value -> value.tags.map { tag -> KeyValue(tag, value.id) } },
    Named.`as`("explode-tags"),
)

Re-keying — selectKey

selectKey replaces only the key, leaving the value alone. It is a key-changing operation — use it before groupByKey or a join when you need to key the stream on a value field.

import io.stoatflow.core.topology.Named

val byCustomer = orders.selectKey({ _, order -> order.customerId }, Named.`as`("key-by-customer"))

Side effects — peek

peek runs an action for each record (logging, counters, debugging) and forwards the record unchanged. The key is never modified. (forEach is the terminal variant — it runs the action but returns nothing, ending the chain.)

import io.stoatflow.core.topology.Named

val observed = stream.peek({ k, v -> println("$k => $v") }, Named.`as`("log"))

The Java overloads take a ForeachAction, so void lambdas work directly — no return Unit.INSTANCE.

Combining streams — merge

merge combines two streams of the same key/value types into one. It is not a key-changing operation: records keep their original key affinity. Both streams must come from the same StreamsBuilder.

import io.stoatflow.core.topology.Named

val combined = streamA.merge(streamB, Named.`as`("merge-a-b"))

Branching — split

split routes each record to exactly one branch using first-match semantics: predicates are evaluated in order, and the first match wins. split() returns a BranchedKStream; the terminal defaultBranch() / noDefaultBranch() call returns a map of branch name to KStream. Branch names are the split name plus the Branched suffix — e.g. split name router plus suffix -high gives key router-high. split does not change the key.

Finish the chain with defaultBranch() (unmatched records go to a default branch), defaultBranch(Branched.as("-suffix")) (named default), or noDefaultBranch() (unmatched records are dropped).

import io.stoatflow.core.topology.Branched
import io.stoatflow.core.topology.Named

val branches: Map<String, KStream<Long, String>> = stream
    .split(Named.`as`("router"))
    .branch({ _, v -> v.length > 20 }, Branched.`as`("-high"))
    .branch({ _, v -> v.length > 10 }, Branched.`as`("-medium"))
    .defaultBranch(Branched.`as`("-low"))

branches["router-high"]!!.to("high-topic")
branches["router-medium"]!!.to("medium-topic")
branches["router-low"]!!.to("low-topic")

Branched can also inline a branch transform or side effect instead of returning the stream in the map: Branched.withFunction(s -> s.mapValues(...), "-medium") applies a transform and stores the result; Branched.withConsumer(s -> s.to("topic"), "-low") applies a side effect and stores the original branch stream.

KTable basics

A KTable represents the latest value per key as a changelog. You get one from StreamsBuilder.table(...), from a stream-to-table conversion (KStream.toTable), or as the output of an aggregation. The stateless KTable operators mirror the stream ones but operate on the table's changelog.

mapValues and filter

KTable.mapValues transforms values (value-only and key-aware forms). KTable.filter / filterNot keep or drop entries by predicate. Without a Materialized argument these are derived, on-the-fly views over the source table's state and keep no store of their own.

A materialized KTable.filter differs semantically from the non-materialized form: when an entry stops matching, a materialized filter writes a tombstone (null) to its store and forwards it downstream, whereas the non-materialized filter simply doesn't forward the record. Pass a Materialized argument to opt into the store. See State stores.
import io.stoatflow.core.topology.Named

val upper = table
    .mapValues({ v -> v.uppercase() }, Named.`as`("table-upper"))
    .filter({ _, v -> v.isNotEmpty() }, Named.`as`("table-non-empty"))

toStream — table back to stream

KTable.toStream converts the table's changelog into a KStream: every update to a key becomes a record. This is how you write a table's results to a topic. (count, reduce, and aggregate produce a KTable, so toStream is the usual bridge to a sink — see Aggregations.)

import io.stoatflow.core.topology.Named

table
    .mapValues({ v -> v.uppercase() }, Named.`as`("table-upper"))
    .toStream(Named.`as`("to-stream"))
    .to("output-topic")

There's a key-changing toStream(keyMapper, named) overload that derives a new key from each entry — like selectKey, it re-routes the resulting records to the lane that owns the new key.

toTable — stream to table

KStream.toTable interprets a stream as a changelog: each record updates the key's value in the resulting table, and a null value is a tombstone that deletes the key. It preserves the key (no re-routing). Pass a Materialized to control the backing store and its name; without one StoatFlow auto-generates a store name.

import io.stoatflow.core.state.StateStore
import io.stoatflow.core.topology.Materialized
import org.apache.kafka.common.serialization.Serdes

val latest = stream.toTable(
    Materialized.`as`<String, String, StateStore>("latest-by-key")
        .withKeySerde(Serdes.String())
        .withValueSerde(Serdes.String()),
)

Putting it together

A complete stateless pipeline: read a topic, re-key, filter, uppercase, and write the result. (Adapted from the map-filter example.)

import io.stoatflow.core.state.KeyValue
import io.stoatflow.core.topology.Consumed
import io.stoatflow.core.topology.Named
import io.stoatflow.core.topology.Produced
import io.stoatflow.core.topology.StreamsBuilder
import org.apache.kafka.common.serialization.Serdes

fun buildTopology(builder: StreamsBuilder) {
    builder
        .stream("input-topic", Consumed.`as`<Long, String>("source").withKeySerde(Serdes.Long()))
        .map({ key, value -> KeyValue(value.substring(0, 3), "$key:$value") }, Named.`as`("rekey"))
        .filter({ _, value -> value.length > 5 }, Named.`as`("keep-long"))
        .mapValues({ value -> value.uppercase() }, Named.`as`("uppercase"))
        .to(
            "output-topic",
            Produced.`as`<String, String>("sink")
                .withKeySerde(Serdes.String())
                .withValueSerde(Serdes.String()),
        )
}

Next steps

  • AggregationsgroupByKey, groupBy, count, reduce, aggregate.
  • Joins — stream-table, stream-stream, and table-table joins.
  • SerdesConsumed, Produced, and serde resolution.
  • Lanes and parallelism — how key affinity and re-routing work.
  • Testing — verify these operators with the in-memory test driver.