Architecture Decision Record
ADR-066: Broker Transport Selection and Dev/Prod Parity
Status
Accepted (2026-08-07). Revised 2026-08-14 (source citations re-anchored; the ADC AppHost comment
that used to say no WithBroker() was wired has been corrected in code, so the trade-off recording
it is restated). Revised 2026-09-01 (the Service Bus emulator parity tier is authoritative and
deploy-gating in BOTH consumers, ADC since 2026-08-31 as TD-17 and Store immediately after, so the
"both jobs are continue-on-error" record and the "no gating check exercises the production
transport" trade-off are rewritten; the dedicated app-clients SAS sourcing is recorded for both
repos rather than Store alone; and the emulator fixture is now framework code, ServiceBusEmulatorFixtureBase
in MMCA.Common.Testing since v1.178.0, subclassed by both consumers instead of hand-copied into
each, so the bullet that described a per-repo fixture shape is restated around the shipped base).
The same 2026-09-01 revision records the local Service Bus emulator path, which the Decision did not
mention at all: a second WithBroker overload and AddServiceBusEmulatorBroker ship in
MMCA.Common.Aspire.Hosting, and ADC's AppHost now selects between the two brokers on
ADC_BROKER=servicebus rather than calling WithBroker() per service, so the local-development
bullet is split in two and the source citations are re-anchored. Revised 2026-09-03 (citations
re-anchored after file moves inside MMCA.Common: MessageBusSettings.cs now sits under
Messaging/ and the shared test fixture bases under MMCA.Common.Testing/Fixtures/; the Azure
Service Bus emulator host build moved out of ConfigureBrokerTransport into the
ServiceBusEmulatorSupport helper, so the bullet describing that branch is restated).
Revised 2026-09-07 (the single shared client SAS is replaced by a rule per service in both
consumers, so broker rights and rotations are scoped to one service). Revised 2026-09-19 (no
app-clients rule is named anywhere in either template, so the tier-and-rights bullet, the Manage
trade-off and the 2026-09-07 revision are restated around the seven per-service rules that actually
exist and are named here; the claim that a namespace-level rule survives beside them is dropped,
because both templates record the opposite in a comment above the rules). Revised 2026-10-01
(Store's AppHost now carries the same STORE_BROKER=servicebus emulator opt-in as ADC, so both
consumers can run the production transport locally; see Revision below).
Context
ADR-003 decides that integration events leave an aggregate through the outbox and are published by
OutboxProcessor via IMessageBus, and it settles the dispatch question ("in-process for the
monolith, a broker once a module is extracted"). ADR-016 decides the MassTransit version (v8, with
a fitness-function gate). Neither decides which broker actually runs: local development and
production run different products (RabbitMQ in a container versus a managed Azure Service Bus
namespace), and that difference is exactly the kind of thing that leaks into code, into per-service
appsettings, or into a class of failure that only appears after a deploy.
Three questions were open and are answered here: which transport each environment gets and who supplies it, what keeps the two transports behaving the same, and how the production-only transport gets exercised before production sees it.
Decision
Keep one IMessageBus abstraction with a three-value transport selector, choose the value at
the deployment edge (never in application code), configure both broker transports identically, and
carry a dedicated test tier for the transport that only production uses.
- Three provider values, one abstraction.
MessageBusProviderhas exactlyInProcess = 0,RabbitMq = 1,AzureServiceBus = 2(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:236-252), bound from theMessageBussection (:14) and defaulting toInProcess(:17).AddBrokerMessagingreturns the container untouched forInProcess(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:51-54); for either broker value it replacesIMessageBuswithBrokerMessageBus(:94) andIEventBuswithBrokerEventBus(:100), so the outbox becomes the only delivery channel. No application or domain code names a transport. - Local development defaults to RabbitMQ, wired by the AppHost.
AddMessageBroker()provisions the RabbitMQ container with the management plugin (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:160-165), and theWithBrokeroverload taking aRabbitMQServerResourceattaches it to a project resource withWithReference+WaitForand setsMessageBus__Provider=RabbitMq(:252-262). Every extracted service gets a broker: ADC's four (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:133,162,205,233) and Store's three (MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:167,193,239, RabbitMQ branch at:83-85). The developer sets nothing: the orchestrator owns the choice. - The same local stack can run the production transport, per developer and opt-in. A second
WithBrokeroverload takes aServiceBusEmulatorResourceand setsMessageBus__Provider=AzureServiceBusplus the emulator's AMQP connection string andMessageBus__EmulatorAdminEndpoint(Extensions.cs:280-292, the setting it binds to atMessageBusSettings.cs:49). The resource is framework code too:AddServiceBusEmulatorBrokerruns the pinnedazure-messaging/servicebus-emulator:2.0.1container against the AppHost's existing SQL Server rather than a second engine (Extensions.cs:201-238, image tag at:107). The infrastructure side keys off one marker and nothing else: when the connection string carriesUseDevelopmentEmulator=true,ConfigureBrokerTransportdelegates the host build toServiceBusEmulatorSupport(IsEmulatorConnectionString, thenConfigureEmulatorHost), which attaches the administration client MassTransit v8 needs; otherwise it takes the productioncfg.Host(connectionString)path unchanged (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:296-314, delegation at:305-309, production path at:312; the helper isMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/ServiceBusEmulatorSupport.cs). Both consumers are wired for it. ADC's AppHost picks the emulator over RabbitMQ whenADC_BROKER=servicebusis set, then applies that one choice to all four services through a repo-localWithSelectedBrokerhelper, because the two overloads take different resource types (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:90-102, rationale at:67-89, helper atMMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/BrokerSelection.cs:21-26). Store's AppHost does the same for its three services onSTORE_BROKER=servicebus(MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:74-86, rationale at:51-73, helper atMMCA.Store/Source/Hosting/MMCA.Store.AppHost/BrokerSelection.cs:21-26). Unset is the default in both, so the everyday inner loop pays neither the extra container nor its warm-up. - Production is Azure Service Bus, injected by Bicep. Each service container app receives
MessageBus__Provider=AzureServiceBusplus aMessageBus__ConnectionStringsecret reference: ADC atMMCA.ADC/infra/main.bicep:1733-1734(identity,:1624),:1943-1944(conference,:1851),:2085-2086(engagement,:2002),:2236-2237(notification,:2135); Store atMMCA.Store/infra/main.bicep:1512-1513(identity,:1420),:1678-1679(catalog,:1599),:1810-1811(sales,:1720), each Store service on its own per-service secret. The images are the same ones the AppHost runs locally; only the two environment variables differ. - One resolution order for the connection string.
ResolveBrokerConnectionStringprefers an explicitMessageBus:ConnectionString, thenConnectionStrings:rabbitmq, thenConnectionStrings:messaging(DependencyInjection.Messaging.cs:219-228). Aspire'sWithReferenceand Bicep'ssecretReftherefore both land on a path the host already reads, and the transport selector stays separate from the credential. - Retry policy is identical on both transports. Each branch of
ConfigureBrokerTransportcallscfg.UseMessageRetry(r => r.Exponential(...))with the same four arguments beforeConfigureEndpoints: RabbitMQ atDependencyInjection.Messaging.cs:286-290, Azure Service Bus at:326-330. The values come from one settings object:RetryLimit5,RetryMinIntervalSeconds1,RetryMaxIntervalSeconds30 (MessageBusSettings.cs:92,99,105). Second-level redelivery is the one place the two transports diverge by design: Azure Service Bus schedules messages natively, soUseDelayedRedeliveryis applied unconditionally there (DependencyInjection.Messaging.cs:320-324), while RabbitMQ keeps it opt-in behindEnableDelayedRedelivery(defaultfalse,MessageBusSettings.cs:195, documented at:184-187) because it needs the delayed-message-exchange plugin the Aspire container does not ship (DependencyInjection.Messaging.cs:277-284, posture documented in the remarks at:246-252). - Service Bus Standard tier and
Managerights are forced by the topology MassTransit builds. Both namespaces areStandard/Standard(MMCA.ADC/infra/main.bicep:1039-1042,MMCA.Store/infra/main.bicep:999-1002) becauseUsingAzureServiceBusconfigures a topic per message type plus a subscription per consumer, and Basic supports queues only (MMCA.ADC/infra/main.bicep:1030-1031,MMCA.Store/infra/main.bicep:991-993). There is no shared client rule: each container app owns a namespace authorization rule of its own, and every one of them carriesSend+Listen+Manage. ADC declares four (identity-service,conference-service,engagement-service,notification-serviceatMMCA.ADC/infra/main.bicep:1079,:1091,:1103,:1115, rights at:1083-1087and repeated identically on the other three); Store declares three (catalog-app,sales-app,identity-appatMMCA.Store/infra/main.bicep:1035,:1047,:1059, rights at:1039-1043).Manageis on all seven soConfigureEndpointscan provision that topology at startup; without it the first publish fails with an Unauthorized topology error, which is why neither template drops it (MMCA.ADC/infra/main.bicep:1052-1064,MMCA.Store/infra/main.bicep:1025-1034). Neither repo sources a connection string fromRootManageSharedAccessKey, so a later move to managed identity can revoke these without touching the namespace root: each service reads its ownlistKeys().primaryConnectionStringvariable, four of them under a four-line rationale comment in ADC (MMCA.ADC/infra/main.bicep:200-203, variables at:204-207) and three under a five-line one in Store (MMCA.Store/infra/main.bicep:154-158, variables at:159-161). - Tests use the transport the tier is testing. A per-service integration host configures no
provider, so
AddBrokerMessagingshort-circuits and the in-process bus stands (DependencyInjection.Messaging.cs:51-54). The cross-service round-trip tier runs the real broker: the shared fixture base setsMessageBus__Provider=RabbitMqplusConnectionStrings__rabbitmqagainst a Testcontainers RabbitMQ for every host it boots (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Fixtures/CrossServiceFixtureBase.cs:249-250). - A dedicated emulator tier exists to prove the production binding, and it gates the deploy. The
fixture is framework code, not a per-repo copy:
ServiceBusEmulatorFixtureBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Fixtures/ServiceBusEmulatorFixtureBase.cs) ships inMMCA.Common.Testingas of v1.178.0, and both consumers subclass it, supplying only theirReceiveQueueName, their contract handlers throughConfigureReceiveEndpoint, the[CollectionDefinition]class (per test assembly by construction) and the assertions. The base owns everything that was hand-copied on both sides before: the pinned image (DefaultEmulatorImage,mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1, 2.x on purpose because the HTTP management plane MassTransit provisions its topology through shipped in 2.0.0); the admin-plane connection string built against the mapped port 5300 (AdminPlanePort,ComposeAdminConnectionString, pure and static so it is unit-testable without a container); the MassTransit v8 bus started throughBus.Factory.CreateUsingAzureServiceBuswith the custom-clientsHostoverload, the only v8 path onto the emulator; and a static constructor lowering MassTransit's process-global TTL and auto-delete defaults under the emulator's one-hour quota, which is why the tier runs in its own test process. Two shapes the base now enforces rather than suggests: the bus lives on the FIXTURE and starts once for the whole tier (a test class implementingIAsyncLifetimeis re-instantiated per[Fact], so a bus created there re-provisions the entire topology through an admin plane that throttles at roughly one admin operation per second), and exactly ONE receive endpoint is provisioned (ReceiveQueueName), so an added contract costs a topic plus a subscription rather than another queue. Both startup phases are wall-clock bounded by overridable budgets (ContainerStartTimeout4 minutes,BusStartTimeout3 minutes,BusStopTimeout1 minute) that throw a named PHASE 1 or PHASE 2TimeoutException: the point is not only failing sooner but keeping the evidence, since a step killed by the JOB timeout has its log discarded, which is what left ADC's 7-of-7 hang (2026-07-21 to 07-24) unlocalized for a week. Both jobs are authoritative on the weekday-nightly workflow: neither carriescontinue-on-error(ADCMMCA.ADC/.github/workflows/cross-service-tests.yml:159-163, rationale:132-143, cron:31; Store'sservicebus-emulator-smokejob inMMCA.Store/.github/workflows/cross-service-tests.yml), and each repo'scross-service-freshnessdeploy gate requires BOTH thecross-servicejob and theservicebus-emulator-smokejob to have concluded success in the same nightly run (ADCMMCA.ADC/.github/workflows/deploy.yml:923-926, gate job:902, indeploy.needsat:1185and asserted at:1226; the Store gate enumerates the same two job names inMMCA.Store/.github/workflows/deploy.yml). ADC promoted the tier on 2026-08-31 (TD-17) and Store followed immediately after, so the transport only production runs is a deploy precondition in both apps. Both gates count per-JOB conclusions rather than the run conclusion, so a still-advisory job elsewhere in the same workflow cannot drag a proven run down.
Adoption differs by repo. ADC and Store select a broker on every extracted service, local and
production alike. MMCA.Helpdesk stays on InProcess: its monolith host calls the same
AddBrokerMessaging(builder.Configuration) with no MessageBus:Provider configured
(MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:130), and its AppHost provisions no
broker, so extraction later is an AppHost change rather than a code change.
Rationale
- The transport is a deployment fact, so it lives at the deployment edge. The only difference between a laptop and production is two environment variables set by the AppHost or by Bicep. No service has a per-environment appsettings branch for messaging, and nothing has to be rebuilt to change transport.
- RabbitMQ locally is the cheap, offline, inspectable broker. It is a container with a management UI, it starts with the rest of the stack, and it needs no cloud subscription or credential to run the real outbox to broker to consumer path on a developer machine.
- Azure Service Bus in production is the managed one. It removes broker operations (patching, clustering, storage) from a two-app footprint that has no platform team, and the topic model is what MassTransit already targets.
- Identical retry configuration is what makes the two brokers substitutable. Retry semantics are the behavior most likely to differ between transports, so both branches are configured from the same settings object with the same call; a difference would have to be introduced deliberately.
- Tier and rights are recorded because they are not obvious and fail late. Basic tier and a
Send+Listenrule both provision cleanly and then fail at the first publish, in production, when MassTransit tries to create a topic. Writing the constraint next to the resource is what keeps a cost-trimming pass from "downgrading" the namespace. - The emulator tier is the only automated pre-production evidence for the production transport.
Every other tier that exercises messaging runs on RabbitMQ or in-process, so without it the Azure
Service Bus binding would first be executed by a deploy. The local
ADC_BROKER=servicebus(orSTORE_BROKER=servicebus) stack runs the same transport on a developer machine, which is what makes a Service-Bus-only bug reproducible in the inner loop, but it is a debugging tool: nothing runs it on a schedule and nothing gates on it.
Trade-offs
- Two brokers means two behaviors to keep aligned. Configuration parity is enforced by one code
path, but the products still differ (Service Bus supports delayed redelivery natively, the Aspire
RabbitMQ container does not,
DependencyInjection.Messaging.cs:246-252), so a transport-specific behavior can still be adopted by accident. The local Service Bus emulator narrows that window but does not close it: it is opt-in and off by default, so the inner loop a developer actually runs is still the divergent one unless they setADC_BROKER=servicebusorSTORE_BROKER=servicebus. - The production transport is gated nightly, not per commit. Both emulator jobs are authoritative
and both
cross-service-freshnessgates require them (MMCA.ADC/.github/workflows/deploy.yml:923-926and the Store equivalent), so a Service-Bus-only regression blocks the next deploy rather than the merge that introduced it: the tier needs a Docker daemon the gating jobs do not have, so it runs on the weekday nightly and reaches the deploy chain through a recency check. The residual is the window between a merge and the nightly that judges it, plus the recency tolerance itself (5 days on ADC, to absorb the weekday-only cadence,MMCA.ADC/.github/workflows/deploy.yml:921). The sanctioned way past a red job is a fix or a dispatched green run, never re-addingcontinue-on-error; the one escape hatch isdeploy.yml's break-glassskip_freshness_gatesinput, which forces a written justification into the run summary. - The emulator is not Azure Service Bus. It imposes its own quotas (the one-hour entity TTL the
shared fixture base works around in its static constructor,
MMCA.Common/Source/Hosting/MMCA.Common.Testing/Fixtures/ServiceBusEmulatorFixtureBase.cs, plus a throttled admin plane and a 10-connection namespace quota), so a green smoke proves the binding and the topology provisioning, not production behavior at volume. Managerights are broad, and splitting the credential did not narrow them. Every per-service rule can create and delete entities anywhere in the namespace, which is the price of lettingConfigureEndpointsbuild the topology instead of declaring every topic in Bicep (MMCA.ADC/infra/main.bicep:1079-1125,MMCA.Store/infra/main.bicep:1035-1069). Both templates record the residual next to the rules: a compromised container still holds namespace-wideSend+Listen+Manage, so per-service rules buy credential separation and revocability, not privilege reduction (MMCA.ADC/infra/main.bicep:1066-1078,MMCA.Store/infra/main.bicep:1025-1034).- Provider selection is per host and silent when missing. A service that never receives
MessageBus__Providerkeeps the in-process bus and publishes nothing to the broker, without an error (DependencyInjection.Messaging.cs:51-54); correctness depends on auditing the AppHost and the Bicep env lists, the same inventory caveat ADR-021 carries for the inbox. - The choice lives in AppHost prose that has to be maintained alongside the calls. The note above
ADC's Notification registration now states that
WithSelectedBroker(withBroker)wires the transport the same way as the other services, and records that an earlier version of the same note said the broker was not wired yet (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:122-124, the call it describes at:133). TheADC_BROKERopt-in adds a second block of the same kind, a 23-line rationale above the selection itself (:67-89), and Store'sSTORE_BROKERopt-in carries its own (MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:51-73). Keeping the transport decision in orchestration code puts the explanation in comments, which are not checked by anything.
Revision (2026-09-07)
The transport selection is unchanged. The credential topology under it is (SEC-ADC-26 / SEC-Store-38).
Both repos previously sourced their Service Bus connection strings from one shared authorization
rule on the namespace. Each service now has its own, and the shared rule is gone rather than kept
alongside them: ADC declares identity-service, conference-service, engagement-service and
notification-service (MMCA.ADC/infra/main.bicep:980, :992, :1004, :1016) and states the
absence in the template itself, "One rule per service (SEC-ADC-26): there is no namespace-wide
shared credential" (:200); Store declares catalog-app, sales-app and identity-app
(MMCA.Store/infra/main.bicep:1005, :1017, :1029) under the matching note, "One namespace
authorization rule per container app, and no namespace-wide shared credential" (:981). Store's
namespace keeps local auth enabled precisely because those three rules are SAS credentials
(MMCA.Store/infra/main.bicep:984-985).
The consequence for this record is operational rather than architectural: dev/prod parity is unaffected (the emulator tier has no SAS at all), but a rotation is now a per-service action instead of a synchronized restart of every service on the namespace, and a leaked connection string names which service leaked it.
Revision (2026-10-01)
The local Service Bus emulator path is no longer ADC-only. Store's AppHost selects its broker once
on STORE_BROKER=servicebus: set, it calls AddServiceBusEmulatorBroker over the existing SQL
Server; unset, it keeps AddMessageBroker()
(MMCA.Store/Source/Hosting/MMCA.Store.AppHost/Program.cs:74-86, rationale at :51-73, which
states that it mirrors ADC's ADC_BROKER switch). All three Store services attach through the same
repo-local WithSelectedBroker(withBroker) helper ADC uses (:167, :193, :239;
MMCA.Store/Source/Hosting/MMCA.Store.AppHost/BrokerSelection.cs:21-26), so the record that
Store's AppHost wires RabbitMQ unconditionally is replaced, and the Rationale and Trade-offs now
name both opt-in variables. The transport decision itself is unchanged: RabbitMQ stays the local
default and nothing schedules or gates on the local emulator stack.
The same pass re-anchors citations that drifted with file growth. The broker registration,
connection-string resolution and transport configuration now live in the partial file
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs, so every
former DependencyInjection.cs anchor points there; the MessageBusSettings.cs, both
main.bicep templates, the ADC AppHost and workflow files, and the Helpdesk host anchors are
refreshed in place with no change in what they show. Store's Bicep now gives each service its own
MessageBus__ConnectionString secret reference (MMCA.Store/infra/main.bicep:1513, :1679,
:1811), consistent with the per-service rules recorded in the 2026-09-07 revision. The ADC
freshness gate now runs through the shared freshness-gate composite action
(MMCA.ADC/.github/workflows/deploy.yml:915) with the same same-run, per-job requirement
(:923-926).
Related
ADR-003 (the outbox that feeds IMessageBus; this ADR picks the transport underneath it), ADR-016
(the MassTransit v8 pin the emulator tier must work within, which is why the custom-clients Host
overload is used), ADR-008 (module extraction, the reason a broker exists at all), ADR-021 (the
consumer-side inbox that deduplicates broker redeliveries), ADR-006 (database-per-service, the
sibling boundary the broker crosses).