Architecture Decision Record
ADR-135: One Application Namespace Isolates Shared Infrastructure Keyspaces
Status
Accepted (2026-10-26).
Context
Several framework subsystems write into infrastructure that more than one deployed application can point at: Redis (distributed cache entries, distributed-lock keys, distributed rate-limit counters, the SignalR backplane) and the message broker (consumer queue and endpoint names). A keyspace that is shared by accident is a correctness and a security problem, not a tidiness one, and nothing in the infrastructure itself warns when two applications collide in it.
The resolver's own remarks record the failure this decision closes (SEC-Common-53,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:14-19).
Two MMCA hosts on one Redis or one broker shared every keyspace by default: the cache prefix
defaulted to empty, the lock keys reused it, the broker endpoint prefix was optional, and the SignalR
backplane had no prefix knob. Because NotificationHub ships in the framework, its backplane channel
name is identical in every consumer, so a user-targeted push or a live-channel event published in one
application reached the other application's clients holding the same numeric user id
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:50-55).
The same shape put two applications' consumers on the same queue names
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:90-93).
Until now the decision lived only as revision lines spread across the ADRs that own each keyspace (see Related). This record states it once.
Decision
One resolver, ApplicationNamespace, names the shared-infrastructure keyspaces of four subsystems
(distributed cache and lock keys, distributed rate-limit counters, the SignalR backplane, broker
endpoint names), and isolation is the default rather than an opt-in. The Redis output-cache store
is not one of them (see Trade-offs).
- One resolver, one precedence.
ApplicationNamespace.Resolve(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:53) returns the explicitApplication:Namespacevalue when set (:31,:55-59), otherwise the host application name fromIHostEnvironment.ApplicationNameor, when no environment is passed or its name is blank, theapplicationNameconfiguration key (:40,:61-65), otherwise the constantapp(:34,:67). - The value is sanitized for every consumer at once.
Sanitizereplaces each run of characters outside[a-zA-Z0-9_-]with-, trims leading and trailing dashes, and returnsappwhen nothing survives (ApplicationNamespace.cs:42-43,:77-83), so one value is legal as a Redis key part, a SignalR channel prefix and a MassTransit queue name. - Never empty. Every branch of
Resolveyields a non-empty string (ApplicationNamespace.cs:46-48,:67), so a host that configures nothing still gets its own keyspace. - Distributed cache and distributed lock keys.
CacheKeyNamespace.From(IServiceProvider)uses an explicitCache:KeyPrefixwhen set and otherwise"{namespace}:"(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:73-88, theResolvecall at:83). That one namespace is passed toDistributedCacheService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:67), toRedisDistributedLock(:95-96) and to the opt-inHybridCacheService(:173). The in-processMemoryCacheServicetakes no prefix, because its keyspace is private to the process (:76). - Distributed rate-limit counters.
RateLimitKeyNamespaceusesCache:KeyPrefixwithout its trailing colon when set, otherwise the resolved namespace (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs:220-226, theResolvecall at:224), and qualifies each Redis counter key as{namespace}:{scope}:{key}(:275). - SignalR Redis backplane. When a
redisconnection string is present, the backplaneChannelPrefixis the resolved namespace (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:56-58). There is no subsystem-specific override here:Application:Namespaceis the only knob. - Broker endpoint names. Unless
MessageBus:PreserveDefaultEndpointNamesis set, the endpoint name formatter takesMessageBus:EndpointPrefixwhen set and otherwise the resolved namespace (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:98-105, theResolvecall at:101; the settings atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:80,:90). - When to set the override. Set
Application:Namespaceonly when two hosts of the SAME application must share a keyspace (a web head and a worker draining one backplane), and keep it different everywhere else (ApplicationNamespace.cs:22-25). The subsystem keys (Cache:KeyPrefix,MessageBus:EndpointPrefix) remain for pinning one keyspace alone (CacheKeyPrefix.cs:36-41).
Adoption differs per consumer. Every MMCA.ADC service pins Application:Namespace to adc and
Cache:KeyPrefix to adc:
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/appsettings.json:38, :45; likewise the
Identity, Engagement and Notification services at appsettings.json:46, :58, :72), so its four
services deliberately share one keyspace. Every MMCA.Store service pins its own value
(store-sales, store-identity, store-catalog) with a matching Cache:KeyPrefix
(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:52, :55; the Identity and
Catalog services at appsettings.json:42, :45), so a project rename cannot move a keyspace
(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:39-43). Both ADC and Store
also set MessageBus:PreserveDefaultEndpointNames to true in every service
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/appsettings.json:62,
MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:69), so no deployed ADC or
Store host prefixes its broker endpoints: their live queues and subscriptions keep MassTransit's
default names, and each runs one application per broker namespace
(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:45-50). MMCA.Helpdesk sets
none of these keys, so it takes the default resolution.
Rationale
- One name, not four defaults. Each subsystem already had, or needed, its own prefix setting. Deriving all of them from one value means a host that is isolated for caching is also isolated on the backplane and on the broker, and an operator who needs two hosts to share a keyspace changes one key rather than remembering four.
- The safe state needs no configuration. The host application name is distinct per deployed app
and is available without configuration (
ApplicationNamespace.cs:22-23), so isolation does not depend on an operator remembering a setting; the collision that the default used to permit was silent. - Sharing is the deliberate act. Two hosts of one application that must cooperate (a backplane they both drain, a lock both contend on) are the case that needs a decision, so that case carries the explicit setting.
- Tested as a contract.
ApplicationNamespaceTestspins the precedence, the host-key fallback, the never-empty rule, per-application distinctness, and the cache default and its explicit override (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Configuration/ApplicationNamespaceTests.cs:31,:41,:49,:57,:65,:84,:94).
Trade-offs
- Adopting the default renames resources. Cache and lock keys gain a prefix (a cold cache after
deploy), the backplane moves channel, and an unset
EndpointPrefixyields prefixed queue names; the CHANGELOG entry tells hosts to setApplication:Namespacewhere two hosts of one application must share a keyspace and to drain old broker queues on cutover (MMCA.Common/CHANGELOG.md:2367-2374).MessageBus:PreserveDefaultEndpointNameskeeps MassTransit's unprefixed names for a deployment that cannot rename its queues and subscriptions (DependencyInjection.Messaging.cs:95-97). - Two hosts of one application need the override. If they run under different application
names, the default separates keyspaces that are meant to be shared, so a backplane message or a
lock held by one is invisible to the other until
Application:Namespaceis set on both. - The fallback is shared. A host whose application name is unavailable resolves to
app(ApplicationNamespace.cs:34), and two such hosts on one Redis or broker share a keyspace again. The backplane and broker call sites pass noIHostEnvironment(DependencyInjection.Notifications.cs:56,DependencyInjection.Messaging.cs:101), so they read the name from theapplicationNameconfiguration key (ApplicationNamespace.cs:40,:61-65) and depend on the host having populated it; a host that leaves the key unset and sets noApplication:Namespaceresolves toappthere. - The output cache is not covered.
AddRedisOutputCachingregisters the Redis output-cache store with a connection string and no instance name or namespace (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Caching/RedisCachingExtensions.cs:99), so output-cache entries and tag sets on a shared Redis are not isolated per application. It is adopted byMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:202andMMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:105. - Sanitization can merge names. Distinct raw names that differ only in characters outside
[a-zA-Z0-9_-]sanitize to the same value (ApplicationNamespace.cs:81).
Related
- ADR-019 (Layered Rate Limiting): owns the distributed rate-limit counter keyspace qualified by the namespace.
- ADR-021 (Consumer-Side Inbox): owns the consumer endpoint and
queue names whose default
MessageBus:EndpointPrefixis the namespace. - ADR-024 (Two-Channel User Notifications): owns
NotificationHuband the SignalR Redis backplane channel the namespace prefixes. - ADR-039 (Live channel push): owns the live-channel events that ride the same notification hub backplane.
- ADR-108 (Cross-Replica Mutual Exclusion via
IDistributedLock): owns the Redis lock keyspace, which shares the cache key namespace. - ADR-114 (Internal Commands): owns durable deferred work on the outbox machinery; it links here for the application-namespace decision, and no internal-command code calls the resolver directly.