| name | kafka-topology |
| description | Build Kafka Streams topologies using the helved-utbetaling custom DSL |
| license | MIT |
| compatibility | opencode |
| metadata | {"audience":"ai-assistant","language":"kotlin","framework":"kafka-streams","domain":"nav-payment-system"} |
What I do
I guide you to use the custom Kafka Streams DSL in libs/kafka/ correctly. This DSL wraps raw Kafka Streams with type-safe builders, custom processors, and opinionated error handling. Do NOT use raw Kafka Streams APIs -- use these abstractions instead.
When to use me
Load this skill when writing or modifying Kafka topologies, Topics, state stores, or stream processors.
Prerequisites: Root AGENTS.md covers general architecture. apps/AGENTS.md has the Kafka topic reference table. Load libs-reference skill for full library API details.
1. Topology Builder
Entry point -- the topology { } function with Topology as the receiver:
val topology = topology("app-name") {
consume(topic)
consume(table)
globalKTable(table)
intercept { builder -> }
}
2. Topic, Table, Store
Topic
Type-safe topic abstraction with key/value serdes:
object Topics {
val utbetalinger = Topic<String, Utbetaling>(
name = "helved.utbetalinger.v1",
keySerde = Serdes.String(),
valueSerde = JsonSerde(Utbetaling::class)
)
}
Table
Wraps a Topic with a state store name. Used for KTable materialization:
val sakerTable = Table<SakKey, Set<UtbetalingId>>(
topic = Topics.saker,
storeName = "saker-store"
)
Store / StateStore
Store defines a state store. StateStore is the read-only runtime wrapper:
val store: StateStore<K, V> = streams.getStore(myStore)
store.getOrNull(key)
store.iterator()
store.filter { k, v -> predicate }
3. Stream Type Chain
The DSL enforces a type-safe progression. Each type exposes only valid operations:
consume(topic) -> ConsumedStream
.map { k, v -> Pair(k2, v2) } -> MappedStream
.branch(predicate) { ... } -> BranchedStream
.forEach { k, v -> } (terminal)
.groupByKey() -> GroupedStream
MappedStream
.join(table) { v, tableV -> } -> JoinedStream
.leftJoin(table) { v, tableV -> } -> JoinedStream
.branch(predicate) { ... } -> BranchedMappedStream
.produce(topic) (terminal)
BranchedStream / BranchedMappedStream
.branch(predicate) { ... } (chain more branches)
.default { ... } (catch-all, required to end branching)
GroupedStream
.aggregate(init, aggregator) -> KTable
.windowedBy(window) -> TimeWindowedStream / SessionWindowedStream
Key rules
.branch() requires a .default {} to terminate
.produce(topic) is the terminal operation to write to a topic
.forEach {} is the terminal operation for side effects (DB writes, logging)
.rekey { newKey } changes the stream key (triggers repartition)
.repartition(numPartitions) explicitly repartitions
4. Serde Helpers
Top-level functions in Serde.kt:
string()
bytes()
json<V>()
jsonList<V>()
xml<V>()
jaxb<V>()
5. Error Handling
In processors: Result.catch { }
Wraps business logic in a Result<V, StatusReply>. Catches ApiError and Throwable:
consume(topic)
.map { key, value ->
Result.catch {
processPayment(value)
}
}
.branch(Result::isErr) { stream ->
stream.map { key, err -> Pair(key, err.unwrap()) }
.produce(Topics.status)
}
.default { stream ->
stream.map { key, ok -> Pair(key, ok.unwrap()) }
.produce(Topics.output)
}
Infrastructure-level error handlers
Configured on the Kafka Streams instance (not in topology code):
DeserializationAgainHandler / DeserializationNextHandler -- deserialization failures
ProductionAgainHandler / ProductionNextHandler -- production failures
UncaughtHandler -- shuts down the Kafka client
6. Processors
| Processor | Purpose |
|---|
Processor<Kin,Vin,Kout,Vout> | Simple stateless transform |
StateProcessor<K,V,U,R> | Processor with named state store access |
StateScheduleProcessor<K,V> | Wall-clock scheduled punctuator on KTable state |
SuppressProcessor | Buffers windowed records, emits after inactivity gap |
DedupProcessor | Deduplicates by key+value hash within retention period |
EnrichMetadataProcessor | Enriches record with Metadata (topic, partition, offset, timestamps, headers) |
7. Named Processors
Every processor must have a unique name. The Named value class registers names in a global Names singleton that fails on duplicates:
In tests, clear the Names singleton between test runs (typically in @AfterEach).
8. Real Examples
Simple: Audit logger (peisschtappern pattern)
Consumes all topics as raw bytes, enriches with metadata, persists to DB:
topology("peisschtappern") {
consume(Topics.oppdrag)
.process(EnrichMetadataProcessor())
.forEach { key, value ->
dao.insert(key, value)
}
}
Moderate: Status sync + aggregation (utsjekk pattern)
topology("utsjekk") {
val sakerTable = globalKTable(Tables.saker, retention = 24.hours)
consume(Topics.utbetalinger)
.groupByKey()
.aggregate(
initializer = { emptySet() },
aggregator = { key, value, acc -> acc + value.uid }
)
consume(Topics.status)
.forEach { key, status ->
runBlocking {
withContext(jdbcCtx) {
StatusDao.upsert(key, status)
}
}
}
}
Complex: Payment aggregation (abetal pattern)
Multiple sub-topologies per fagsystem, each following the same flow:
topology("abetal") {
val sakerTable = globalKTable(Tables.saker)
val pendingTable = globalKTable(Tables.pendingUtbetalinger)
consume(Topics.dpExternal)
.repartition(3)
.merge(consume(Topics.dpInternal))
.map { key, value -> Pair(SakKey(value), value) }
.leftJoin(sakerTable) { value, existingSaker ->
Result.catch { aggregate(value, existingSaker) }
}
.branch(Result::isErr) { stream ->
stream.map { k, v -> Pair(k, v.unwrap()) }
.produce(Topics.status)
}
.default { stream ->
stream.map { k, v -> Pair(k, v.unwrap()) }
.produce(Topics.oppdrag)
}
consume(Topics.oppdrag)
.filter { _, oppdrag -> oppdrag.hasKvittering() }
.flatMap { _, oppdrag -> oppdrag.uids.map { uid -> Pair(uid, oppdrag) } }
.leftJoin(pendingTable) { oppdrag, pending ->
if (pending != null) pending.withKvittering(oppdrag)
else null
}
.branch({ _, v -> v == null }) { stream ->
stream.produce(Topics.retryOppdrag)
}
.default { stream ->
stream.produce(Topics.utbetalinger)
}
}
Key patterns in abetal:
.repartition(3) for consistent processing across partitions
.merge() combines external + internal topics
.leftJoin() with GlobalKTable for stateful processing
.branch() / .default {} for routing errors vs happy path
Result.catch {} wraps all business logic
.flatMap() to fan out from oppdrag to individual UIDs
9. Testing with StreamsMock
object TestRuntime {
val kafka = StreamsMock()
}
@Test
fun `produces oppdrag from payment request`() {
TestRuntime.kafka.connect(createTopology())
val input = TestRuntime.kafka.testTopic(Topics.dpExternal)
val output = TestRuntime.kafka.testTopic(Topics.oppdrag)
input.produce("key1", PaymentRequest(...))
output.assertThat()
.hasTotal(1)
.has("key1", expectedOppdrag)
}
@Test
fun `routes errors to status topic`() {
TestRuntime.kafka.connect(createTopology())
val input = TestRuntime.kafka.testTopic(Topics.dpExternal)
val status = TestRuntime.kafka.testTopic(Topics.status)
input.produce("key1", invalidRequest)
status.assertThat()
.hasTotal(1)
.has("key1", StatusReply(Status.FEILET, ...))
}
TopicAssertion API
topic.assertThat()
.hasTotal(n)
.has(key, expectedValue)
.hasNot(key, unexpectedValue)
.hasTombstone(key)
.hasHeader("name", "value")
.isEmpty()
State store access in tests
val store = TestRuntime.kafka.getStore(Tables.saker)
val saker = store.getOrNull(sakKey)
assertNotNull(saker)
Wall-clock punctuators in tests
TestRuntime.kafka.advanceWallClockTime(Duration.ofMinutes(5))
10. Important Constraints
- Kafka Streams is NOT coroutine-based. Use
runBlocking for DB access inside forEach. Do NOT use suspend functions in stream processors.
- Do NOT use raw Kafka Streams APIs. Always use the DSL wrappers (
Topic, Table, consume(), etc.).
- Every
.branch() chain must end with .default {}.
- Processor names must be unique. The
Named singleton enforces this at topology construction time.
- State stores are eventually consistent. GlobalKTables replicate across all instances but have replication lag.
- snickerboa is the exception -- it uses vanilla
KafkaProducer/KafkaConsumer via KafkaFactory for request-reply correlation, not the topology DSL.