Skip to main content

How to Choose Between OpenAPI and AsyncAPI for Microservices

When a microservice architecture spans both synchronous HTTP APIs and asynchronous event streams, teams repeatedly face the same decision: which specification governs which surface? This guide extends the OpenAPI Specification Deep Dive with a focused decision framework covering communication style analysis, annotated spec examples, and a shared-schema governance pattern that prevents the type drift that breaks CI contract validation.

Problem Framing: Sync vs Async Contract Choice

The failure mode that drives teams to this decision is contract drift. It typically surfaces as:

ERROR [contract-validator]: Schema drift detected in shared payload definitions.
  Field mismatch: 'user_id' (integer) in OpenAPI vs 'userId' (string) in AsyncAPI.
  Both specs reference 'UserCreated' but define incompatible types.

The root cause is not a tooling defect — it is an architectural decision deferred until CI fails. OpenAPI and AsyncAPI exist on a hard boundary: one models request-response over HTTP, the other models message exchange over channels. Applying either spec beyond its boundary produces an incomplete contract that no validator can enforce.

Decision Criteria

Before reaching for either spec, answer four questions about the communication channel you are contracting:

  1. Who initiates the exchange? A caller that sends a request and waits for a response → OpenAPI. A producer that emits a message with no synchronous reply expected → AsyncAPI.
  2. What is the transport? HTTP/1.1, HTTP/2, or HTTP/3 → OpenAPI. Kafka, RabbitMQ, AMQP, NATS, WebSocket, SNS/SQS, Server-Sent Events → AsyncAPI.
  3. Is there a 1:1 or 1:N delivery model? One consumer receives the response → OpenAPI. Multiple consumers may subscribe to the same message → AsyncAPI.
  4. Does the interaction carry request metadata (headers, query params, path params)? Structured HTTP request metadata → OpenAPI. Broker-level metadata (Kafka headers, AMQP message properties) → AsyncAPI bindings.

A service that answers “both” to any of these is genuinely hybrid. That is addressed in the Edge Cases section below.

Comparison Table: OpenAPI vs AsyncAPI by Communication Style

Dimension OpenAPI 3.1 AsyncAPI 3.0
Transport HTTP (1.1 / 2 / 3) Kafka, AMQP, NATS, WebSocket, SNS, SQS, MQTT, etc.
Interaction pattern Request → Response (synchronous) Publish / Subscribe (asynchronous)
Primary artifact openapi.yaml with paths asyncapi.yaml with channels + operations
Schema format JSON Schema draft 2020-12 (3.1) JSON Schema draft 2020-12 (3.0)
Auth / security securitySchemes (OAuth2, API Key, OIDC) Server-level security + protocol bindings
Generated tooling Prism mock server, openapi-typescript 7, swagger-ui AsyncAPI Studio, Microcks, @asyncapi/generator
Linting (Spectral 6.11) @stoplight/spectral-openapi ruleset @stoplight/spectral-asyncapi ruleset
Versioning signal info.version + HTTP Accept / Content-Type info.version + channel naming conventions
Breaking change detection Field removal, type narrowing, required additions Message schema changes, channel address changes
Contract testing Pact HTTP consumer tests, Prism validation Pact MessageConsumerPact, Microcks test suites

Annotated YAML: OpenAPI for Synchronous HTTP

The following excerpt covers a POST /users endpoint. Notice the components/schemas section — these types will be shared with AsyncAPI in the pattern that follows.

# openapi.yaml — OpenAPI 3.1
openapi: "3.1.0"
info:
  title: User Service API
  version: "1.0.0"

paths:
  /users:
    post:
      operationId: createUser
      summary: Create a new user account
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        "201":
          description: User created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        "422":
          description: Validation error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'

components:
  schemas:
    # Shared domain type — also referenced from asyncapi.yaml
    User:
      $ref: './schemas/user.json'
    CreateUserRequest:
      type: object
      required: [email, name]
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          minLength: 1
    ProblemDetail:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer

