Skip to main content

AsyncAPI 3.0 for Event-Driven Systems

Event-driven systems fail quietly: a producer renames a field, every consumer keeps running, and the breakage only surfaces hours later as malformed records in a dead-letter queue. AsyncAPI 3.0 closes that gap by making the message contract — channels, operations, message payloads, and protocol bindings — an explicit, version-controlled artifact you can validate and generate code from. This guide extends API Contract Fundamentals & Tool Selection, applying the same design-validate-gate discipline that the OpenAPI Specification Deep Dive brings to synchronous HTTP, but for asynchronous messaging over Kafka and AMQP.

AsyncAPI 3.0 is a significant redesign of the 2.x line. Operations are now first-class and decoupled from channels, the confusing publish/subscribe verbs are gone, and messages are reusable across channels. If you maintain an existing 2.x document, work through the AsyncAPI 2 to 3 migration checklist before adopting the patterns below.

When to Use This Approach

Reach for AsyncAPI when your integration is asynchronous and message-shaped rather than request/response. Concretely, define an AsyncAPI 3.0 contract when:

  • Services communicate over a broker — Apache Kafka, RabbitMQ/AMQP 0-9-1, MQTT, NATS, or WebSocket — instead of direct HTTP calls.
  • Multiple independent consumers depend on the shape of an event, and you need a single source of truth they can all validate against.
  • You want generated, typed models (TypeScript, Java) for producers and consumers so the payload schema and the code never drift.
  • A schema change must be caught in CI before it reaches a topic that production consumers read.
  • You publish documentation for an event catalog and want it rendered from the same contract that gates the build.

If your interface is a synchronous HTTP API, use OpenAPI instead — see how to choose between OpenAPI and AsyncAPI for microservices for the decision boundary. Many systems need both: OpenAPI for the edge, AsyncAPI for the event backbone.

Prerequisites

You need Node.js 18+ and the official AsyncAPI CLI, which bundles validation, the generator, and diff tooling.

# Install the AsyncAPI CLI (3.x line) globally
npm install -g @asyncapi/cli

# Confirm the version — this guide targets AsyncAPI spec 3.0.0
asyncapi --version

# Scaffold a new document interactively (choose "from scratch")
asyncapi new --file-name asyncapi.yaml

For linting and governance rules, add the Spectral CLI, which ships an asyncapi:recommended ruleset:

npm install -D @stoplight/spectral-cli@6.11

Commit asyncapi.yaml to the repository that owns the producing service. Treat it like source code: every change goes through review and CI.

AsyncAPI message flow: publisher to broker channel to subscriber A producer application with a send operation emits an OrderCreated message into a Kafka topic channel hosted on a broker server. A consumer application with a receive operation reads from the same channel. A payload schema defined in components governs the message shape on both ends. Producer order-service operation: send Kafka Broker channel orders.created OrderCreated message Consumer billing-service operation: receive payload schema components.schemas.OrderCreated One contract governs both producer and consumer

1. Establish the Document Root: info, id, and defaultContentType

Start with the top-level metadata. The asyncapi field pins the spec version — this must be 3.0.0 to use the operation-centric model. The id is a globally unique URN that other documents can reference, and defaultContentType sets the encoding for every message unless overridden.

# asyncapi.yaml
asyncapi: 3.0.0                       # spec version — 3.0 is operation-centric
id: urn:com:acme:order-service       # stable URN identity for this application
info:
  title: Order Service Events
  version: 1.2.0                      # bump on every contract change
  description: Events emitted and consumed by the order domain.
defaultContentType: application/json # applies to all messages unless overridden

Version the info.version field deliberately. A backward-compatible addition (a new optional field, a new channel) is a minor bump; removing a field, tightening a type, or renaming a channel is a major bump. CI will enforce this in step 6.

2. Declare servers and protocol bindings

servers describe where messages physically flow. Each entry names a host, a protocol, and optional bindings carrying protocol-specific configuration. The same document can list a Kafka server and an AMQP server; operations later point at the channels these servers host. For a deeper treatment of topic-level metadata, see documenting Kafka topics with AsyncAPI.

servers:
  production-kafka:
    host: kafka.acme.internal:9092
    protocol: kafka                  # also: kafka-secure for TLS/SASL
    description: Primary Kafka cluster
    bindings:
      kafka:
        schemaRegistryUrl: https://registry.acme.internal
        schemaRegistryVendor: confluent
  audit-amqp:
    host: rabbit.acme.internal:5672
    protocol: amqp                   # AMQP 0-9-1 (RabbitMQ)
    bindings:
      amqp:
        exchange:
          name: audit
          type: topic                # routing-key based fan-out
          durable: true

Bindings are optional but valuable: the Kafka binding records the schema registry, and the AMQP binding declares exchange type and durability. Validators check binding structure, and the generator can emit broker-aware client code from them.

3. Model payload schemas as reusable components

