Architecture Decision Record
ADR-134: Cloud-Neutral Core With Vendor Adapter Packages
Status
Accepted (2026-10-24). The change is the MMCA.Common v1.255.0 entry of the framework changelog
(MMCA.Common/CHANGELOG.md:7, Added at :11-17, Fixed at :21, Breaking at :25-29) and its
upgrade section (MMCA.Common/UPGRADING.md:35). It amends the records whose Azure integrations
moved: ADR-041, ADR-044,
ADR-045, ADR-061,
ADR-066, ADR-069,
ADR-015 and
ADR-118; each carries a 2026-10-24 revision.
Context
Before v1.255.0 the core packages carried the Azure SDKs themselves: MMCA.Common.Infrastructure,
MMCA.Common.Aspire, MMCA.Common.API and MMCA.Common.Testing each referenced Azure packages, and
MMCA.Common.Testing brought Testcontainers.ServiceBus, Azure.Messaging.ServiceBus and
MassTransit.Azure.ServiceBus.Core into every test project that used it (MMCA.Common/CHANGELOG.md:25).
A consumer that never runs on Azure (a self-hosted deployment, or one on AWS) still restored, audited
and shipped those dependencies, and the broker selection was a closed enum, MessageBusProvider, so a
second cloud's transport could only be added by editing the core (MMCA.Common/CHANGELOG.md:26).
The framework already had a working shape for optional vendor code. The AI boundary
(ADR-120) keeps the provider SDKs in separate packages
(MMCA.Common.AI.Anthropic, MMCA.Common.AI.OpenAI) and resolves a configured provider name against
the factories those packages register, failing at boot when the name matches none
(MMCA.Common/Source/Core/MMCA.Common.AI/Providers/AiProviderValidator.cs:6-12, :16). The cloud
integrations had no equivalent: their SDKs were unconditional references of the core.
Decision
The core packages reference no cloud SDK. Each vendor integration ships in an adapter package that
references the core, and a host opts in with one registration call per capability. The framework
guide states the rule (MMCA.Common/AGENTS.md:145).
- Three adapter packages.
MMCA.Common.Azure: Key Vault as a configuration source,AddAzureKeyVaultConfiguration()(MMCA.Common/Source/Hosting/MMCA.Common.Azure/AzureKeyVaultConfigurationExtensions.cs:23,:80); the Blob plus Key Vault DataProtection key ring,AddAzureDataProtection()(MMCA.Common/Source/Hosting/MMCA.Common.Azure/AzureDataProtectionExtensions.cs:20,:53); the Azure Monitor exporter,AddAzureMonitorExporter()(MMCA.Common/Source/Hosting/MMCA.Common.Azure/AzureMonitorExporterExtensions.cs:12,:42, callingUseAzureMonitor()at:63); Blob file storage,AddAzureBlobFileStorage(configuration)(MMCA.Common/Source/Hosting/MMCA.Common.Azure/AzureServiceCollectionExtensions.cs:41); Notification Hubs native push,AddAzureNotificationHubsPush(configuration)(:83); and the Service Bus transport,AddAzureServiceBusTransport()(:117-118). The Entra ID SQL authentication provider (Microsoft.Data.SqlClient.Extensions.Azure) arrives by referencing the package, with no call (MMCA.Common/CHANGELOG.md:11).MMCA.Common.Aws: S3-compatible storage,AddS3FileStorage(configuration), and the SQS/SNS transport,AddAmazonSqsTransport()(MMCA.Common/Source/Hosting/MMCA.Common.Aws/AwsServiceCollectionExtensions.cs:23,:44,:79).MMCA.Common.Testing.Azure: the Service Bus emulator broker-parity fixture,ServiceBusEmulatorFixtureBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Azure/Fixtures/ServiceBusEmulatorFixtureBase.cs:54).
- Configuration sections and keys are unchanged. Only the call and the type names moved; no shim
or
[Obsolete]alias is left in the core (MMCA.Common/CHANGELOG.md:25). Each adapter binds the section the core used: Blob storage and S3 both readFileStorage(MMCA.Common/Source/Hosting/MMCA.Common.Aws/Storage/S3StorageClientFactory.cs:14), and the Notification Hubs registration keeps theNativePushgate ofEnabledplus a connection string and hub name (AzureServiceCollectionExtensions.cs:91-96). - The message bus provider is a string resolved against registered transports.
MessageBusSettings.Provideris astringdefaulting toInProcess(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:25), and the built-in names are theMessageBusProvidersconstantsInProcessandRabbitMq(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Transports/MessageBusProviders.cs:9,:15,:18). The extension point isIBrokerTransport, aNameplus aConfigure(IBusRegistrationConfigurator, BrokerTransportContext)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Transports/IBrokerTransport.cs:24,:30,:40;BrokerTransportContext.cs:10), registered throughAddBrokerTransport(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:148, added as an enumerable singleton at:157).AddBrokerMessaging(:45) returns early forInProcess(:54), otherwise resolves the transport by name (:66,ResolveBrokerTransportat:177, with RabbitMQ built in at:179-181) and hands it the bus configurator (:98). A name with no registered transport throws there, naming the adapter package and call for the two known adapter names (MessageBusProviders.cs:25-29;DependencyInjection.Messaging.cs:199-203). Every transport, built in or adapter, applies the same receive policies throughBrokerTransportPolicies(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Transports/BrokerTransportPolicies.cs:25:ApplyReceivePoliciesat:37and:56,ApplyMessageRetryat:80,ApplyBackpressureat:103,BuildRedeliveryIntervalsat:131and:144). - OTLP is the only built-in exporter.
AddServiceDefaults()turns onUseOtlpExporter()whenOTEL_EXPORTER_OTLP_ENDPOINTis set (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.Telemetry.cs:166,:170). Azure Monitor is the adapter callAddAzureMonitorExporter(), which throws when it runs beforeAddServiceDefaults()because the framework's outbox and probe filter processors must be registered ahead of the exporter (AzureMonitorExporterExtensions.cs:45-50;Extensions.Telemetry.cs:146;MMCA.Common/CHANGELOG.md:27). - Startup tripwires fail fast when vendor configuration exists without its adapter. Two
IValidateOptionsvalidators run underValidateOnStart, before any hosted service starts.CloudAdapterTripwire(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/CloudAdapterTripwire.cs:30, registered byAddInfrastructureatMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:221-223) failsNativePush:Enabled=truewhoseINativePushSenderis still the null default (:33,:48-53), naming the call. A tripwire fires only on a key that declares intent, so aFileStoragesection, which is routinely shared across a solution's services, is deliberately not checked (:21-26); a host that lost its storage adapter fails each upload withFileStorage.NotConfiguredinstead (v1.256.0,MMCA.Common/CHANGELOG.md:11).HostIntegrationTripwire(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Integrations/HostIntegrationTripwire.cs:15,:31, registered byAddServiceDefaults()atMMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:44-46) checksAPPLICATIONINSIGHTS_CONNECTION_STRING,KeyVault:UriandDataProtection:BlobStorageUri(:20,:23,:26) against theHostIntegrationMarkereach adapter call registers (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Integrations/HostIntegrationMarker.cs:10, names atHostIntegrationNames.cs:17,:20,:23; registered atAzureKeyVaultConfigurationExtensions.cs:84,AzureDataProtectionExtensions.cs:57,AzureMonitorExporterExtensions.cs:56). A section holding only blank placeholders stays silent (MMCA.Common/CHANGELOG.md:28). - The boundary is gated twice, matched on package id. The
EnforceCloudVendorBoundarybuild target fails a core project whosePackageReferenceis an Azure, AWS or vendor MassTransit transport package, or an adapter package (MMCA.Common/Source/Build/MMCA.Common.LayerEnforcement.targets:108-111, target at:126, errors at:144and:146).CloudVendorIsolationTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Layering/CloudVendorIsolationTestsBase.cs:14) fails a core assembly that names a vendor type or references a vendor or adapter assembly (:26-27,:30-31). - Exactly two references are exempt, both database engines rather than cloud services.
Microsoft.EntityFrameworkCore.Cosmosin MMCA.Common.Infrastructure, because Cosmos DB is one of the framework's data engines and bringsMicrosoft.Azure.Cosmostransitively, so that namespace is allowed in Infrastructure alone; andAspire.Hosting.Azure.CosmosDBin MMCA.Common.Aspire.Hosting, the AppHost-side emulator resource that never reaches a service process (MMCA.Common.LayerEnforcement.targets:115-120, exemptions at:130-131;CloudVendorIsolationTestsBase.cs:21). - S3-compatible, not S3-only.
AddS3FileStoragetargets Amazon S3, Cloudflare R2, MinIO and the GCS XML API from oneFileStoragesection (MMCA.Common/CHANGELOG.md:12;MMCA.Common/Source/Hosting/MMCA.Common.Aws/Storage/S3FileStorageSettings.cs:13,S3FileStorageService.cs:21). Request checksum calculation and response checksum validation both default toWHEN_REQUIREDrather than the AWS SDK default (S3FileStorageSettings.cs:59,:65,:72;AwsServiceCollectionExtensions.cs:35), the posture that keeps the non-AWS stores working. - The SQS transport works within SQS limits.
AmazonSqsTransportregisters under the provider nameAmazonSqs(MMCA.Common/Source/Hosting/MMCA.Common.Aws/Messaging/AmazonSqsTransport.cs:37,:40) and usesMassTransit.AmazonSQSat 8.5.11, the same version as every other MassTransit pin (MMCA.Common/Directory.Packages.props:125-130). SQS caps a message delay at 900 seconds (MaxDelaySeconds,AmazonSqsTransport.cs:43), so an unconfigured redelivery list defaults to[60, 300, 900](:55, applied at:128) and a configured interval over 900 failsAddBrokerMessaginginstead of being silently shortened (:159-161). TheMMCA-*ambient headers (:46) travel in the MassTransit envelope body, not as SQS/SNS message attributes (:30-33,:134-135), so they never compete for the 10-attribute cap (MMCA.Common/CHANGELOG.md:12). - The adapters are exercised against real infrastructure. The
aws-integrationCI job runs MMCA.Common.Aws against LocalStack through Testcontainers (MMCA.Common/.github/workflows/ci.yml:846-847, test run at:883); it is not a required check (MMCA.Common/CHANGELOG.md:17).
Rationale
- A consumer pays only for the cloud it runs on. With the SDKs in adapters, a self-hosted or AWS deployment restores, audits and ships no Azure package, and an Azure deployment carries no AWS one.
- The AI provider shape already worked. Optional vendor code in its own package, selected by name
and refused at boot when the name has no registration (
AiProviderValidator.cs:6-12), is the model the cloud adapters follow, so a reader of one boundary already knows the other. - A string provider is open to extension without editing the core. A closed enum forced every new
transport into MMCA.Common.Infrastructure;
IBrokerTransportlets an adapter add one, andBrokerTransportPolicieskeeps retry, redelivery and backpressure identical across all of them. - Unchanged configuration keeps the move to code. A deployment's environment variables, Bicep and Key Vault names stay as they are; only the package reference and the registration calls change.
- Silent degradation is the failure mode the tripwires remove. Before the move, the core wired each integration itself. After it, a host that keeps its configuration but loses the call would otherwise start with the null storage, the null push sender, no telemetry export, no vault secrets or a per-replica key ring. Failing startup with the call named turns that into a deploy-time error.
Trade-offs
- Every Azure host gains calls it did not need before.
AddAzureMonitorExporter()afterAddServiceDefaults()andAddAzureServiceBusTransport()beforeAddBrokerMessagingare new, and the Key Vault, DataProtection and push calls are renamed (MMCA.Common/UPGRADING.md:35). - Registration order matters in two places. The exporter must follow
AddServiceDefaults()(AzureMonitorExporterExtensions.cs:45-50), and an adapter transport must be registered beforeAddBrokerMessagingresolves it (DependencyInjection.Messaging.cs:148,:177). - The Cosmos engine still brings an Azure assembly into Infrastructure.
Microsoft.Azure.Cosmosarrives transitively throughMicrosoft.EntityFrameworkCore.Cosmos, so the core is cloud-neutral in its cloud-service integrations, not free of every Azure assembly (MMCA.Common.LayerEnforcement.targets:115-118). - SQS redelivery is shorter than the framework default. The framework default is
[60, 600, 3600](MessageBusSettings.cs:212); SQS cannot delay an hour, so its default stops at 900 seconds (AmazonSqsTransport.cs:55). - The AWS tier is not a required check. A regression in MMCA.Common.Aws shows as a red
aws-integrationjob without blocking a merge (MMCA.Common/CHANGELOG.md:17).
Alternatives rejected
Weighed and rejected on 2026-10-24.
- Leave the Azure SDKs in the core. Simplest for the Azure-hosted consumers, but every other consumer keeps restoring, auditing and shipping Azure packages it never calls, and a test project keeps pulling the Service Bus emulator stack through MMCA.Common.Testing. Revisit only if every consumer is permanently Azure-only.
- Add AWS to the core next to Azure. It would serve AWS deployments without new packages, but it doubles the dependency footprint every consumer carries instead of removing it, and the closed provider enum would grow with each cloud. Revisit never: it worsens the problem this record solves.
- One package per capability per vendor (for example separate Blob, Key Vault, Service Bus and Monitor packages). It gives the finest-grained dependency control, but multiplies the package count the dual-registry release publishes and versions in lockstep, and the maintenance that comes with each. One package per vendor with one call per capability keeps the opt-in at the call while the package count stays at one per cloud. Revisit if one capability's SDK becomes heavy enough that hosts using the others object to carrying it.
Consequences
- The change is breaking. The renames, moved types and new calls are mapped once in
MMCA.Common/UPGRADING.md:35(the old-to-new table and the seven-step fix per host) rather than restated here;Microsoft.Data.SqlClient.Extensions.Azurenow arrives only through MMCA.Common.Azure, so any process whose SQL connection string usesAuthentication=Active Directory ...references that package (MMCA.Common/CHANGELOG.md:25). - A configured redelivery list now replaces the default. Making the provider a string came with
RedeliveryIntervalsSecondsbecomingnullwhen unconfigured (MessageBusSettings.cs:234), which fixes the binder appending a configured list to the old initializer, so[0]turns second-level redelivery off as documented (BrokerTransportPolicies.cs:124-125;MMCA.Common/CHANGELOG.md:21). - A new cloud is an adapter, not a core change. A transport implements
IBrokerTransport, storage implementsIFileStorageService, and a tripwire message names the call, with the build and architecture gates holding the core free of the new SDK. - The residual Cosmos dependency stays. The two engine exemptions are the only vendor references in the core; adding a third is a deliberate edit to both gates, not a drift either can miss.