Key points:

  • paths is the entry point for all HTTP routes. AsyncAPI has no paths key.
  • $ref: './schemas/user.json' pulls the canonical domain type from a shared file, not from an inline definition. This is the hook that connects both specs to the same source of truth.
  • application/problem+json signals RFC 7807 error contract — consumers can deserialize errors predictably.

Annotated YAML: AsyncAPI for Event-Driven Channels

The same User domain type surfaces again here as the event payload when a user account is created. AsyncAPI 3.0 separates channel declaration from operations, which is the clearest structural difference from the OpenAPI paths model.

# asyncapi.yaml — AsyncAPI 3.0
asyncapi: "3.0.0"
info:
  title: User Service Events
  version: "1.0.0"

servers:
  production:
    host: kafka.internal:9092
    protocol: kafka
    description: Internal Kafka cluster

channels:
  # Channel declares the address and message schema only
  user.created:
    address: user.created
    messages:
      UserCreated:
        $ref: '#/components/messages/UserCreated'

operations:
  # Operation declares who acts on the channel and how
  publishUserCreated:
    action: send          # this service sends to the channel
    channel:
      $ref: '#/channels/user.created'
    messages:
      - $ref: '#/channels/user.created/messages/UserCreated'

components:
  messages:
    UserCreated:
      name: UserCreated
      title: User account created event
      contentType: application/json
      payload:
        $ref: './schemas/user.json'   # same shared schema as OpenAPI

  schemas:
    User:
      $ref: './schemas/user.json'

Key points:

  • channels + operations replaces the single channels block from AsyncAPI 2.x. The action: send declares that this service is the producer; a consuming service would use action: receive.
  • protocol: kafka in the server block drives binding-aware tooling. Replace with amqp, nats, or ws for other transports.
  • payload.$ref: './schemas/user.json' references exactly the same file as the OpenAPI components/schemas/User. This is the structural guarantee that eliminates type drift.

Shared Domain Schema: The Linking Pattern

