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:
- 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.
- What is the transport? HTTP/1.1, HTTP/2, or HTTP/3 → OpenAPI. Kafka, RabbitMQ, AMQP, NATS, WebSocket, SNS/SQS, Server-Sent Events → AsyncAPI.
- 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.
- 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:
pathsis the entry point for all HTTP routes. AsyncAPI has nopathskey.$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+jsonsignals 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+operationsreplaces the singlechannelsblock from AsyncAPI 2.x. Theaction: senddeclares that this service is the producer; a consuming service would useaction: receive.protocol: kafkain the server block drives binding-aware tooling. Replace withamqp,nats, orwsfor other transports.payload.$ref: './schemas/user.json'references exactly the same file as the OpenAPIcomponents/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.