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.

The 3.0 split, and why it matters

AsyncAPI 3.0 separates three things that earlier versions blurred, and understanding the separation removes most of the confusion people bring from 2.x. A channel describes the medium — for Kafka, one channel per topic, carrying the address and the broker bindings. An operation describes what your application does on that channel, send or receive, and lives outside the channel referencing it. A message describes the payload and any protocol-specific key.

Channels, operations and messages A channel describes the medium, an operation describes what this application does on it, and a message describes the payload, each defined independently. channel the medium address + bindings the topic itself operation send or receive references a channel this application's behaviour message payload + key reusable component what travels

The reason the split matters is that publish and subscribe in 2.x meant opposite things depending on whose document you were reading — a producer’s publish and a consumer’s view of the same channel were the same word for different perspectives. Moving the action out of the channel and naming it from the application’s own point of view removes that ambiguity entirely, which is what makes a 3.0 document shareable between the teams on either side of a topic.

What a document has to say that an HTTP one does not

Event-driven contracts carry operational information that request/response specifications never need, and omitting it is the most common way an AsyncAPI document ends up technically valid and practically unusable.

Beyond the payload An event contract needs the partition key, ordering and delivery guarantees, and correlation information in addition to the payload schema. a consumer cannot build correctly without all four payload schema what the bytes mean the only part HTTP needs too partition key what groups messages decides ordering scope delivery semantics at-least-once? ordered? duplicates are certain correlation tracing across hops otherwise untraceable

The partition key is the one most often left out and the one with the largest consequence. It determines which messages are ordered relative to each other, so a consumer that does not know it cannot reason about whether two events can arrive out of order — and will assume they cannot, which is wrong. Declaring it in the message bindings turns an implicit broker detail into part of the contract.

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.

Event design outlives any broker

The decisions that turn out to matter over years are about the events themselves rather than the transport. A message that carries a full snapshot of the changed entity is easy to consume and expensive on the wire; one that carries only identifiers is cheap and forces every consumer into a follow-up call, which turns your event stream into a load generator for your own API. Neither is universally right, but the choice should be deliberate and consistent, because consumers build very different systems depending on which one they find.

Naming is similarly durable. An event named for what happened — InvoicePaid — stays accurate regardless of how the system that emits it is restructured. One named for the mechanism, or for the service that currently produces it, becomes misleading the first time that changes. Since event names appear in generated code, in dashboards, and in every consumer’s routing logic, renaming one is a coordinated migration rather than an edit.

Versioning deserves settling early too. Events are consumed by systems you do not control and cannot upgrade in step, so the compatible-evolution rules that apply to a request/response API apply with more force here: adding an optional field is safe, changing the meaning of an existing one is not, and removing anything requires the same deprecation discipline as retiring an endpoint. The difference is that a consumer of an event usually has no way to signal that they were broken — they simply start behaving incorrectly — which makes the contract discipline more important rather than less.

Finally, decide what a consumer should do with an event it does not recognise. Streams gain event types over time, and a consumer that treats an unknown type as an error will fail the moment you add one. Stating that unknown types must be ignored makes adding an event type an additive change; leaving it unstated makes every addition a coordination exercise.

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.

All four of these — payload granularity, naming, versioning, and unknown-type handling — are cheap to decide before the first consumer exists and progressively harder afterwards, because each one becomes a property that other teams’ systems have already been built around. An afternoon spent settling them at the start is the highest-return work available in an event-driven contract.

Broker choice, by contrast, is comparatively easy to revisit: bindings change, the channel definitions largely do not, and consumers care about the events rather than the transport carrying them.

Consumers integrate against the events; the broker is an implementation detail they should rarely need to think about.

Documenting the events well is also what makes the estate legible to people who did not build it. An engineer joining a team can read a channel definition and know what flows through it, who sends it, and what a consumer is expected to do — which is not true of a topic that exists only in configuration and tribal knowledge.

That legibility compounds: every well-documented channel is one fewer conversation the owning team has to have with every consumer that comes after.