Define the data shape once under components.schemas and reference it everywhere. AsyncAPI uses JSON Schema for payloads, so the same modeling rules you apply elsewhere carry over — including how you structure deeply nested objects and shared definitions. Keeping schemas in components lets multiple messages and channels share them through $ref.

components:
  schemas:
    OrderCreatedPayload:
      type: object
      required: [orderId, customerId, total, occurredAt]
      additionalProperties: false    # reject unknown fields — fail loud
      properties:
        orderId:
          type: string
          format: uuid
        customerId:
          type: string
          format: uuid
        total:
          type: number
          minimum: 0
        currency:
          type: string
          enum: [USD, EUR, GBP]       # removing an enum value is breaking
        occurredAt:
          type: string
          format: date-time

Set additionalProperties: false on event payloads. Events are append-only facts; a consumer that silently ignores unknown fields hides producer mistakes until they matter. Mark every field a consumer relies on as required.

4. Define channels and the messages they carry

A channel is the addressable medium — a Kafka topic or AMQP routing key. Under messages, attach the message definitions the channel transports, wiring each payload via $ref. Channels no longer carry publish/subscribe; they only describe the medium and its messages.

channels:
  orderCreated:
    address: orders.created          # the actual Kafka topic name
    description: Emitted when an order is confirmed.
    servers:
      - $ref: '#/servers/production-kafka'
    messages:
      orderCreated:
        name: OrderCreated
        title: Order Created
        contentType: application/json
        payload:
          $ref: '#/components/schemas/OrderCreatedPayload'
        headers:                     # broker headers, e.g. for tracing
          type: object
          properties:
            correlationId:
              type: string
              format: uuid

The address is the broker-level identifier (topic or routing key); the channel key (orderCreated) is the internal reference name. Define headers explicitly when consumers depend on metadata such as correlationId for distributed tracing — undeclared headers are invisible to generated code and documentation.

5. Declare operations with send and receive

This is the heart of the 3.0 model. An operation states what this application does: action: send means it publishes to the channel, action: receive means it consumes. Operations reference a channel and, optionally, the specific messages involved. From the producer’s document:

operations:
  publishOrderCreated:
    action: send                     # this app publishes to the channel
    channel:
      $ref: '#/channels/orderCreated'
    messages:
      - $ref: '#/channels/orderCreated/messages/orderCreated'
    description: Publish an OrderCreated event after checkout.

The consuming billing-service keeps its own document with the mirror operation:

operations:
  onOrderCreated:
    action: receive                  # this app consumes from the channel
    channel:
      $ref: '#/channels/orderCreated'
    messages:
      - $ref: '#/channels/orderCreated/messages/orderCreated'

Because the action is defined from the application’s own perspective, there is no ambiguity about direction — the chronic source of confusion in AsyncAPI 2.x. For request/reply flows, add a reply object to the operation pointing at the response channel.

6. Validate and lint in CI

Two checks gate every pull request. asyncapi validate confirms the document is structurally valid and conforms to the 3.0 spec. spectral lint applies the asyncapi:recommended ruleset plus any custom governance rules, mirroring the linting discipline used for contract testing across microservices.

# .spectral.yaml
extends: ["asyncapi:recommended"]
rules:
  asyncapi-operation-operationId: error   # every operation must be identifiable
  asyncapi-info-contact: warn

Wire both into a workflow, and add asyncapi diff to flag breaking changes against the base branch:

# .github/workflows/asyncapi-gate.yml
name: AsyncAPI Contract Gate
on: [pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }            # need history for diff
      - run: npm install -g @asyncapi/cli @stoplight/spectral-cli
      - name: Validate spec conformance
        run: asyncapi validate asyncapi.yaml
      - name: Lint governance rules
        run: spectral lint asyncapi.yaml --ruleset .spectral.yaml
      - name: Detect breaking changes
        run: |
          git show origin/main:asyncapi.yaml > /tmp/base.yaml
          asyncapi diff /tmp/base.yaml asyncapi.yaml --type=breaking

asyncapi diff --type=breaking exits non-zero when it finds a breaking change (a removed required field, a narrowed type, a deleted channel), forcing a major version bump before the merge can proceed.

7. Generate typed models and documentation

The contract pays off when it drives code. asyncapi generate models invokes Modelina to produce typed model classes that stay in lockstep with your payload schemas, and asyncapi generate fromTemplate renders documentation or application skeletons.

# Generate TypeScript models from every payload schema
asyncapi generate models typescript asyncapi.yaml -o ./src/generated

# Render a static HTML doc site for the event catalog
asyncapi generate fromTemplate asyncapi.yaml @asyncapi/html-template -o ./docs

Run model generation in CI and fail the build if ./src/generated has uncommitted changes — that guarantees the checked-in types match the contract. Producers serialize the generated OrderCreated model; consumers deserialize into it; neither hand-writes the shape.

Spec/Schema Reference

