At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at durability and message resumption across different transports.
Now let's look at what happens when your messaging spec grows beyond a handful of channels - and why generating everything is rarely what you actually want.
Why filtering matters for messaging
Event-driven architectures tend to grow. What starts as three channels for a streetlights demo becomes fifty channels spanning order processing, inventory updates, payment notifications, shipping events, and audit logs. And unlike HTTP APIs (where a large spec means a large client library you can mostly ignore), messaging is different. Generated consumers subscribe to channels. If you generate everything, you're subscribing to everything, which means your service receives messages it has no business processing.
Even if you only generate producers, a monolithic generation pass gives you types and channel resolution code for every channel in the spec. That's code to compile, code to maintain, and code that obscures the actual messaging boundaries of your service.
What you want is a clean boundary: this service produces to these channels and consumes from those channels, and the generated code reflects exactly that scope.
Channel filtering
The --include-channel and --exclude-channel options let you select which channels to generate code for:
# Generate only the command channels
corvusjson asyncapi-generate streetlights.json \
--rootNamespace Streetlights.Commands \
--outputPath ./Generated \
--mode producer \
--include-channel "smartylighting.streetlights.*.turn.*"
# Generate everything except internal audit channels
corvusjson asyncapi-generate events.json \
--rootNamespace MyApp.Events \
--outputPath ./Generated \
--exclude-channel "internal.audit.**"
Channel patterns use glob syntax: * matches within a single segment (between dots), and ** matches across multiple segments. This aligns with how most organisations structure their channel naming hierarchies.
As with OpenAPI path filtering, the generated code is self-contained. Payload schemas referenced by the filtered channels are included regardless of whether other channels also reference them. You always get a compilable output.
Filtering by operation tags
When your spec uses tags to classify operations by domain, you can filter on those directly:
# Generate only operations tagged 'payments'
corvusjson asyncapi-generate platform-events.json \
--rootNamespace MyApp.Payments.Events \
--outputPath ./Generated \
--tag payments
This is particularly useful for specs where the channel naming convention doesn't neatly separate domains, but the tags do. Some organisations tag operations by owning team, by business capability, or by deployment boundary. All of these make good filter criteria.
Previewing the selection
The asyncapi-show command accepts the same filter options, letting you see what will be generated before committing to a generation pass:
# See everything in the spec
corvusjson asyncapi-show platform-events.json
# Preview what a channel filter selects
corvusjson asyncapi-show platform-events.json \
--include-channel "orders.**"
The output shows the channel tree with operations and message types:
Platform Events v2.1.0 (AsyncAPI 3.0)
Operations (3 of 14)
├── orders.created
│ └── SEND orderCreated - New order placed (1 msg)
├── orders.{orderId}.updated
│ └── SEND orderUpdated - Order status changed (1 msg)
└── orders.{orderId}.cancelled
└── SEND orderCancelled - Order cancelled by customer (1 msg)
This preview-then-generate workflow is the same pattern as the OpenAPI series. It avoids wasted cycles when you're iterating on the right filter expression.
Splitting a spec by bounded context
The most compelling use of filtering is carving a large shared spec into domain-specific generation targets. Consider a platform events spec that covers ordering, payments, shipping, and notifications:
# Order service - produces order events, consumes payment confirmations
corvusjson asyncapi-generate platform-events.json \
--rootNamespace Orders.Events \
--outputPath ./Generated/Orders \
--mode producer \
--include-channel "orders.**"
corvusjson asyncapi-generate platform-events.json \
--rootNamespace Orders.Events \
--outputPath ./Generated/Orders \
--mode consumer \
--include-channel "payments.confirmed,payments.failed"
# Payment service - consumes order events, produces payment confirmations
corvusjson asyncapi-generate platform-events.json \
--rootNamespace Payments.Events \
--outputPath ./Generated/Payments \
--mode consumer \
--include-channel "orders.created"
corvusjson asyncapi-generate platform-events.json \
--rootNamespace Payments.Events \
--outputPath ./Generated/Payments \
--mode producer \
--include-channel "payments.**"
Each service gets exactly the producers and consumers it needs. The generated code documents the service's messaging contract explicitly. You can look at what's generated and know precisely which channels this service publishes to and subscribes on.
This also means that regenerating one service's generated code doesn't touch another service. Teams can regenerate independently, and the spec remains the shared source of truth.
Combining mode and channel filters
Notice in the example above that we combine --mode (producer vs. consumer) with --include-channel. This is deliberate. A service might produce to orders.created and consume from payments.confirmed - the same spec, but different roles on different channels.
Running the generator twice (once for producer channels, once for consumer channels) into the same output directory composes correctly. The lock file tracks each generation pass independently, so you can also run them from separate build steps or CI jobs if that suits your workflow.
When to filter
For a small spec with a handful of channels, filtering is unnecessary overhead. But it becomes valuable in several situations:
- Platform event buses where a single AsyncAPI spec describes the entire organisation's event taxonomy and each service participates in only a subset
- Shared specs across teams where each team owns different channels and wants to generate only their portion
- Incremental migration where you're adding typed messaging to an existing system one domain at a time
- Large IoT deployments where the spec describes hundreds of device telemetry channels but your analytics service only cares about a specific device class
For the complete filtering reference, see the AsyncAPI documentation on corvus-oss.org.
In the next post, we'll look at testing - using the in-memory transport to exercise your producers, consumers, and handlers without a running broker.



Stilted: ‘I am extremely pleased to see you. I have been waiting for two hours.’
More natural: ‘I’m glad you’re here. I’ve been waiting for two hours.’
