Modeling Kafka Topics in AsyncAPI 3.0
Kafka topics carry no schema of their own, so the contract that producers and consumers agree on lives entirely in your AsyncAPI document. This guide shows how to map a Kafka topic to an AsyncAPI 3.0 channel, attach the protocol-specific bindings.kafka block (topic, partitions, key), wire up operations, and reference reusable message schemas. It is part of the AsyncAPI event-driven patterns section and the broader OpenAPI & AsyncAPI Schema Authoring guide.
If you are still deciding whether Kafka belongs in AsyncAPI at all rather than OpenAPI, read AsyncAPI vs OpenAPI for event-driven architectures first; the short answer is that only AsyncAPI models broker topology natively.
Problem & Context
A Kafka topic is just a named, partitioned log. Nothing in the broker tells a new consumer what bytes to expect, which field is the partition key, or how many partitions exist. Teams that skip a formal contract end up reverse-engineering payloads from production messages and discovering ordering bugs the hard way.
AsyncAPI 3.0 solves this by separating three concerns that earlier versions blurred:
channelsdescribe the medium — for Kafka, one channel per topic, with the topic name inaddressand broker details inbindings.kafka.operationsdescribe what your application does on a channel —send(publish) orreceive(consume) — and reference the channel by$ref.messagesdescribe the payload and Kafka key, ideally as reusable components.
The 3.0 split matters: in AsyncAPI 2.x a channel mixed publish/subscribe verbs whose meaning depended on perspective, which made shared documents ambiguous. In 3.0 the operation lives outside the channel and uses an unambiguous action. The rest of this guide builds one topic end to end.
Step-by-Step Solution
1. Install the AsyncAPI CLI
Pin to the AsyncAPI CLI so validation matches the 3.0 spec.
npm install -g @asyncapi/cli
asyncapi --version
Expected output (version will vary; any 2.x line of the CLI supports spec 3.0.0):
@asyncapi/cli/2.16.0 linux-x64 node-v20.11.1
2. Declare the document and the channel
The channel address is the literal Kafka topic name. Reference messages by $ref so the payload definition stays in components.
asyncapi: 3.0.0
info:
title: Orders Service
version: 1.0.0
servers:
production:
host: kafka.internal:9092
protocol: kafka
channels:
ordersCreated:
address: orders.created
messages:
OrderCreated:
$ref: '#/components/messages/OrderCreated'
3. Add the Kafka channel binding
The bindings.kafka block on the channel documents broker topology — partition count, replication, and an explicit topic if the address is a logical alias. Pin bindingVersion so tooling resolves the right binding schema.
bindings:
kafka:
topic: orders.created
partitions: 3
replicas: 3
bindingVersion: '0.5.0'
Validate after each addition (see step 6). A missing bindingVersion is the most common lint warning here.
4. Define the operation
Operations are top-level in 3.0. A producer document uses action: send and points at the channel with a runtime $ref.
operations:
publishOrderCreated:
action: send
channel:
$ref: '#/channels/ordersCreated'
messages:
- $ref: '#/channels/ordersCreated/messages/OrderCreated'
Use action: receive instead in a consumer’s document. Keep producer and consumer documents separate when they own different operations.
5. Set the message and key bindings
The Kafka key decides partition assignment and per-key ordering, so document it explicitly. The bindings.kafka.key block on the message holds a JSON Schema for the key. Pull the payload schema from your shared components — the same single-source approach described in defining JSON Schema components.
components:
messages:
OrderCreated:
name: OrderCreated
title: Order created event
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
bindings:
kafka:
key:
type: string
description: Order ID; drives partitioning and ordering.
bindingVersion: '0.5.0'
6. Validate the document
asyncapi validate asyncapi.yaml
Expected output on a clean document:
File asyncapi.yaml is valid! File asyncapi.yaml and referenced documents don't have governance problems.
Complete Working Example
A single self-contained asyncapi.yaml modeling the orders.created topic: channel with Kafka binding, a send operation, and a reusable message with payload and key schemas.
asyncapi: 3.0.0
info:
title: Orders Service
version: 1.0.0
description: Publishes order lifecycle events to Kafka.
servers:
production:
host: kafka.internal:9092
protocol: kafka
description: Production Kafka cluster.
channels:
ordersCreated:
address: orders.created
title: Order created channel
description: Emitted once per accepted order.
messages:
OrderCreated:
$ref: '#/components/messages/OrderCreated'
bindings:
kafka:
topic: orders.created
partitions: 3
replicas: 3
bindingVersion: '0.5.0'
operations:
publishOrderCreated:
action: send
summary: Publish an order created event.
channel:
$ref: '#/channels/ordersCreated'
messages:
- $ref: '#/channels/ordersCreated/messages/OrderCreated'
components:
messages:
OrderCreated:
name: OrderCreated
title: Order created event
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
bindings:
kafka:
key:
type: string
description: Order ID; drives partitioning and ordering.
bindingVersion: '0.5.0'
schemas:
OrderCreatedPayload:
type: object
required: [orderId, customerId, amount, currency, createdAt]
properties:
orderId:
type: string
format: uuid
customerId:
type: string
format: uuid
amount:
type: integer
description: Amount in minor units (cents).
minimum: 0
currency:
type: string
pattern: '^[A-Z]{3}$'
createdAt:
type: string
format: date-time
Save it and run asyncapi validate asyncapi.yaml to confirm it passes before committing.
Gotchas & Edge Cases
Compacted topics need a non-null key. On a log-compacted topic, the key is the compaction identity. Mark the key schema as required in your team’s review checklist; a null key on a compacted topic silently breaks compaction.
partitions is documentation, not provisioning. The bindings.kafka.partitions value records the intended topology so consumers can reason about ordering. It does not create or alter the topic — Kafka topic creation stays with your infrastructure tooling. Keep the number in sync with the real topic or the contract lies.
Avoid Kafka address templating with parameters. Channel parameters and {placeholder} addresses suit subject-based brokers like MQTT or NATS. Kafka topic names are flat strings, so prefer one explicit channel per topic; dynamic address templating tends to confuse Kafka-specific code generators.
The bindings block is where the broker-specific truth lives. A channel address names the topic; everything a consumer needs to reason about ordering, throughput, and durability sits in the Kafka bindings beside it. Omitting them produces a document that describes what the messages look like and nothing about how they behave, which is only half of what a consumer needs to build correctly.
The key determines what ordering means. Kafka orders messages within a partition, and the key decides which partition a message lands in — so declaring it tells a consumer precisely which events are ordered relative to each other and which are not. A consumer without that information will assume global ordering, build accordingly, and be wrong in a way that only appears under load.
Use send and receive from your own application’s perspective. The 3.0 action describes what the document’s application does, not what the broker does, so a producer’s document says send for a topic that a consumer’s document describes with receive. Both documents are correct and describe the same channel — which is precisely the ambiguity that the 2.x publish/subscribe verbs could not express.
FAQ
Should the channel address be the Kafka topic name?
Yes. In AsyncAPI 3.0 the channel address holds the literal topic string the broker uses. The bindings.kafka.topic field is optional and only needed when the address is a logical name that differs from the physical topic.
How do I document the Kafka message key in AsyncAPI?
Add a bindings.kafka.key block on the message object with a JSON Schema describing the key. This tells consumers which field drives partition assignment and ordering guarantees.
Do I use send or receive for a producer in AsyncAPI 3.0?
Use send for an application that publishes to the topic and receive for one that consumes from it. Operations describe the application’s own behavior, not the broker’s, so a producer’s document uses send.
Related
- AsyncAPI event-driven patterns — parent guide for channels, operations, and bindings.
- OpenAPI & AsyncAPI Schema Authoring — the full contract-authoring guide.
- AsyncAPI vs OpenAPI for event-driven architectures — when to pick each spec.
- Correlation IDs for event tracing — trace Kafka events across services.
- Defining JSON Schema components — reuse payload schemas across messages.
Keep the document describing one application rather than the whole estate. A file that tries to describe every producer and consumer of a topic becomes ambiguous about whose behaviour it documents, which is exactly the problem the 3.0 operation model solves. One document per application, each referencing shared message components, gives every team a contract that is unambiguously about them.
Validation belongs in the same pipeline as the rest of the contract. An AsyncAPI document that is never linted drifts exactly as an OpenAPI one does, and the drift is harder to notice because there is no rendered page anybody visits daily. Running the CLI validation on every change costs seconds and is the only thing standing between the document and quiet decay.
Wire it into the same job that lints the OpenAPI documents, so both contracts are held to one standard by one pipeline.
One pipeline, one standard, and one place to look when a contract check fails is worth considerably more than the small effort of adding a second validation step to an existing job.