Field Type Default Effect
asyncapi string — (required) Spec version; must be 3.0.0 for the operation-centric model.
id string (URN) none Stable identity other documents reference.
defaultContentType string none Encoding applied to messages without an explicit contentType.
servers.<id>.protocol string — (required) Transport: kafka, kafka-secure, amqp, mqtt, ws, nats.
servers.<id>.bindings object none Protocol-specific config (schema registry, exchange type, durability).
channels.<id>.address string none Broker-level topic name or routing key.
channels.<id>.messages map none Message definitions the channel carries.
operations.<id>.action string — (required) send (this app publishes) or receive (this app consumes).
operations.<id>.channel $ref — (required) The channel the operation targets.
operations.<id>.reply object none Response channel/message for request-reply flows.
payload.additionalProperties boolean true Set false to reject unknown fields in event payloads.
message.headers schema none JSON Schema for broker headers (e.g. correlationId).

Verification

A passing pipeline produces a clear, layered signal. Local verification before pushing:

$ asyncapi validate asyncapi.yaml
File asyncapi.yaml is valid! File asyncapi.yaml and referenced documents don't have governance problems.

$ spectral lint asyncapi.yaml --ruleset .spectral.yaml
No results with a severity of 'error' found!

$ asyncapi diff /tmp/base.yaml asyncapi.yaml --type=breaking
No breaking changes detected.

In CI, the job turns green only when validation passes, Spectral reports zero errors, the breaking-change diff is clean (or the version was bumped), and generated models are byte-identical to the committed ones. Any non-zero exit blocks the merge — the contract gate is doing its job.

Troubleshooting

asyncapi validate reports “should NOT have additional properties: publish”

Root cause: the document still uses the AsyncAPI 2.x channel structure, where publish/subscribe lived inside the channel. In 3.0 those move to top-level operations with action: send/receive. Fix: restructure per step 5, or run the AsyncAPI 2 to 3 migration checklist to convert systematically.

spectral lint fails with “Unknown format asyncapi” or no AsyncAPI rules apply

Root cause: an old Spectral version that predates AsyncAPI 3.0 support, or a ruleset missing the extends line. Fix: pin @stoplight/spectral-cli@6.11 or later and ensure .spectral.yaml starts with extends: ["asyncapi:recommended"].

$ref resolves to nothing / “Could not resolve reference”

Root cause: a pointer path that does not match the document — a common slip is #/components/messages/... when the message is defined inline under a channel at #/channels/<id>/messages/<id>. Fix: reference messages at their actual location, and prefer defining shared messages under components.messages so every $ref points to one canonical path.

asyncapi diff exits non-zero on a pull request

Root cause: the change removed a required field, narrowed a type, deleted an enum value, or removed a channel — all breaking for existing consumers. Fix: bump info.version to the next major, or redesign the change to be additive (new optional field, new channel) so consumers keep working.

Generated models drift from the committed files in CI

Root cause: a payload schema changed but asyncapi generate models was not re-run, so the checked-in types are stale. Fix: regenerate locally, commit the output, and keep the “fail on uncommitted generated changes” step in CI so the drift can never reach main.

Frequently Asked Questions

What is the difference between a channel and an operation in AsyncAPI 3.0?

A channel models the medium a message travels through — a Kafka topic, an AMQP queue, a WebSocket path. An operation declares what your application does with that channel (send or receive) and references the channel and the messages involved. AsyncAPI 3.0 split these apart; in 2.x the publish/subscribe verbs lived inside the channel and meant the opposite of what most people expected.

Does send mean my application publishes the message?

Yes. In AsyncAPI 3.0, action: send means the application that owns the document transmits the message to the channel, and action: receive means it consumes from the channel. This is application-centric and unambiguous, unlike the 2.x publish/subscribe verbs that were defined from the broker’s point of view.

Can one AsyncAPI document describe both Kafka and AMQP?

Yes. Each entry under servers carries its own protocol and bindings, and an operation can target a channel bound to any server. You can describe a Kafka producer and an AMQP consumer in the same document, though most teams keep one document per service for clearer ownership.

How do I validate an AsyncAPI document in CI?

Run asyncapi validate asyncapi.yaml for structural and spec-conformance checks, then layer spectral lint with the asyncapi:recommended ruleset for style and governance rules. Both exit non-zero on failure, so they gate a pull request directly.

What does the AsyncAPI generator actually produce?

It renders your document through a template into code or docs — TypeScript or Java model classes, a documented HTML site, or a runnable client/application skeleton. Models come from Modelina via the generator and stay in sync with your payload schemas whenever you regenerate.

Should I use AsyncAPI or OpenAPI for a request-reply API?

Use OpenAPI for synchronous HTTP request/response APIs. Use AsyncAPI for asynchronous, message-driven flows over brokers like Kafka or AMQP — including the reply pattern, which AsyncAPI 3.0 models natively with a reply object on the operation. See the comparison in how to choose between OpenAPI and AsyncAPI for microservices.