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.
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.
Related
- OpenAPI & AsyncAPI Schema Authoring — the parent overview for contract-first authoring.
- AsyncAPI vs OpenAPI for event-driven architectures — when to reach for each spec.
- Modeling Kafka topics in AsyncAPI — partitions, keys, and bindings in depth.
- Correlation IDs for event tracing — wiring distributed tracing through messages.
- Defining JSON Schema components — share payload schemas across protocols.