ADR-014: Commands Are Events with an Enforced Single Consumer
Status
Accepted (introduced 2026-07)
Context
NimBus routes every message by event type: the publisher stamps To = EventTypeId, and one forward subscription per declared consumer copies the message to each consuming endpoint's topic. A publisher cannot address a specific endpoint through the public API, and "this message has exactly one recipient" is expressible only as a catalog convention — declare one consumer and hope nobody adds a second. If someone does, the "command" silently becomes a broadcast with no compile-time or runtime signal.
When users asked for command messages ("do this", one logical recipient, imperative intent) alongside events ("this happened", any number of observers), we had three options:
- Pure naming convention — imperative class names, single declared consumer, README discipline. Zero enforcement; the silent-broadcast failure mode remains.
- First-class point-to-point routing — a
Send(command, targetEndpoint)API addressingTo = '{endpointId}'directly, or real queue entities. True point-to-point, but a new send path, provisioning changes across all topology emitters, and Resolver semantics review — significant machinery for a guarantee the catalog can already express. - Marker type + catalog enforcement — a
Commandbase class deriving fromEvent(wire, pipeline, ordering, audit all unchanged) plus validation that every command type has exactly one declared consumer, failing provisioning otherwise.
Decision
Option 3. NimBus.Core.Events.Command is an empty marker base class extending Event. NimBus.Core.PlatformValidation.EnsureCommandConsumers(IPlatform) enforces at provisioning time that every catalog event type whose CLR type derives from Command has exactly one consuming endpoint:
- Zero consumers is an error — every send would dead-letter.
- More than one is an error — the instruction would fan out as a broadcast.
The check runs in ServiceBusTopologyProvisioner.ApplyAsync (covering nb topology apply and in-process provisioner consoles). Platforms constructed from exported configuration (the WebApp path) carry no CLR types and are skipped by construction — enforcement happens where the code-first catalog lives.
The command/event distinction is therefore real but contractual, not mechanical: same topics, same session ordering, same Resolver audit, same handlers (IEventHandler<TCommand>). What changes is the catalog contract the platform is willing to provision.
Consequences
- Adding a second
Consumes<SomeCommand>()fails topology provisioning with an error naming the command and all consumers — the silent-broadcast failure mode is gone. - No new routing infrastructure, no dual send paths, no changes to storage or the Resolver.
- Commands remain observable in the WebApp exactly like events.
- Enforcement is only as early as the first provisioning run; a host that never provisions never validates. Acceptable: topology apply is already the gate every deployment path goes through.
- If genuine point-to-point routing (competing consumers, load-balanced queues) is ever needed, that is a separate feature; this ADR does not preclude it.
- Convention: imperative names for commands (
PlaceCustomerOnCreditHold), past tense for events (CustomerCreated). Request/reply request types are good candidates forCommand, since multiple responders would race to reply.