AsyncAPI 2 to 3 Migration Checklist
When you feed a 2.x AsyncAPI document to a 3.0-aware tool — the updated AsyncAPI Studio, asyncapi validate from @asyncapi/cli 2.x, or a Spectral 6.11 linter with the asyncapi:recommended ruleset — the tool fails with a cascade of errors: should NOT have additional properties: publish, must have required property 'action', channels/<id> should NOT have additional properties. The document is structurally valid for 2.x but completely wrong for 3.0, and bumping the version field alone makes things worse, not better. This guide is part of the AsyncAPI 3.0 for Event-Driven Systems reference and walks through every structural change required, with annotated before/after YAML and a terminal checklist you can run through in order.
Root Cause: A Redesigned Spec Structure
AsyncAPI 3.0 is not a minor revision. The committee resolved three years of practitioner complaints about the 2.x model with changes that are clean but breaking:
Operations moved out of channels. In 2.x, each channel contained publish and subscribe blocks that described who does what. In 3.0, channels only describe the medium and its messages. A new top-level operations block holds all the application-action declarations.
publish/subscribe replaced by action: send/receive. The 2.x verbs were defined from the broker’s perspective — publish meant something publishes to this channel, which from the application’s view meant the application consumed. This inverted semantics caused constant misunderstandings. In 3.0, action: send means this application sends a message and action: receive means this application receives a message. Unambiguous, application-centric.
Messages are reusable across channels. In 2.x, messages were typically defined inline under publish/subscribe. In 3.0 they live under channels.<id>.messages or, for shared definitions, under components.messages. Operations reference them via $ref.
Servers use host instead of a URL string. The 2.x server format embedded the host and path in a single string. In 3.0, host carries just the hostname and port (no scheme), protocol is a sibling field, and pathname is a separate optional field for path-based addressing.
Step 1: Bump the Version and Scope the Errors
Start by changing the version field and running validation without touching anything else. The error list becomes your work queue.
# Install the CLI that supports 3.0 (2.x line)
npm install -g @asyncapi/cli
# Edit asyncapi: 2.6.0 → asyncapi: 3.0.0, then:
asyncapi validate asyncapi.yaml
You will see output like:
/channels/orders~1created/publish is not allowed
/channels/orders~1created/subscribe is not allowed
/operations is required at document root
/servers/production missing required property 'host'
Every error class maps to a migration step below. Do not try to fix all errors simultaneously — work through them in order.
Step 2: Migrate Channels — Remove publish/subscribe, Keep Messages
In 2.x, channels were the container for everything. In 3.0 they describe only the address, the servers they belong to, and the messages they carry. Strip publish and subscribe entirely.
Before (AsyncAPI 2.x):
channels:
orders/created:
description: Order confirmation events
publish: # app publishes TO this channel
operationId: receiveOrderCreated
message:
name: OrderCreated
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
subscribe: # app subscribes FROM this channel
operationId: sendOrderCreated
message:
$ref: '#/components/messages/OrderCreated'
After (AsyncAPI 3.0):
channels:
orderCreated: # key is now a plain identifier, not a path
address: orders/created # the broker address moves here
description: Order confirmation events
servers:
- $ref: '#/servers/production-kafka'
messages:
orderCreated:
name: OrderCreated
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
Why this works: channels in 3.0 no longer express direction. The address field holds the broker-level topic or routing key (what used to be the channel key in 2.x). Direction moves entirely to operations.
Step 3: Create Top-Level Operations with action: send or receive
Extract every publish and subscribe block from channels into a top-level operations section. The key insight: in 2.x, publish from the channel’s perspective meant other things publish here (i.e., your app subscribes), and subscribe meant your app publishes. AsyncAPI 3.0 drops this inversion. Use the action from your application’s perspective directly.
Before (AsyncAPI 2.x — operation embedded in channel):
channels:
orders/created:
publish: # confusing: broker-centric, means app subscribes
operationId: receiveOrderCreated
message:
$ref: '#/components/messages/OrderCreated'
After (AsyncAPI 3.0 — operations at document root):
operations:
receiveOrderCreated:
action: receive # application-centric: this app consumes
channel:
$ref: '#/channels/orderCreated'
messages:
- $ref: '#/channels/orderCreated/messages/orderCreated'
description: Consume order confirmation events from the order domain.
If the same 2.x document had both publish and subscribe on a channel (a broker-facing document describing both sides), split them into two separate operations with appropriate actions. The producing service gets action: send; the consuming service gets action: receive. Most teams migrate to one document per service at this point, which is the recommended pattern for documenting Kafka topics with AsyncAPI.
Step 4: Update Server Entries to host/protocol Format
AsyncAPI 2.x servers used a name: { url, protocol } pattern where the URL included the scheme. AsyncAPI 3.0 uses host (hostname and port only, no scheme) as a top-level server field.
Before (AsyncAPI 2.x):
servers:
production:
url: kafka.acme.internal:9092
protocol: kafka
description: Primary Kafka cluster
security:
- saslScram: [] # 2.x: list of single-key objects
After (AsyncAPI 3.0):
servers:
production-kafka:
host: kafka.acme.internal:9092 # no scheme — just host:port
protocol: kafka
description: Primary Kafka cluster
security:
- type: scramSha256 # 3.0: objects with type and scopes
scopes: []
bindings:
kafka:
schemaRegistryUrl: https://registry.acme.internal
schemaRegistryVendor: confluent
Why this works: the scheme is already captured by protocol. Separating host from scheme removes the redundancy and makes multi-protocol descriptions unambiguous. The security format change aligns AsyncAPI with OpenAPI 3.1’s security scheme model.
Step 5: Move Shared Messages to components.messages
In 2.x, messages were often defined inline under publish/subscribe or in components.messages (supported but rarely used). In 3.0, components.messages is the idiomatic place for any message referenced by more than one operation or channel.
Before (AsyncAPI 2.x — inline message):
channels:
orders/created:
publish:
message:
name: OrderCreated
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
After (AsyncAPI 3.0 — shared via components):
components:
messages:
OrderCreated:
name: OrderCreated
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
headers:
type: object
properties:
correlationId:
type: string
format: uuid
channels:
orderCreated:
address: orders/created
messages:
orderCreated:
$ref: '#/components/messages/OrderCreated'
operations:
receiveOrderCreated:
action: receive
channel:
$ref: '#/channels/orderCreated'
messages:
- $ref: '#/channels/orderCreated/messages/orderCreated'
Why this works: a single $ref from both the channel and the operation point to one canonical message definition. When the payload schema changes, there is one place to update — the same design discipline that the schema-first vs code-first workflow applies to OpenAPI documents.
Using the asyncapi CLI convert Command
The CLI includes a convert subcommand that automates the mechanical parts of the migration. It is a useful starting point but not a complete solution.
# Convert 2.x document to 3.0 — outputs to stdout by default
asyncapi convert asyncapi-v2.yaml --target-version 3.0.0 -o asyncapi.yaml
# Validate the converted output immediately
asyncapi validate asyncapi.yaml
The converter handles: extracting operations from channels, renaming the server URL to host, restructuring message $ref paths, and converting publish/subscribe to action: send/receive. It cannot infer: which direction was intended when both publish and subscribe existed on the same channel in a broker-facing document, custom security scheme semantics, or binding fields that changed between spec versions.
After conversion, scan every operation and confirm the action value matches what your application actually does — the CLI makes a best-effort guess that is occasionally wrong on documents that described both sides of a channel.
Complete Before/After Reference
A minimal order-service document, before and after migration:
2.x document (asyncapi-v2.yaml):
asyncapi: 2.6.0
info:
title: Order Service Events
version: 1.1.0
servers:
production:
url: kafka.acme.internal:9092
protocol: kafka
channels:
orders/created:
publish:
operationId: publishOrderCreated
message:
name: OrderCreated
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
components:
schemas:
OrderCreatedPayload:
type: object
required: [orderId, total]
properties:
orderId:
type: string
format: uuid
total:
type: number
3.0 document (asyncapi.yaml):
asyncapi: 3.0.0
id: urn:com:acme:order-service
info:
title: Order Service Events
version: 2.0.0 # major bump: breaking structural change
defaultContentType: application/json
servers:
production-kafka:
host: kafka.acme.internal:9092 # no scheme
protocol: kafka
channels:
orderCreated:
address: orders/created
servers:
- $ref: '#/servers/production-kafka'
messages:
orderCreated:
$ref: '#/components/messages/OrderCreated'
operations:
publishOrderCreated:
action: send # this service publishes
channel:
$ref: '#/channels/orderCreated'
messages:
- $ref: '#/channels/orderCreated/messages/orderCreated'
components:
messages:
OrderCreated:
name: OrderCreated
contentType: application/json
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
schemas:
OrderCreatedPayload:
type: object
required: [orderId, total]
additionalProperties: false
properties:
orderId:
type: string
format: uuid
total:
type: number
minimum: 0
Verification
Run validation and a lint pass against the migrated document:
# Structural conformance check
$ asyncapi validate asyncapi.yaml
File asyncapi.yaml is valid! File asyncapi.yaml and referenced documents don't have governance problems.
# Governance lint (requires Spectral 6.11 with asyncapi:recommended)
$ spectral lint asyncapi.yaml --ruleset .spectral.yaml
No results with a severity of 'error' found!
# Document the delta between old and new for reviewers
$ asyncapi diff asyncapi-v2.yaml asyncapi.yaml
asyncapi diff will report the migration as a breaking change (channel structure changed, operations added, server format changed) — that is expected and correct. Bump info.version to the next major version before merging.
In CI, add the validation step to the pull request gate using the same workflow pattern shown in the parent guide:
# .github/workflows/asyncapi-gate.yml
- name: Validate AsyncAPI 3.0 spec
run: asyncapi validate asyncapi.yaml
- name: Lint governance rules
run: spectral lint asyncapi.yaml --ruleset .spectral.yaml
Migration Checklist
Work through these in order. Each item maps to a step above.
Edge Cases and Caveats
Broker-facing 2.x documents with both publish and subscribe on one channel. Some teams wrote a single document from the broker’s perspective describing both the producer and consumer. When converting, the asyncapi convert command may assign action: send to both. Review every operation and confirm the action matches what the service owning this document actually does — or split into two service-specific documents.
$ref paths change between 2.x and 3.0. A 2.x reference like $ref: '#/components/messages/OrderCreated' still resolves in 3.0 if you kept messages in components. However, references that pointed at #/channels/orders~1created/publish/message no longer resolve because publish is gone. Search the document for publish and subscribe in $ref strings before considering the migration complete.
asyncapi-bindings version compatibility. Protocol bindings (Kafka, AMQP, MQTT) have their own versioning separate from the AsyncAPI spec. Some binding fields changed or were added between the bindings spec versions that 2.x and 3.0 tools target. Consult the asyncapi/bindings repository changelog if binding-related validation errors appear after migration.
Frequently Asked Questions
Will my AsyncAPI 2.x document pass asyncapi validate after upgrading the version field to 3.0.0?
No. Changing asyncapi: 2.6.0 to asyncapi: 3.0.0 without restructuring the document causes the validator to report dozens of errors because the schemas for channels, operations, and messages changed fundamentally. You must restructure the document, not just bump the version number.
What replaced publish and subscribe in AsyncAPI 3.0?
The top-level operations block, where each operation carries action: send (this application publishes) or action: receive (this application consumes). The 2.x publish/subscribe verbs were defined from the broker’s perspective, which caused persistent confusion about direction.
Do I need to move all messages out of channels in AsyncAPI 3.0?
Messages can stay under channels.<id>.messages in 3.0, but the channel no longer uses publish/subscribe to describe who handles them. The operation references the channel and the specific messages inside it via $ref. For reuse across channels, move messages to components.messages.
Can I use the asyncapi CLI to convert 2.x to 3.0 automatically?
The asyncapi convert command handles the mechanical structural changes — operation extraction, action renaming, message $ref updates. Review the output carefully: the converter cannot infer intended semantics (send vs receive) when the original document used both publish and subscribe on the same channel.
Is AsyncAPI 3.0 backward-compatible with 2.x tooling?
No. AsyncAPI 3.0 is a breaking change at the spec level. Generators, validators, and studio tools that target 2.x will reject 3.0 documents. Pin your toolchain to versions that explicitly support 3.0, such as @asyncapi/cli 2.x and Spectral 6.11+.
What changed in the servers block between 2.x and 3.0?
In 2.x, servers used a name: url: format and protocol was a top-level field. In 3.0, each server entry uses host (hostname and port, no scheme) alongside a separate protocol field. The security field changed from a list of single-key objects to a list of objects with type and scopes properties.