Implementing AsyncAPI 3.0 Event-Driven Patterns

This guide is part of OpenAPI & AsyncAPI Schema Authoring, and it focuses on the asynchronous half of contract-first design: documenting publish/subscribe, request/reply, and streaming systems with AsyncAPI 3.0. Where synchronous REST endpoints lean on structuring OpenAPI paths, event-driven systems need explicit channel definitions, operation roles, and protocol-specific bindings so that a Kafka topic, RabbitMQ exchange, or NATS subject becomes a first-class, machine-readable contract.

AsyncAPI 3.0 introduced the most significant restructuring in the spec’s history. It split channels (the medium that carries messages) from operations (what an application does on that medium), added a dedicated address field, and reworked request/reply with an explicit reply object. If you are coming from 2.x, treat the migration as a redesign rather than a rename. This page walks through the prerequisites, the core channel-and-operation model, a CI workflow that blocks breaking changes, advanced patterns for replies and traits, and the troubleshooting you will actually hit.

AsyncAPI 3.0 channels versus operations A producer sends to a channel bound to a Kafka topic, and a consumer receives from the same channel, with operations referencing the channel. Producer action: send channel address: user-events.created Consumer action: receive kafka binding Operations reference one channel; the channel binds to the broker

Prerequisites & Environment Setup

Before authoring, pin your tooling so CI and local runs agree. The AsyncAPI ecosystem moves quickly, and unpinned CLIs are the most common source of “works on my machine” lint differences.

# Node 20 LTS or later is required by the modern AsyncAPI CLI
node --version    # v20.x

# Install the core CLI and the Spectral linter, pinned
npm install --save-dev @asyncapi/cli @stoplight/[email protected]

# Confirm the CLI resolves the 3.0 parser
npx @asyncapi/cli --version

Create a predictable layout. Keep the spec at the repository root, split reusable message and schema fragments under components/, and store organizational lint rules in .spectral.yaml:

asyncapi.yaml
components/
  messages/
    UserCreated.yaml
  schemas/
    User.yaml
.spectral.yaml
.github/workflows/asyncapi-validate.yml

Payload schemas should not live only inside the AsyncAPI file. Author them as reusable JSON Schema fragments following defining JSON Schema components so the same User shape can be referenced by both your AsyncAPI channels and your REST contract. That single-source-of-truth discipline is what makes generated SDKs and portal docs stay consistent across protocols.

Core Configuration

The heart of an AsyncAPI 3.0 document is the channel-to-operation mapping. Map each broker destination to exactly one channel, give it an explicit address, attach its messages, and document the broker binding. Operations then point at the channel and declare their action.

asyncapi: 3.0.0
info:
  title: User Event Service
  version: 1.2.0
  description: Publishes lifecycle events for user accounts.

servers:
  production:
    host: "${KAFKA_BROKER_HOST}"   # injected at build time, never hardcoded
    protocol: kafka
    security:
      - $ref: '#/components/securitySchemes/saslScram'

channels:
  userCreated:
    address: 'user-events.created'  # the real topic name on the broker
    messages:
      UserCreated:
        $ref: '#/components/messages/UserCreated'
    bindings:
      kafka:
        bindingVersion: '0.5.0'
        topic: user-events.created
        partitions: 3
        replicas: 3                 # match production replication, not a demo value

operations:
  publishUserCreated:
    action: send                    # this application produces to the channel
    channel:
      $ref: '#/channels/userCreated'
    messages:
      - $ref: '#/channels/userCreated/messages/UserCreated'

components:
  messages:
    UserCreated:
      name: UserCreated
      title: User Created
      correlationId:
        location: '$message.header#/correlationId'  # required for tracing
      payload:
        $ref: '#/components/schemas/User'
  schemas:
    User:
      type: object
      required: [userId, timestamp]
      properties:
        userId:
          type: string
          format: uuid
        timestamp:
          type: string
          format: date-time
  securitySchemes:
    saslScram:
      type: scramSha512

A few decisions in that file carry most of the weight. Keep the channel key (userCreated) distinct from the address (user-events.created): the key is a stable internal identifier you reference from operations, while the address is the broker-facing destination that may differ per environment. Always define correlationId for any message that participates in request/reply or distributed tracing — without it, consumers cannot stitch an event back to its originating request. Finally, declare bindings with a bindingVersion so the spec is unambiguous about which binding schema applies; partition and replica counts documented here become the contract operators provision against. Detailed topic modeling lives in modeling Kafka topics in AsyncAPI.

Integration Pattern

Validate every change on pull request. The workflow below validates syntax, enforces your Spectral ruleset, and — critically — diffs the spec against the previous commit to surface breaking changes before they reach consumers.

# .github/workflows/asyncapi-validate.yml
name: AsyncAPI Contract Validation
on:
  pull_request:
    paths: ['asyncapi.yaml', 'components/**', '.spectral.yaml']
jobs:
  validate-spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2          # need HEAD~1 for the diff step

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Validate AsyncAPI document
        run: npx @asyncapi/cli validate ./asyncapi.yaml

      - name: Lint with Spectral
        run: >
          npx @stoplight/spectral-cli lint ./asyncapi.yaml
          --ruleset .spectral.yaml --fail-severity error

      - name: Detect breaking changes against base
        run: |
          git show "${{ github.event.pull_request.base.sha }}:asyncapi.yaml" \
            > /tmp/asyncapi-base.yaml || { echo "No base spec; skipping diff"; exit 0; }
          npx @asyncapi/cli diff /tmp/asyncapi-base.yaml ./asyncapi.yaml \
            --format json --markdown-subtype error

