NimBus → AsyncAPI mapping
nb asyncapi export (and its backward-compatible alias nb catalog asyncapi) generates an
AsyncAPI 3.0 document from a NimBus IPlatform. The
nb asyncapi validate and nb asyncapi diff subcommands turn that document into a CI/CD
governance gate (see docs/cli.md). This page explains how NimBus concepts
map to AsyncAPI, and — importantly — how the document represents NimBus's real Azure Service Bus
topology rather than a simplified "one topic per event" model.
The topology being described
NimBus does not use a topic per event category. Its runtime shape (created by
ServiceBusTopologyProvisioner) is:
- Topic per endpoint. Every endpoint gets one Service Bus topic named after its id.
- Routing by SQL rules on application properties —
user.To,user.From,user.EventTypeId(seeNimBus.Core/Messages/UserPropertyName.cs), not by topic name. - Auto-forwarding between topics. A producer publishes only to its own topic
(
user.To= event class name,user.From= null). For each consumer, a forward subscription on the producer topic — ruleuser.EventTypeId = 'X' AND user.MessageType = 'EventRequest' AND user.From IS NULL, actionSET user.From=<producer>; SET user.EventId=newid(); SET user.To=<consumer>— auto-forwards a rewritten copy into the consumer's topic. The two guards restrict the rule to original publishes:MessageType = 'EventRequest'keeps the producer's own control traffic about an event of type X (responses to the Resolver, retry and deferred-processing requests, resubmissions) off the forwarder, andFrom IS NULLkeeps forwarded and parked copies off it. - Consumers read their own topic. Delivery is a session-required subscription named after
the endpoint, filtered
user.To = '<endpoint>'. The consumer never subscribes to the producer.
Upgrading forwarder rules
Releases before the MessageType guard provisioned forwarder rules that filter only on
EventTypeId and the From IS NULL guard. Such a rule also forwarded the producer's own
control traffic to its consumers whenever an endpoint both consumed and produced the same event
type.
nb topology applybrings every existing forwarder rule to the new filter. On a real namespace it replaces each rule with a singleUpdateRuleAsynccall instead of deleting it and creating it again, which is meant to avoid a window in which publishes go unforwarded. This has been checked only against a recording administration client in the tests, not on a live namespace. On an emulator (UseDevelopmentEmulator=true) the rule is deleted and recreated, because the emulator cannot update a rule in place.- The WebApp's subscription rebuild (Topology → Subscriptions → Delete & recreate) follows the same rule: updated in place on a real namespace, deleted and recreated when the WebApp's Service Bus connection string targets an emulator.
- Producers outside NimBus that write
EventTypeIdonto a NimBus topic must also stampMessageType = EventRequest(and leaveFromunset) for their publishes to be forwarded.
Why specification extensions (no servicebus binding)
AsyncAPI has no official Azure Service Bus binding — the
amqp1 binding is a v0.1.0 empty placeholder, and Service Bus speaks AMQP 1.0. So the document is
a hybrid: portable logical channels/operations (what developer portals expect) enriched with
Service Bus specifics carried in x-servicebus* / x-nimbus* specification extensions. The
server declares protocol: amqp and an empty amqp1 binding for tooling that keys off it.
Exporting the document
Two ways to produce the same full-platform document:
- CLI —
nb catalog asyncapiwrites it to a file (.json⇒ JSON, else YAML). - Management WebApp — the Topology page's header offers
Download YAML and Download JSON buttons. The endpoint is
GET /api/admin/asyncapi?format=yaml|json(missing/emptyformatdefaults to YAML; any other value is a400). It is site Owner only — the same gate as the rest of/api/admin, includingGET /api/admin/platform-config— and returns the document as animbus-asyncapi.yaml/nimbus-asyncapi.jsonattachment. Both paths reuse the one exporter (NimBus.ServiceBus.AsyncApi.AsyncApiExporter), so the WebApp download and the CLI output are identical for the same platform.
Concept mapping
| NimBus concept | AsyncAPI construct |
|---|---|
| Service Bus namespace | servers.production (protocol: amqp, host {namespace}.servicebus.windows.net) |
| Topology (topic-per-endpoint, SQL routing, auto-forward, Resolver/Deferred subs) | servers.production.x-nimbus-topology |
| Endpoint topic | channels.<endpointId> (address = topic; x-servicebus.resourceType: topic) |
| Event type appearing on a topic (produced here, or forwarded in) | entry in channels.<endpointId>.messages |
| Producer publishes an event | operations.<producer>_send_<event> (action: send) — one per producer |
| Consumer consumes an event | operations.<consumer>_receive_<event> (action: receive) |
| Consumer's session delivery subscription | operations.<...>_receive_<...>.x-servicebus-delivery.deliverySubscription (requiresSession: true, filter user.To = '<endpoint>') |
| Auto-forward subscription(s) on producer topic(s) | ...x-servicebus-delivery.forwardSubscriptions[] (topic, subscription, forwardTo, filter, action) |
Event contract (IEventType / CLR class) | components.messages.<event> + components.schemas.<event> |
user.* application properties | components.schemas.NimBusMessageHeaders, referenced by every message's headers |
[SessionKey], MessageId/CorrelationId conventions, session/dead-letter | components.messages.<event>.x-servicebus |
[Description] / [AsyncApiMessage] on an event | message title / summary / description / tags |
[AsyncApiMessage(Name=…)] / fluent o.AsyncApi.Name | message name and payload schema title (component key stays the event id) |
Owner / Team / BusinessCapability / Version | message x-nimbus-governance.* (no standard AsyncAPI slot) |
ExternalDocsUrl / ExternalDocsDescription | message externalDocs.url / externalDocs.description |
Deprecated | payload schema deprecated: true (the AsyncAPI/JSON-Schema marker), mirrored as x-nimbus-governance.deprecated |
Event's static example instance (GetEventExample()) + fluent o.AsyncApi.Examples | message examples[] (derived sample first, then fluent examples) |
DynamicForward (spec 022) | message flagged x-nimbus-dynamic + its send/receive operations |
Enrichment: attributes and fluent per-publish config
Enrichment can be supplied two ways, and they are merged into one document:
- Attribute —
[AsyncApiMessage(Title=…, Summary=…, Description=…, Tags=…, Name=…, Owner=…, Team=…, BusinessCapability=…, Version=…, Deprecated=…, ExternalDocsUrl=…, ExternalDocsDescription=…)]on the event class. - Fluent — at publisher registration:
The fluent call records metadata only — it never changes the send path.services.AddNimBusPublisher("StorefrontEndpoint", b => b.Publish<OrderPlaced>(o =>{o.AsyncApi.Title = "Order placed";o.AsyncApi.Owner = "commerce";o.AsyncApi.Tags.Add("CRM");o.AsyncApi.Examples.Add(new AsyncApiMessageExample { Name = "typical", Payload = new { orderId = "…" } });}));
Merge rule (deterministic). When both are present for the same event: scalars use fluent → attribute → derived default; Tags are unioned (first-seen, de-duped); Examples are the derived sample followed by fluent examples; Deprecated is OR-ed.
The fluent → document bridge
Fluent enrichment is imperative container state (recorded in an AsyncApiEnrichmentRegistry by the
Publish<T> calls), so it is exported by the host that registered the publishers: call
AddNimBusAsyncApiDocument(platform, (p, f, r) => AsyncApiExporter.Serialize(p, f, r)), then resolve
IAsyncApiDocumentProvider and call GetDocument(format). The provider reads the same registry, so
fluent values appear in that host's exported document. AsyncApiFormat, AsyncApiEnrichmentRegistry,
and IAsyncApiDocumentProvider live in NimBus.Abstractions (ns NimBus.Core.Events) so the SDK,
CLI, and consumers share them without any project depending on NimBus.CommandLine.
Three surfaces expose fluent enrichment:
nb asyncapi export --assembly <host.dll> [--provider <Type>]— the CLI loads the host assembly (viaAssembly.LoadFrom, the same convention the WebApp uses to load anIPlatform) and resolves the provider it exposes, then writesGetDocument(format). BecauseAddNimBusAsyncApiDocumentregisters a private, DI-backedIAsyncApiDocumentProvider(it has constructor dependencies the standalone CLI cannot instantiate), the host bridges to it with a public parameterlessIAsyncApiDocumentProviderFactorywhoseCreate()builds the container and resolves the provider from it; the CLI also accepts a directly-exposed public parameterlessIAsyncApiDocumentProvider. This is the CLI path to a fluent-enriched document. Without--assemblythe CLI exports the static built-inPlatformConfiguration, which surfaces attribute enrichment only.- In-process — any host that called
AddNimBusAsyncApiDocument(...)resolvesIAsyncApiDocumentProviderfrom its own container.
A management-UI download of the enriched document (issue capability #6) is a follow-up — see Notes & limits.
Validation & diff (governance)
nb asyncapi validateis section-aware: each$refmust resolve to the correct AsyncAPI section for its context (operation → channel; operation message → channel-scoped message → component message, or directly to component message; channel message → component message; messagepayload/headers→ a component schema). A payload$refinto#/components/messagesis rejected even though the node exists.nb asyncapi diffclassifies added/removed/changed channels (including a channel message whose$refis retargeted to a different component message — breaking), operations, messages, and schemas (down to schema properties, including a property's effective shape, enum values on properties or arrayitems,[Range]-derivedminimum/maximumbounds, anddescriptionmetadata) and flags breaking changes for build gating. A tightened bound is breaking; a relaxed/removed bound and metadata edits are non-breaking but still reported (so a metadata-only delta never shows as "No differences"). Seedocs/cli.mdfor the exact breaking-change list.
Schema generation
Schemas are reflected from the CLR event type (IEventType.GetEventClassType()):
- Formats —
Guid→string/uuid;DateTime/DateTimeOffset→string/date-time; integers →integer;decimal/double/float→number;bool→boolean. - Enums →
stringwith anenumlist. Collections →arraywithitems. Nested objects →$refto a registered schema (recursively). - Required — a property is required if it has
[Required]or is non-nullable (value types, and NRT-annotated reference types).[Range]maps tominimum/maximum.
Notes & limits
EventTypeIdis the unqualified CLR class name and is global to the namespace; two different classes with the same name collide (documented hazard) — the exporter emits both producers'sendoperations but the shared message/schema key resolves to one type.- Dynamically-typed events have no compiled contract, so they get no payload schema.
- Not yet included (follow-ups): contract-first validation (verifying implemented
publishers/subscribers match a supplied contract) and XML-doc-comment-sourced schema
descriptions (today only
[Description]attributes are read).
See ADR-007 for the decision record and
docs/cli.md for command usage.