소스 정보
- 저장소
- navikt/helved-utbetaling
- 최근 소스 활동
- 2026년 4월 24일 11:31
- 감지된 SKILL.md 언어
- 영어
- 스타
- 1
- 포크
- 1
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/navikt/helved-utbetaling --skill kafka-topology명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
SOC 직업 분류 기준
SKILL.md 표시 중
| 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"} |
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.
Load this skill when writing or modifying Kafka topologies, Topics, state stores, or stream processors.
Prerequisites: Root
AGENTS.mdcovers general architecture.apps/AGENTS.mdhas the Kafka topic reference table. Loadlibs-referenceskill for full library API details.
Entry point -- the topology { } function with Topology as the receiver:
val topology = topology("app-name") {
// Available operations inside this block:
consume(topic) // -> ConsumedStream<K,V>
consume(table) // -> KTable<K,V>
globalKTable(table) // -> GlobalKTable<K,V>
intercept { builder -> } // escape hatch to raw StreamsBuilder
}
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)
)
}
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 defines a state store. StateStore is the read-only runtime wrapper:
val store: StateStore<K, V> = streams.getStore(myStore)
store.getOrNull(key) // K -> V?
store.iterator() // Iterator<KeyValue<K, V>>
store.filter { k, v -> predicate }
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
.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 repartitionsTop-level functions in Serde.kt:
string() // Serdes<String, String>
bytes() // Serdes<ByteArray, ByteArray>
json<V>() // Serdes<String, V> using Jackson
jsonList<V>() // Serdes<String, List<V>>
xml<V>() // Serdes<String, V> using Jackson XML
jaxb<V>() // Serdes<String, V> using JAXB
Result.catch { }Wraps business logic in a Result<V, StatusReply>. Catches ApiError and Throwable:
consume(topic)
.map { key, value ->
Result.catch {
// Business logic that may throw
processPayment(value)
}
}
.branch(Result::isErr) { stream ->
// Route errors to status topic
stream.map { key, err -> Pair(key, err.unwrap()) }
.produce(Topics.status)
}
.default { stream ->
// Happy path continues
stream.map { key, ok -> Pair(key, ok.unwrap()) }
.produce(Topics.output)
}
Configured on the Kafka Streams instance (not in topology code):
DeserializationAgainHandler / DeserializationNextHandler -- deserialization failuresProductionAgainHandler / ProductionNextHandler -- production failuresUncaughtHandler -- shuts down the Kafka client| 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) |
Every processor must have a unique name. The Named value class registers names in a global Names singleton that fails on duplicates:
// Names are typically derived from topic + operation
// The DSL handles this automatically for most operations
// Custom processors need explicit naming
In tests, clear the Names singleton between test runs (typically in @AfterEach).
Consumes all topics as raw bytes, enriches with metadata, persists to DB:
topology("peisschtappern") {
consume(Topics.oppdrag) // bytes()
.process(EnrichMetadataProcessor())
.forEach { key, value ->
// Persist to DB (uses runBlocking since Kafka Streams is not coroutine-based)
dao.insert(key, value)
}
}
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 ->
// Write to DB (not coroutine-based)
runBlocking {
withContext(jdbcCtx) {
StatusDao.upsert(key, status)
}
}
}
}
Multiple sub-topologies per fagsystem, each following the same flow:
topology("abetal") {
val sakerTable = globalKTable(Tables.saker)
val pendingTable = globalKTable(Tables.pendingUtbetalinger)
// One sub-topology per fagsystem (dp, aap, ts, tp, historisk)
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)
// Also produce pending + status
}
// Kvittering handling: join oppdrag with pending to produce final utbetalinger
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 // retry later
}
.branch({ _, v -> v == null }) { stream ->
stream.produce(Topics.retryOppdrag) // not found yet, retry
}
.default { stream ->
stream.produce(Topics.utbetalinger) // final payment
}
}
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 pathResult.catch {} wraps all business logic.flatMap() to fan out from oppdrag to individual UIDsobject 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, ...))
}
topic.assertThat()
.hasTotal(n) // exact count
.has(key, expectedValue) // key-value match
.hasNot(key, unexpectedValue) // negative match
.hasTombstone(key) // null value for key
.hasHeader("name", "value") // header check
.isEmpty() // no records
val store = TestRuntime.kafka.getStore(Tables.saker)
val saker = store.getOrNull(sakKey)
assertNotNull(saker)
TestRuntime.kafka.advanceWallClockTime(Duration.ofMinutes(5))
// Triggers any StateScheduleProcessor punctuators
runBlocking for DB access inside forEach. Do NOT use suspend functions in stream processors.Topic, Table, consume(), etc.)..branch() chain must end with .default {}.Named singleton enforces this at topology construction time.KafkaProducer/KafkaConsumer via KafkaFactory for request-reply correlation, not the topology DSL.