Skip to main content

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.

AsyncAPI 2.x vs 3.0 document structure On the left, AsyncAPI 2.x shows channels containing publish and subscribe blocks inline. On the right, AsyncAPI 3.0 shows channels containing only messages, while a separate top-level operations block holds send and receive actions that reference channels via $ref. AsyncAPI 2.x servers name: kafka.host:9092 channels orders/created: publish message: … subscribe message: …

broker-centric: confusing direction messages defined inline no reuse across channels

components schemas only AsyncAPI 3.0 servers host: kafka.host:9092 channels orderCreated: address, messages no publish / subscribe here operations ← NEW action: send → channel $ref action: receive → channel $ref components schemas + messages (reusable) migrate

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.