Both YAML files above reference ./schemas/user.json. This file is the authoritative definition of the User domain type:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.internal/user.json",
  "title": "User",
  "type": "object",
  "additionalProperties": false,
  "required": ["userId", "email", "name", "createdAt"],
  "properties": {
    "userId": {
      "type": "string",
      "format": "uuid",
      "description": "Stable unique identifier. Never reused after deletion."
    },
    "email": {
      "type": "string",
      "format": "email"
    },
    "name": {
      "type": "string",
      "minLength": 1
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}

additionalProperties: false is non-negotiable here. Without it, one spec can silently add properties that the other ignores, and type drift re-enters through the back door.

Decision Walkthrough

Work through this sequence for any new service interface:

Step 1 — Classify the channel. Is it HTTP? If yes, write an openapi.yaml entry under paths. If it is a broker topic, stream, or WebSocket channel, write an asyncapi.yaml entry under channels.

Step 2 — Identify shared domain types. List every named type (e.g., User, Order, PaymentIntent) that will appear in both specs. Create schemas/<type>.json for each.

Step 3 — Wire $ref in both specs. In openapi.yaml, reference shared types under components/schemas. In asyncapi.yaml, reference them under components/schemas and from components/messages[*].payload. Never copy-paste the type body between files.

Step 4 — Lint both specs in CI. Run Spectral 6.11 against each file with the appropriate built-in ruleset. A single CI step that lints only one file gives false confidence.

Step 5 — Add contract tests. HTTP surfaces get Pact consumer tests against a Prism 5 mock. Event surfaces get MessageConsumerPact tests or Microcks contract test suites. Both verify against the shared JSON Schema, not against an in-memory copy.

Verification

After implementing the shared-schema pattern, validate it end-to-end in CI:

# .github/workflows/contract-validation.yml
name: Contract Validation
on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install tools
        run: |
          npm install -g \
            @stoplight/spectral-cli@6.11 \
            @asyncapi/cli@2.3

      - name: Lint OpenAPI
        run: |
          spectral lint openapi.yaml \
            --ruleset @stoplight/spectral-openapi \
            --fail-severity warn

      - name: Validate AsyncAPI
        run: asyncapi validate asyncapi.yaml

      - name: Lint AsyncAPI
        run: |
          spectral lint asyncapi.yaml \
            --ruleset @stoplight/spectral-asyncapi \
            --fail-severity warn

      - name: Assert shared schemas are valid JSON Schema
        run: |
          npx ajv-cli@5 validate \
            -s https://json-schema.org/draft/2020-12/schema \
            -d "schemas/*.json"

Expected output on a clean branch:

openapi.yaml: 0 errors, 0 warnings
asyncapi.yaml: Document is valid!
asyncapi.yaml: 0 errors, 0 warnings
schemas/user.json: valid

If spectral lint emits warnings about $ref resolution, verify that the schemas/ directory is co-located with the spec files or that you have set the correct --base-path flag.

Edge Cases: Hybrid Systems

Service exposes HTTP query API and publishes Kafka events. This is the most common hybrid. Maintain openapi.yaml and asyncapi.yaml as sibling files in the same repository. Use a shared schemas/ directory as described. Document the boundary explicitly: HTTP responses are ephemeral query results; Kafka events are durable domain facts. These are different contracts even when they carry similar fields.

WebSocket upgrades from HTTP. An HTTP endpoint that upgrades to WebSocket (101 Switching Protocols) can be modeled in AsyncAPI using the ws protocol. The upgrade path itself (the initial HTTP handshake) can be noted in OpenAPI as a 101 response, but the subsequent message stream must live in AsyncAPI. Do not attempt to model WebSocket frame payloads in OpenAPI paths.

GraphQL subscriptions over WebSocket. GraphQL subscriptions are asynchronous push from server to client and belong in AsyncAPI with protocol: graphql-ws. The query and mutation surface is synchronous HTTP and belongs in an OpenAPI or SDL schema. For the GraphQL contract side, see the REST vs GraphQL vs gRPC Contract Strategies guidance.

Shared schemas across repositories. When multiple services share the same domain types, extract the schemas/ directory into a dedicated versioned package (e.g., a private npm package or a Git submodule). Pin spec files to a specific version of the schemas package. Update via PRs with automated drift detection on every consumer.

Frequently Asked Questions

Can I use OpenAPI to describe Kafka or WebSocket events?

OpenAPI 3.x is scoped to HTTP request-response semantics and has no first-class model for message channels, subscriptions, or broker bindings. Use AsyncAPI for event-driven systems for those transports. Trying to shoehorn events into OpenAPI callbacks works only for simple webhook patterns and breaks down immediately for fan-out topics or consumer groups.

Do I need both specs when a service exposes HTTP endpoints and publishes Kafka events?

Yes. Write an openapi.yaml for the HTTP surface and an asyncapi.yaml for the event channels. Share domain type definitions through a common schemas/ directory referenced via $ref in both files. This eliminates the duplication that causes the type drift shown in the diagnostic output at the top of this guide. For a worked example of Kafka topic documentation, see Documenting Kafka Topics with AsyncAPI.

Which spec should own the canonical domain model — OpenAPI or AsyncAPI?

Neither should own it exclusively. Extract shared domain types into standalone JSON Schema files (draft 2020-12) in a schemas/ directory and $ref them from both specs. This makes the source of truth independent of any one transport and allows both specs to be validated against a single authoritative definition.

How does the AsyncAPI 3.0 channel model differ from AsyncAPI 2.x?

AsyncAPI 3.0 separates channels from operations. A channel declares the address and message schema; an operation (send or receive) references that channel. In 2.x both were collapsed under the channel key. This split makes it possible to express multiple operations on a single channel without ambiguity, and it aligns the model more closely with how Kafka consumer groups and AMQP routing keys actually work.

Can Spectral lint both OpenAPI and AsyncAPI files?

Yes. Spectral 6.11 ships built-in rulesets for both. Run spectral lint openapi.yaml --ruleset @stoplight/spectral-openapi and spectral lint asyncapi.yaml --ruleset @stoplight/spectral-asyncapi in separate CI steps, or combine them under a custom ruleset that extends both. The --fail-severity warn flag is recommended so that documentation gaps fail the build, not just errors.