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.
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.
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.
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.
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.
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.