The validate step catches structural and syntax errors against the 3.0 schema. The Spectral step enforces house rules — naming conventions, mandatory correlationId, required descriptions — that the base spec alone cannot. The diff step compares the PR head to the merge base rather than blindly to HEAD~1, which keeps it correct on multi-commit branches. Pair this validation gate with the SDK and portal generation step described next, run on tagged releases rather than on every PR, so consumers always have docs that match a published version.

Advanced Options

Request/reply with the reply object

AsyncAPI 3.0 models request/reply natively. An operation declares a reply block pointing at the channel where the response arrives, and the address of the reply can be dynamic, taken from a message header so each requester gets its own response destination.

operations:
  requestUserProfile:
    action: send
    channel:
      $ref: '#/channels/userProfileRequests'
    reply:
      channel:
        $ref: '#/channels/userProfileReplies'
      address:
        location: '$message.header#/replyTo'  # dynamic per-request reply queue

Correlation is non-negotiable here: the requester sets correlationId on the outbound message, and the responder echoes it so the requester can match the reply to its pending call. The full tracing story, including propagation across services, is covered in correlation IDs for event tracing.

Operation and message traits

Repeated header sets — a tenant ID, a schema version, a trace context — belong in reusable traits rather than copy-pasted into every message. Define them once under components and apply them with traits:

components:
  messageTraits:
    commonHeaders:
      headers:
        type: object
        required: [traceId, schemaVersion]
        properties:
          traceId:
            type: string
          schemaVersion:
            type: string
            example: '1.2.0'
  messages:
    UserCreated:
      traits:
        - $ref: '#/components/messageTraits/commonHeaders'
      payload:
        $ref: '#/components/schemas/User'

Traits keep the contract DRY and make org-wide header policy enforceable in one place — a Spectral rule can then assert that every message references commonHeaders.

Build-time server injection

Never commit environment-specific broker hosts. Keep ${KAFKA_BROKER_HOST} style placeholders in the spec and substitute real values during the build before handing the document to a generator:

KAFKA_BROKER_HOST=broker.prod.internal:9092 \
  envsubst < asyncapi.yaml > dist/asyncapi.prod.yaml
npx @asyncapi/cli generate fromTemplate dist/asyncapi.prod.yaml \
  @asyncapi/html-template --output ./docs/async

This keeps the source spec portable across environments and avoids leaking infrastructure topology into version control.

Verification & Testing

Run the same checks locally that CI runs, plus a generation smoke test, before opening a PR.

# 1. Structural validation against the 3.0 schema
npx @asyncapi/cli validate ./asyncapi.yaml

# 2. House rules
npx @stoplight/spectral-cli lint ./asyncapi.yaml \
  --ruleset .spectral.yaml --fail-severity error

# 3. Confirm docs actually generate (catches template-breaking changes)
npx @asyncapi/cli generate fromTemplate ./asyncapi.yaml \
  @asyncapi/html-template --output /tmp/async-docs

# 4. Confirm TypeScript types generate from message schemas
npx @asyncapi/cli generate fromTemplate ./asyncapi.yaml \
  @asyncapi/ts-nats-template --output /tmp/async-sdk

A passing validate confirms the document conforms to AsyncAPI 3.0. A clean Spectral run confirms it meets organizational policy. The two generation steps confirm the spec is not just valid but usable — a document can pass validation yet still break a template because of a missing name or an unresolved $ref. Treat a failed generation as a failed build. For payloads with heavy sample data, externalize examples per example payload management so generation stays fast and diffs stay readable.

Troubleshooting

Channel object must have an "address" validation error

In AsyncAPI 3.0 the channel name and the broker address are separate concepts. A 2.x document used the channel key as the address, so converted files often omit the explicit address. Add address: 'your.topic.name' to each channel, and set address: null only for channels whose address is genuinely unknown at design time.

reference $ref "#/channels/x/messages/y" cannot be resolved

Operation-level message references must point at a message inside the channel, not at components/messages directly. Reference the channel’s message map — #/channels/userCreated/messages/UserCreated — and let the channel itself $ref the component. Pointing an operation straight at #/components/messages/... is the single most common 3.0 migration error.

Spectral passes but generate fromTemplate fails with Cannot read properties of undefined

Templates require fields the core schema treats as optional, most often message.name or message.payload. Add name to every message and ensure each payload either inlines a schema or resolves through $ref. Run a generation step in CI so this surfaces on the PR rather than at release time.

diff reports a breaking change for a field you only added

The AsyncAPI diff classifies a newly required field as breaking even when the field itself is new, because existing producers will not emit it. Add new fields as optional first, ship, then promote to required in a later major version. Use the --markdown-subtype error flag to fail CI only on genuinely breaking categories.

FAQ

How do I migrate AsyncAPI 2.x specs to 3.0?

Run npx @asyncapi/cli convert ./asyncapi-v2.yaml --output asyncapi-v3.yaml to handle the channels and operations split plus the new address field. Always review the output by hand, because custom x- extensions and reply objects often need adjustment.

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

A channel describes the communication medium, such as a Kafka topic or a NATS subject, and the messages it carries. An operation describes what an application does on that channel using send or receive, which decouples the message route from the application’s role.

Can AsyncAPI automatically generate consumer SDKs for multiple languages?

Yes, the AsyncAPI Generator ships templates for TypeScript, Python, Java, and Go that produce type-safe clients and message parsers from the validated spec. You run them through @asyncapi/cli generate fromTemplate against the same source of truth your portal uses.

How should I document HTTP webhooks alongside event streams?

Use OpenAPI 3.1’s native webhooks object for HTTP callbacks and keep a parallel AsyncAPI spec for broker-based streams. Share payload schemas through a standalone JSON Schema Draft 2020-12 file that both documents reference with $ref.