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.

Kafka topic to AsyncAPI 3.0 object mapping A Kafka topic with partitions maps to an AsyncAPI channel; a send operation references the channel; a message defines payload and key schemas. Kafka topic orders.created 3 partitions channel address + bindings operation send → channel $ref message payload + key

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:

  • channels describe the medium — for Kafka, one channel per topic, with the topic name in address and broker details in bindings.kafka.
  • operations describe what your application does on a channel — send (publish) or receive (consume) — and reference the channel by $ref.
  • messages describe 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.

What the Kafka bindings add beyond the payload The bindings declare the topic, partition count and message key, which together determine ordering scope and consumer parallelism. the key is the field consumers most need and most often lack topic and replicas physical topology where the data lives operational context partitions parallelism how many consumers help capacity planning message key ordering scope what is ordered relative to what the critical one

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.

How the message key scopes ordering guarantees Messages sharing a key land in the same partition and are ordered, while messages with different keys carry no ordering guarantee between them. ordering is per key, never global same key same partition ordered relative to each other safe to sequence different keys different partitions no ordering guarantee sequencing is a bug no key round-robin placement no ordering at all document this explicitly

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.

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.