↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Onboarding guide

14. Module System, Composition & Configuration

What this chapter covers. This is the wiring layer, the code that turns a pile of layered assemblies into a running host. It answers three questions a new host author asks: how does the process discover and assemble its modules?, in what order does DI get built so the decorators wrap the right handlers?, and where do the dozens of appsettings.json knobs land as typed objects? The cast is small but load-bearing: the IModule contract and its IModuleSeeder sidekick; the ModuleLoader that discovers and Kahn-sorts them; the two extension(IServiceCollection) DependencyInjection composition roots (Application and Infrastructure) plus the MmcaApplicationPipelineBuilder and the DecoratorPipelineSeal marker that together make the ordering rule enforceable instead of merely documented; the AssemblyReference / ClassReference assembly anchors that Scrutor and the architecture tests pin to; the two data-source attributes (UseDataSourceAttribute, UseDatabaseAttribute); the settings types this chapter owns, ApplicationSettings / ModulesSettings / ModuleSettings / QueryCachePipelineSettings in Application plus the Infrastructure sections MessageBusSettings, SmtpSettings, SchedulerSettings, PushNotificationSettings, NativePushSettings and FileStorageSettings (the persistence, auth, caching and outbox sections the same root binds are documented in group 07, group 08, group 09 and group 04); the delivery implementations those sections switch between, null-object by default and Azure-backed when configured (NullNativePushSender, NullPushDeviceRegistrar, AzureNotificationHubNativePushSender, AzureNotificationHubDeviceRegistrar, NativePushPayloads, NullFileStorageService, AzureBlobFileStorageService, ImageSharpImageProcessor); the cross-replica locking pair (RedisDistributedLock, InProcessDistributedLock); the host-scoped job runner (ScheduledJobRunner) and the PeriodicBackgroundService base the other fixed-interval sweeps derive from; the broker-side consumers a host composes (FaultIntegrationEventConsumer<TEvent> with its FaultEndpointConfigurator, UpcastingIntegrationEventConsumer<TEvent>) with the boot-time EventUpcasterStartupValidator; the scoped TenantContext and the ambient-context carriers that let a hop off the request call stack act as the identity that queued the work (AmbientOrigin with its published OriginSnapshot and RestoreHandle, ScopedUserOverride, ImpersonatingCurrentUserService, ConsumerOriginRestore); the internal-command queue a host composes for deferred work (IInternalCommand, IInternalCommandScheduler, IInternalCommandAdministration with its InternalCommandDeadLetter projection and the InternalCommandNameAttribute that keeps a rename from orphaning rows, plus the one ready-made command the framework ships on it, IDeleteBlobInternalCommand handled by DeleteBlobInternalCommandHandlerBase<TCommand>); the two resolvers that answer a composition question a plain settings property answered badly, ApplicationNamespace and SmtpTransportSecurity; and the shared Users use-case bases that two apps compose into their own Identity modules. The detailed per-type sections follow; this overview shows how the pieces fit together at runtime.

[Rubric §7, Microservices Readiness] (assesses whether modules can be enabled, disabled, and deployed independently with minimal coupling) is the lens this whole chapter is built around: the module system is the boundary that lets MMCA.ADC run as either a single monolith host or four separate service processes from the same module code, configuration-switched. [Rubric §12, Performance & Scalability] and [Rubric §3, Clean Architecture] also run throughout: composition is where the inward-pointing dependency rule gets physically realized (Infrastructure references Application references Domain references Shared), and where cross-cutting concerns are registered once for every module rather than per-feature. The ADRs that explain why this shape exists are ADR-059 (the module contract itself), ADR-008 (service-extraction topology), ADR-006 (database-per-service) and ADR-014 (the decorator pipeline whose ordering rule this chapter enforces).

The module contract and the boundary it creates

A module is the unit of cohesion above a feature slice: Conference, Engagement, Identity, Notification. Each one implements IModule (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModule.cs:7): a display Name (IModule.cs:12), an optional Dependencies list of other module names (IModule.cs:17), a RequiresDependencies flag (IModule.cs:23), and one Register(services, configuration, applicationSettings) method (IModule.cs:28) that wires all of that module's services. Three of those five members are default-implemented on the interface (Dependencies => [] at IModule.cs:17, RequiresDependencies => false at IModule.cs:23, and an empty-bodied RegisterDisabledStubs at IModule.cs:34), so a leaf module is just Name plus Register. ADC's modules are deliberately thin: ConferenceModule (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:15) is barely more than a dozen lines. Its Register forwards to an AddConferenceModule(applicationSettings) extension method (ConferenceModule.cs:28-29), and its RegisterDisabledStubs (ConferenceModule.cs:21-25) registers a DisabledSessionBookmarkValidationService and a DisabledEventLiveValidationService so a host that disables Conference still has those cross-module contract types resolvable in DI.

That last detail is the crux of the extraction boundary. RegisterDisabledStubs plus RequiresDependencies is what makes one module assembly boot in two topologies. When Conference runs in its own service the Engagement module is disabled in that host's config, yet Conference's GetSessionBookmarkCountHandler still needs Engagement's IBookmarkCountService, so the disabled Engagement module contributes a stub and the host then replaces that stub with a typed gRPC client pointed at the real Engagement process (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:393-397, :349). Application code never learns which path it got; the transport choice lives entirely at the composition edge (ADR-008). [Rubric §2, Design Patterns] applies here: this is a clean strategy / null-object pairing (real service, disabled stub, remote client) rather than scattered if (moduleEnabled) checks.

Discovery and Kahn-ordered registration

ModuleLoader (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:16) is the engine. Its one DiscoverAndRegister overload (ModuleLoader.cs:59-65) takes the assemblies to scan as a required parameter: there is no AppDomain-scan convenience, and the parameter doc says why, because an AppDomain scan only sees assemblies already loaded, so a module assembly that is referenced but not yet touched by any code path would be silently absent from discovery (ModuleLoader.cs:49-54). The scan guards each GetTypes() call so one bad reference never aborts the whole pass: a ReflectionTypeLoadException keeps every type that did load, so one unloadable type does not make the assembly's modules vanish, and any other failure drops just that assembly; both shapes log at Error, because a module that silently fails to register is an outage that looks like a configuration choice (ModuleLoader.cs:69-95). It then instantiates every concrete IModule via Activator.CreateInstance (ModuleLoader.cs:97-100) and every concrete IModuleSeeder into a case-insensitive dictionary keyed by ModuleName (ModuleLoader.cs:102-105).

Ordering is Kahn's topological sort (ModuleLoader.cs:282-332) over the modules' declared Dependencies. Kahn's algorithm is BFS over a dependency graph: compute each module's in-degree (count of unprocessed dependencies, ModuleLoader.cs:287), build the reverse adjacency list of dependents (ModuleLoader.cs:290-303), seed a queue with the zero-in-degree modules (ModuleLoader.cs:306-307), and as each is emitted decrement its dependents' in-degrees, enqueuing any that reach zero (ModuleLoader.cs:316-320). A dependency name that was never discovered is skipped rather than treated as an edge (ModuleLoader.cs:297-298); validation catches it later. If fewer modules come out than went in, the remainder form a cycle and the loader throws with the offending names (ModuleLoader.cs:324-329). The payoff is an ordering where a module's DI registrations always exist before any dependent registers, which matters because the CQRS decorator pipeline (below) can only wrap handlers that are already in the container.

For each sorted module the loader checks ModulesSettings.IsModuleEnabled (ModuleLoader.cs:112). A disabled module gets RegisterDisabledStubs called, has the exact service descriptors that call added recorded (ModuleLoader.cs:118-120), and is listed in DisabledModuleNames (ModuleLoader.cs:122); an enabled one runs ValidateModuleDependencies then RegisterEnabledModule (ModuleLoader.cs:126-127) and contributes its seeder if one was found (ModuleLoader.cs:129-132). Registration is also where per-module configuration is loaded by convention: before calling module.Register(...) the loader adds modules.{name}.json and, when an environment name is passed, modules.{name}.{environment}.json to the configuration builder (ModuleLoader.cs:185-189), so a module can ship its own config file. Dependency validation (ModuleLoader.cs:136-169) is microservice-aware: a dependency that is disabled in-process but listed in that consumer's ModuleSettings RemoteDependencies is treated as satisfied remotely, and only a RequiresDependencies = true module with a genuinely unsatisfied dependency throws, with an error message that spells out the three ways to fix it (ModuleLoader.cs:150-158). Every step emits a [LoggerMessage]-generated structured log (ModuleLoader.cs:334-356), so the startup log tells you exactly which modules loaded, in what order, and how long each took (ModuleLoader.cs:191-194).

Trusting configuration is not quite enough, so the loader offers a second, post-build gate: ValidateRemoteDependencies(serviceProvider) (ModuleLoader.cs:212-234) walks every remote-declared dependency, resolves each service type the disabled module's stub had registered, throws when one does not resolve at all, and logs a warning when it still resolves to the stub implementation (ModuleLoader.cs:236-257). That converts a forgotten gRPC-client registration from a first-request mystery into a startup failure. It is a capability, not a habit: today only MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Modules/ModuleLoaderTests.cs calls it, and no ADC or Store host does. [Rubric §13, Observability & Operability] is the category at play in both this method and the log messages above.

The loader is not called from inside AddApplication(), and a host no longer constructs it by hand either. AddModuleHost in the API layer (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:51) binds and validates ApplicationSettings and ModulesSettings (ModuleHostExtensions.cs:61-76), constructs the ModuleLoader with the host's bootstrap logger, registers it as a singleton (ModuleHostExtensions.cs:78-82), and hands back a ModuleHostContext whose RegisterModules method is the discovery step (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostContext.cs:66-77). Discovery deliberately does not run inside AddModuleHost: it has to land inside the application pipeline described next, and its position relative to a host's other steps is a per-host decision (ModuleHostContext.cs:12-19). After discovery the loader also drives startup data through SeedAllAsync (ModuleLoader.cs:266-272), which invokes each collected IModuleSeeder.SeedAsync in registration order. IModuleSeeder (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModuleSeeder.cs:8) is a two-member interface, ModuleName (IModuleSeeder.cs:13, matched case-insensitively against an IModule Name) and SeedAsync(serviceProvider, cancellationToken) (IModuleSeeder.cs:18), deliberately separate from IModule so seeding runs after the whole container is built and a real IServiceProvider exists.

The two composition roots, and the pipeline that seals them

Service registration itself lives in two static DependencyInjection classes, each using a C# extension(IServiceCollection services) block (see primer §4 for the extension(T) syntax, and ADR-106 for why the public DI surface is written that way). The Application root (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:24) exposes AddApplication() (DependencyInjection.cs:32), which registers the core singletons (IDomainEventDispatcher at :34, IEventUpcasterRegistry at :40, INavigationMetadataProvider at :42, IEntityQueryPipeline at :43) and pulls in the framework's own FluentValidation validators by assembly (DependencyInjection.cs:48). Each of those four singletons is registered with TryAddSingleton, so a host that registered its own first keeps it. The upcaster registry is registered unconditionally on purpose: with no upcasters it is an empty registry whose operations are the identity, so both delivery paths can depend on it without a null check (DependencyInjection.cs:35-39, ADR-090), and individual upcasters accumulate through AddEventUpcaster<TSource, TTarget, TUpcaster>() (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:81).

The Application root also owns ScanModuleApplicationServices<TAssemblyMarker>() (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:28-30) and its Assembly-typed overload (DependencyInjection.ModuleScanning.cs:46), the Scrutor convention scan every module's AddXModule call makes: domain-event and integration-event handlers as singletons (DependencyInjection.ModuleScanning.cs:51-63), DTO mappers, the opt-in IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType> projectors, request mappers and update appliers scoped (DependencyInjection.ModuleScanning.cs:65-103), command and query handlers scoped (DependencyInjection.ModuleScanning.cs:105-115), validators from the module assembly (DependencyInjection.ModuleScanning.cs:117), and finally a reflection pass that TryAdds a CommandRequestValidator<,> for every command implementing ICommandWithRequest<T> (DependencyInjection.ModuleScanning.cs:119-135) so an explicit validator still wins.

The order of these calls is a hard contract in exactly one respect, and it is the reason AddApplicationDecorators() (MMCA.Common.Application/DependencyInjection.cs:114) must come last. Decorators are registered with Scrutor's TryDecorate, which wraps existing registrations, so every module's concrete handlers must already be in the container or there is nothing to wrap, and a handler registered afterwards runs completely unwrapped with nothing failing at startup to say so (DependencyInjection.cs:189-192). Rather than leaving that as a comment, the framework enforces it. AddApplicationDecorators() finishes by calling SealPipeline, which TryAdds a singleton instance of the private marker DecoratorPipelineSeal (DependencyInjection.cs:150, :294, :307-308), and every registration entry point that contributes handlers (ScanModuleApplicationServices, AddEntityCrud, AddEntityUpdateVerb, AddEntityUpdate, AddMmcaApplicationPipeline itself) opens with ThrowIfPipelineSealed, which scans the collection for that marker and throws a message naming the offending call (DependencyInjection.cs:310-319). The whole sequence is available as one call, AddMmcaApplicationPipeline(configure) (DependencyInjection.cs:207-216): it runs AddApplication(), invokes the callback with a MmcaApplicationPipelineBuilder (MMCA.Common/Source/Core/MMCA.Common.Application/MmcaApplicationPipelineBuilder.cs:12) whose ScanModule<TAssemblyMarker> (:27), ScanModules(params Assembly[]) (:41) and Register(Action<IServiceCollection>) (:62) steps are where module discovery, gRPC clients and broker messaging go, and then closes with AddApplicationDecorators(). The builder's constructor is internal precisely so it cannot be created outside that call, because outside it nothing keeps the decorators last (MmcaApplicationPipelineBuilder.cs:9-14). A third guard, VerifyDecoratorPipeline() (DependencyInjection.cs:244), is never called automatically: it is the hook an architecture fitness test calls after replaying a host's own registration sequence, and it reports every ICommandHandler<,> / IQueryHandler<,> descriptor that still carries an implementation type, which after decoration is proof that nothing wrapped it (DependencyInjection.cs:253-285). MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:68 and MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ApplicationPipelineCompositionTests.cs:160 are its callers today.

AddApplicationDecorators also encodes the execution order via TryDecorate's reverse-registration rule (registered innermost first, MMCA.Common.Application/DependencyInjection.cs:131-148), so the command pipeline ends up FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> handler and the query pipeline FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> handler (ADR-014). The rationale for each position is written out in the method's own doc comment (MMCA.Common.Application/DependencyInjection.cs:85-112): authorization sits outside caching so a denied request neither reads nor populates the cache, validation sits outside the transaction so an invalid command never opens one, and the timeout budget sits inside validation and outside the transaction so it covers the database work and cancels it rather than leaving it open. The decorator types themselves (for example FeatureGateCommandDecorator<TCommand, TResult>, AuthorizationCommandDecorator<TCommand, TResult> and TimeoutCommandDecorator<TCommand, TResult>) are documented in the CQRS-pipeline chapter; this chapter owns only the wiring of them. An optional MiniProfiler pair is registered separately by an opt-in AddApplicationProfiling() (MMCA.Common.Application/DependencyInjection.cs:160), never by AddApplicationDecorators(). [Rubric §6, CQRS & Event-Driven] and [Rubric §1, SOLID] (open/closed) live here: cross-cutting behavior is added by wrapping, not by editing handlers.

The Infrastructure root (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:47) exposes AddInfrastructure(configuration) (DependencyInjection.cs:57), which binds most of the settings types in this chapter, registers the three save interceptors as singletons (DependencyInjection.cs:61-70), the persistence stack (data-source service and resolver, entity registry, the scoped and singleton context factories, repositories, unit of work, DependencyInjection.cs:59-130), Scrutor-scans the framework's own EF entity configurations (DependencyInjection.cs:133-138), adds caching (DependencyInjection.cs:140), registers the refresh session store and its retention sweep behind the same flag that maps the table (DependencyInjection.cs:168-186), and enrolls a startup validator that fails the host on a bad upcaster graph (DependencyInjection.cs:204-205). The outbox hosted services are conditional: the method reads MessageBusSettings, calls EnsureOutboxAvailableForProvider, and adds OutboxProcessor plus OutboxCleanupService only when IsOutboxEnabled, otherwise registering OutboxDisabledNoticeService so the choice is visible in the log (DependencyInjection.cs:215-226, ADR-100). The OutboxMessages table stays mapped either way, so flipping the flag is never a migration (DependencyInjection.cs:209-214). It closes by composing the internal-command queue (AddInternalCommands, DependencyInjection.cs:233), running AddServices() (:235) and then layering the impersonation decorator over whatever ICurrentUserService that left registered (:237-244), both described two sections below. Optional add-ons sit alongside the root: the class is partial, split by concern across sibling files, so AddCommonHybridCache (DependencyInjection.Caching.cs:141), AddScheduledJobs (DependencyInjection.Jobs.cs:37), AddTwoFactorAuthentication (DependencyInjection.Auth.cs:40), AddEmailConfirmation (:68), AddLegalAcceptance (:104), AddStoredPermissionGrants (:139), AddAuditTrail (DependencyInjection.Jobs.cs:108), AddMultiTenancy (DependencyInjection.cs:277), AddServices (:295), AddEntityConfigurationAssembly (:349), AddStronglyTypedIds (:377), AddNotificationInfrastructure (DependencyInjection.Notifications.cs:26), AddPushNotifications (:41), AddNativePushNotifications (:82), AddAzureBlobFileStorage (:114), AddBrokerMessaging (DependencyInjection.Messaging.cs:43) and the typed-client helper AddTypedServiceClient<TInterface, TImplementation>(serviceName) (:155) that swaps an in-process abstraction for an HTTP transport.

AddCaching (MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:26) also registers this chapter's one cross-replica primitive: an IDistributedLock that resolves to RedisDistributedLock when the host has an IConnectionMultiplexer registered, and to the warn-once InProcessDistributedLock otherwise (MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:88-102). The Redis implementation is the standard SET key token NX PX ttl acquire (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:67) with a compare-and-delete release script (RedisDistributedLock.cs:36-37, evaluated at :114), handing back a RedisLockHandle that releases exactly its own acquisition, once (RedisDistributedLock.cs:91, :101). That release runs after the guarded work has already committed, so a Redis fault there is logged rather than thrown and the key falls back to expiring on its TTL, and a release that finds the key already gone logs that the section was not exclusive (RedisDistributedLock.cs:106-129); the fallback is exclusive only inside one process, which is exactly what its warning says out loud (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:74-75), and its InProcessLockHandle simply removes the key from a ConcurrentDictionary (InProcessDistributedLock.cs:79-91). A multi-replica host that registers no Redis client therefore gets one execution of the guarded section per replica. [Rubric §29, Resilience] and [Rubric §12, Performance & Scalability] both touch this pair: the degradation is deliberate, announced, and never silent.

Opt-in platform features are composed the same way

Four capabilities are registered beside the roots rather than inside them, and they share one discipline: registering a feature is not the same as turning it on. AddScheduledJobs(configuration) (MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:37) binds SchedulerSettings and enrolls ScheduledJobRunner through TryAddEnumerable rather than AddHostedService, precisely so two modules calling it cannot start two runners racing for the same rows (DependencyInjection.Jobs.cs:44-48); individual IScheduledJob implementations arrive through AddScheduledJob<TJob>() (DependencyInjection.Jobs.cs:72), each registered scoped so the runner can resolve it in a fresh scope per execution. AddAuditTrail(configuration) (DependencyInjection.Jobs.cs:108) binds AuditTrailSettings, adds the AuditTrailSaveChangesInterceptor and the AuditTrailReader that projects AuditTrailEntryDTO rows, and contributes its own retention job (DependencyInjection.Jobs.cs:110-124), which only actually runs when the host also enabled the scheduler. AddMultiTenancy(configuration) (DependencyInjection.cs:277) binds TenancySettings and registers TenancySettingsValidator as an IValidateOptions<TenancySettings> through TryAddEnumerable (DependencyInjection.cs:279-287); note what it does not do, because that is the design: TenantSaveChangesInterceptor and ITenantContext, whose scoped implementation TenantContext reports IsResolved = false until a request calls SetTenant (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/TenantContext.cs:25, :28, registered at DependencyInjection.cs:305) unless it was constructed inside a background hop, in which case it starts with the tenant that hop restored (TenantContext.cs:19, see the deferred-work section below), are registered unconditionally by AddInfrastructure and AddServices and stay inert until a tenant is resolved, so the framework can never sit in the half-wired state where entities carry ITenantEntity but the write-side guard is off (DependencyInjection.cs:67-70, :295, ADR-073). Finally AddUserDataExportSection<TSection>() (MMCA.Common.Application/DependencyInjection.Extensibility.cs:38) accumulates IUserDataExportSection contributors into the one IEnumerable the export handler fans out over. The governing ADRs are ADR-074, ADR-075, ADR-073 and ADR-076; [Rubric §30, Compliance & Data Governance] is the category the trail and the export both serve.

ScheduledJobRunner (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:39) is the one of these with real runtime machinery, and it reuses the outbox's idioms wholesale. It is a BackgroundService that returns immediately (after one log line) when Scheduler:Enabled is false (ScheduledJobRunner.cs:85-90), waits out a 15-second startup delay so migrations finish first (ScheduledJobRunner.cs:69, :95), then loops: run one cycle that reconciles the ScheduledJobs rows against the registered jobs, claims due rows with a lease, executes and stamps the outcome (ScheduledJobRunner.cs:102-107, :213), and smart-waits until the earliest upcoming occurrence capped at Scheduler:PollingIntervalSeconds (ScheduledJobRunner.cs:120-128). A claim attempt is a single filtered ExecuteUpdateAsync against the still-unleased predicate, so two racing replicas both issue it and exactly one matches (ScheduledJobRunner.cs:435-445); it returns a JobClaim carrying either this replica's lock token or null when another replica won the row (ScheduledJobRunner.cs:447), which is what makes an occurrence run exactly once across a scaled host. The persisted row is ScheduledJobEntry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobEntry.cs:20), deliberately not an auditable entity: it is framework bookkeeping with an explicit claim lease instead of a concurrency token, and it is host-scoped, living in the Default data source only (ScheduledJobEntry.cs:8-19), on whichever relational engine Scheduler:DataSource names (SQL Server unless set, and never Cosmos because the table is relational, SchedulerSettings.cs:45-52), which each cycle resolves before touching the table (ScheduledJobRunner.cs:223-224). SchedulerMetrics (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerMetrics.cs:16) publishes the MMCA.Common.Scheduler meter with a run counter tagged by job and outcome (SchedulerMetrics.cs:28-31), a duration histogram (SchedulerMetrics.cs:39-42) and a schedule-lag histogram (SchedulerMetrics.cs:50), the same shape BrokerMetrics (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:18) uses for the MMCA.Common.Broker meter's fault and circuit-open counters (BrokerMetrics.cs:30, :43). So [Rubric §13, Observability & Operability] is covered by instruments rather than by log scraping.

Two more composition-time shapes sit beside those registrations. The first is the base class the other periodic sweeps derive from: PeriodicBackgroundService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hosting/Background/PeriodicBackgroundService.cs:20) factors the fixed-interval pattern into one place, an IsEnabled gate evaluated once at startup (PeriodicBackgroundService.cs:38), a StartupDelay defaulting to 15 seconds (:31), then ExecuteCycleAsync on an Interval loop (:25, :50) whose failing cycle is logged and never kills the loop (through an overridable LogCycleFailure, :45-46, so a derived sweep can keep its own message and the event id its dashboards and tests key on, :40-43), with every wait going through an injected TimeProvider so a test can drive it on a fake clock (PeriodicBackgroundService.cs:7-11); its own doc comment records that the outbox processor deliberately does not use it, because a signal-driven smart wait is not a fixed interval (:13-15). The second is the broker side of AddBrokerMessaging. Every consumer wired through RegisterIntegrationEventConsumer<TEvent> also gets a FaultIntegrationEventConsumer<TEvent> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/FaultIntegrationEventConsumer.cs:26), which turns the Fault<TEvent> MassTransit publishes when a consumer exhausts its retry policy into one structured Error log plus a broker.fault.count metric, and which never throws, because a fault consumer that faulted would publish Fault<Fault<TEvent>> and keep re-entering itself (FaultIntegrationEventConsumer.cs:7-23). When a host passes an endpoint configuration so that one consumer gets its own queue, the fault consumer receives the same configuration through FaultEndpointConfigurator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:189), a private wrapper that forwards every setting unchanged except an explicit Name, which it suffixes with "-fault" (IntegrationEventConsumerExtensions.cs:16, :191-194) so the two consumers never share a queue (IntegrationEventConsumerExtensions.cs:54-56, applied at :176). A retired contract is bound instead to UpcastingIntegrationEventConsumer<TEvent> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/UpcastingIntegrationEventConsumer.cs:32), which upcasts each message to its terminal contract through IEventUpcasterRegistry and dispatches the handlers registered for that contract, so handlers are written once against the newest event type while producers still publishing the old one keep being delivered, with deduplication still keyed on the original message id (UpcastingIntegrationEventConsumer.cs:13-29, ADR-090); registering both consumers for one type is the documented mistake, since they would compete for one queue and run the handlers twice (UpcastingIntegrationEventConsumer.cs:20-22). The upcaster graph those hops depend on is checked at boot by EventUpcasterStartupValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/EventUpcasterStartupValidator.cs:20), an IHostedService that AddInfrastructure enrolls through TryAddEnumerable (MMCA.Common.Infrastructure/DependencyInjection.cs:204): resolving the registry is the check, because its constructor rejects a duplicate source, a self-mapping and a cycle, so a misconfigured host fails to start instead of dead-lettering events hours later (EventUpcasterStartupValidator.cs:7-17, :23-29).

Deferred work, and the context that travels with it

The last two things AddInfrastructure composes are a queue for work a request wants done later, and the machinery that lets whatever drains a queue act as the identity that filled it. AddInternalCommands (MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:146, called at DependencyInjection.cs:233) binds InternalCommandsSettings, registers the origin capture, the scheduler and the operator surface scoped (DependencyInjection.Jobs.cs:160-161, :166), and then takes the outbox's posture deliberately: the table is mapped either way, and InternalCommands:Enabled decides only whether the processor and its retention sweep run (DependencyInjection.Jobs.cs:174-177) or a disabled-notice hosted service says so once in the log (:179, rationale at :130-139).

IInternalCommand (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommand.cs:38) is the whole application-side contract, a marker over ICommand<Result> with no second handler interface: a deferred command is executed by the same ICommandHandler<TCommand, TResult> a synchronous one is, so every decorator (feature gate, authorization, logging, validation, timeout, transaction) applies to the deferred execution exactly as it does inline (IInternalCommand.cs:11-16). The two rules that follow from a row in a table are stated on the interface itself: the payload must round-trip through System.Text.Json, and execution is at-least-once because a host that dies between the handler's commit and the row's stamp re-runs the row once the lease expires, so handlers must be idempotent (IInternalCommand.cs:19-29). IInternalCommandScheduler (IInternalCommandScheduler.cs:16) writes the row on the caller's own unit of work: scheduling from inside an ITransactional command commits with the aggregate change or rolls back with it (IInternalCommandScheduler.cs:9-14), which is the outbox's atomicity guarantee applied to work the host wants to do rather than to a message it wants to publish. With a transaction active the row is only enrolled, and when no later save follows the transactional pipeline saves it just before the commit; with none active the row is saved at once, flushing whatever else is pending on that context (IInternalCommandScheduler.cs:34-43). Both overloads return Result<Guid> (IInternalCommandScheduler.cs:45-48, :59-62), the handle the administration surface takes. A row stores the command's assembly-qualified CLR name unless InternalCommandNameAttribute (InternalCommandNameAttribute.cs:25) declares a stable identity, the internal-command twin of EventNameAttribute on the outbox side, with the same drain-then-rename caveat because the attribute changes only what new rows store (InternalCommandNameAttribute.cs:12-17). IInternalCommandAdministration (IInternalCommandAdministration.cs:17) is the operator half: count the pending backlog, page the dead letters as InternalCommandDeadLetter rows (IInternalCommandAdministration.cs:99) whose command payload is deliberately not projected because it can carry personal data (:86-89), requeue them (attempts reset, the lease and dead-letter stamp cleared, LastError kept on purpose, :48-53), and purge completed rows on demand. Every method returns a Result, because an unknown source name is an expected failure an admin screen renders rather than an exception. [Rubric §13, Observability & Operability] and [Rubric §29, Resilience] are the categories: without this surface, a command that exhausted its attempts has no way back into execution except hand-edited production SQL.

The framework ships one ready-made use of the queue, for the case an inline call handles worst: deleting a blob that a committed change left orphaned. IDeleteBlobInternalCommand (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IDeleteBlobInternalCommand.cs:21) adds a single BlobName (IDeleteBlobInternalCommand.cs:24) to IInternalCommand, because a delete run in the post-commit tail is best effort (a storage outage, a deploy or a crash leaks the blob with nothing left that knows about it) while a row scheduled on the same unit of work survives all three (IDeleteBlobInternalCommand.cs:9-13). An implementation is a small record carrying only the name, with an InternalCommandNameAttribute so a later rename cannot strand rows in flight (IDeleteBlobInternalCommand.cs:16-18). DeleteBlobInternalCommandHandlerBase<TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/DeleteBlobInternalCommandHandlerBase.cs:28) is the idempotent handler the at-least-once rule asks for: a successful delete completes the row, a NotFound answer is already the state the command asks for and so also returns Result.Success() instead of retrying forever, and any other failure is returned unchanged so the processor's backoff retries it (DeleteBlobInternalCommandHandlerBase.cs:45-64, rationale at :13-18). A consumer derives one sealed subclass per command, so the handler scan registers a closed ICommandHandler<TCommand, Result> that the decorators wrap, and overrides only BlobKind (default "Blob", DeleteBlobInternalCommandHandlerBase.cs:38) to label its log lines (:20-23). ADC does this twice, for avatars (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommandHandler.cs:20) and for session assets (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/DeleteSessionAssetBlob/DeleteSessionAssetBlobInternalCommandHandler.cs:20); Store has no subclass. The storage side is ADR-045.

Context propagation is the other half, and it is one helper rather than one per mechanism. AmbientOrigin (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:19) is the single place the framework captures and restores the four values a hop off the call stack needs (user id, roles, tenant, correlation id), so a deferred command, an outbox row and a broker message all carry the same set the same way (AmbientOrigin.cs:9-17). Roles travel as one comma-separated value truncated to the column width, MaxRolesLength = 512 (AmbientOrigin.cs:38, :40-70), and the flattener is null-tolerant on purpose, because ICurrentUserService.Roles is a default interface member that hand written test doubles routinely leave unstubbed and a null there must read as "no roles", never as a capture that throws inside a save (AmbientOrigin.cs:45-51). The capture sites are the internal-command origin capture (InternalCommandOriginCapture, .../Persistence/InternalCommands/InternalCommandOriginCapture.cs:34), the outbox writer (.../Persistence/DbContexts/Factory/DbContextFactory.cs:158) and the broker publisher (.../Messaging/BrokerMessageBus.cs:105); the restore sites are the internal-command processor (.../Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:483), the OutboxProcessor (.../Persistence/Outbox/Processing/OutboxProcessor.cs:508) and the consumers.

Restore (AmbientOrigin.cs:156) does two things. It writes the captured values onto the scope it was given, tenant first because the tenant routes the scoped context factory, then the principal, then the correlation id (AmbientOrigin.cs:169-178); the principal is always overwritten or cleared so a reused scope cannot let one message's user answer for the next, while the tenant is set only when the scope has not already resolved a different one, because a tenant cannot change within a scope (AmbientOrigin.cs:117-127, :194-203). And it publishes what the scope ended up with as an OriginSnapshot (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:252) on an AsyncLocal (AmbientOrigin.cs:24, read through Current at :32), returning a RestoreHandle (AmbientOrigin.cs:240) whose Dispose puts back whatever origin was published before (AmbientOrigin.cs:187, :242). The publication exists because integration event handlers are singletons that open their own scope from the root factory, a scope the direct restore never reaches; so TenantContext, ScopedUserOverride and CorrelationContext seed themselves from Current once, when constructed (AmbientOrigin.cs:133-144, TenantContext.cs:19, ScopedUserOverride.cs:30, CorrelationContext.cs:13), and a handler's own scope still runs as the original interaction. The snapshot is a five-value record (user id, roles, tenant, correlation id, authentication type) whose BuildPrincipal rebuilds the claims principal the same way the direct restore does (AmbientOrigin.cs:252-261). Suppress (AmbientOrigin.cs:231) withdraws the published origin behind the same handle type for the one place an explicit choice must win over an enclosing hop, TenantDataSourceTargets CreateTenantScope (.../Persistence/DataSources/TenantDataSourceTargets.cs:105). Every restore site holds the handle for the whole row or message (using var origin, for example UpcastingIntegrationEventConsumer.cs:68), so the next one cannot inherit it.

A restore lands in ScopedUserOverride (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ScopedUserOverride.cs:27), a per-scope carrier holding the principal that did not arrive over HTTP (ScopedUserOverride.cs:30, set at :36). The principal itself is never a live ambient value: a scope created during a hop copies the published origin once, at construction, and owns that copy from then on, so nothing is shared or mutated across scopes, and an HTTP request (which never publishes an origin) resolves an empty carrier and reads straight through to the HTTP principal (ScopedUserOverride.cs:17-26). It exposes Clear() (:50) because the outbox processor keeps one scope for a whole batch and without an explicit clear a row raised by a user would answer for every later row raised by the system (ScopedUserOverride.cs:42-49). The carrier is read by ImpersonatingCurrentUserService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ImpersonatingCurrentUserService.cs:20), which decorates rather than replaces whatever ICurrentUserService the host registered: with no override every member reads straight through to the inner service, so an HTTP request behaves exactly as it did before the decorator existed (ImpersonatingCurrentUserService.cs:25-35), and claim values are parsed in the invariant culture exactly as CurrentUserService writes them (:46-50). AddInfrastructure applies it with Scrutor's TryDecorate after AddServices() has run its own TryAddScoped, guarded on the presence of the ScopedUserOverride registration so a host whose modules each call AddInfrastructure does not stack one wrapper per call (DependencyInjection.cs:237-244). The broker side of the same restore is ConsumerOriginRestore (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/ConsumerOriginRestore.cs:24), the one step of the restore that knows about a transport: its Apply reads the headers BrokerMessageBus stamped, hands them to AmbientOrigin.Restore, and returns the restore handle the consumer holds for the whole consume (ConsumerOriginRestore.cs:34-57). It is shared by every consumer that binds a queue, both IntegrationEventConsumer<TEvent> (.../Messaging/Consumers/IntegrationEventConsumer.cs:65) and UpcastingIntegrationEventConsumer<TEvent> (UpcastingIntegrationEventConsumer.cs:68), each calling it before the inbox is touched because under database-per-tenant the restored tenant routes the inbox store (UpcastingIntegrationEventConsumer.cs:63-67), so a message reaching a retired contract is restored exactly as one reaching the current contract. The user id is transported as text and parsed back rather than read as a typed header, because a broker is free to widen an integer header (Azure Service Bus hands back a long), and a malformed value yields no identity rather than failing the consume (ConsumerOriginRestore.cs:37-48). [Rubric §11, Security] is the lens: the identity a background execution acts as is captured from the request that queued it, never assumed or elevated.

Assembly anchors

Several pieces of machinery need a Type whose Assembly identifies a layer: Scrutor's FromAssemblyOf<T>() scans, FluentValidation's AddValidatorsFromAssemblyContaining<T>(), and NetArchTest's per-package anchor. That is what the AssemblyReference / ClassReference pairs are, one per layer (Domain, Application, Infrastructure), each a trivial static class AssemblyReference holding Assembly / AssemblyName statics (MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:8-12) beside a non-static class ClassReference (AssemblyReference.cs:18) for the places a generic constraint forbids a static type. AddApplication uses the Application pair for the common validators (MMCA.Common.Application/DependencyInjection.cs:48) and AddInfrastructure uses the Infrastructure pair to scan entity configurations (MMCA.Common.Infrastructure/DependencyInjection.cs:133-138). They are deliberately behavior-free; their whole job is to name an assembly for the scanning and governance tooling. A related, test-only assembly anchor lives in CreateMigrationProofTable (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests.MigrationsFixture/CreateMigrationProofTable.cs:24): a real EF migration for the framework's one SQLite context kept in its own tiny library, because EF selects migrations by the [DbContext] they carry and a migration compiled into the test assembly would make every test that names that assembly see a pending migration (CreateMigrationProofTable.cs:11-21).

Configuration binding, the Settings family

Everything a host operator tunes arrives as a strongly-typed settings object bound from an appsettings.json section, each carrying a static readonly string SectionName so the section name lives next to the shape it binds. The pattern in AddInfrastructure is uniform: services.AddOptions<T>().Bind(configuration.GetSection(T.SectionName)).ValidateDataAnnotations().ValidateOnStart() (for example ConnectionStringSettings at MMCA.Common.Infrastructure/DependencyInjection.cs:72-75), so misconfiguration fails fast at startup rather than lazily on first use (ADR-070). There are no ISettings facade interfaces over these types: consumers take IOptions<T> (or the concrete singleton, for the two dictionary-shaped ones) directly. [Rubric §13, Observability & Operability] and [Rubric §15, Best Practices] apply: ValidateOnStart plus DataAnnotations ranges (for example OutboxSettings BatchSize is [Range(1, 1000)] with a default of 50, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxSettings.cs:15-17) turn a config typo into an immediate, descriptive boot failure.

The Application-layer settings drive composition itself. ApplicationSettings (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:8) carries UseMiniProfiler, MaxPageSize (default 500), MaxExportRows ([Range(1, 10_000_000)], default 100,000) and DatabaseInitStrategy (default "Migrate", ApplicationSettings.cs:14-43), and is passed by value into every IModule.Register. ModulesSettings is a Dictionary<string, ModuleSettings> bound from the "Modules" section (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModulesSettings.cs:7-10) whose IsModuleEnabled and IsDependencyRemote helpers (ModulesSettings.cs:18-32) the loader queries; note that a module absent from configuration is treated as disabled (ModulesSettings.cs:19). ModuleSettings carries the per-module Enabled flag (default true, MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModuleSettings.cs:9) and the RemoteDependencies list (ModuleSettings.cs:38) that flips a dependency from "in-process" to "satisfied by an extracted service", with a worked monolith-after-extraction config example on the property itself (ModuleSettings.cs:24-35). One more Application-layer settings class exists for a layering reason rather than a composition one: QueryCachePipelineSettings (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/QueryCachePipelineSettings.cs:20) exposes the single Cache:PopulateLockTimeout knob the caching query decorator needs, because that decorator lives in Application and Application cannot reference Infrastructure where CacheSettings binds the rest of the same section (QueryCachePipelineSettings.cs:4-18, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheSettings.cs:16-20); AddCaching binds both from the same section so they cannot drift (MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:43-51).

The Infrastructure settings cover the rest of the platform. AddInfrastructure binds them here even though several belong to other chapters by subject (persistence in group 07, auth in group 08, caching in group 09, the outbox in group 04): the binding discipline is this chapter's, the types are theirs. Starting with the database ones, ConnectionStringSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettings.cs:12) has no required property at all: SQL Server is the default engine, but a host may run entirely on SQLite or Cosmos, and may declare its databases through the DataSources section instead (ConnectionStringSettings.cs:3-11). What is required is that the host can reach some database, and that rule spans two sections, so it cannot be a data annotation: ConnectionStringSettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/ConnectionStringSettingsValidator.cs:30) is registered as an IValidateOptions<ConnectionStringSettings> via TryAddEnumerable (MMCA.Common.Infrastructure/DependencyInjection.cs:82-83) and passes only when the top-level section names a database on any engine or one DataSources entry does (ConnectionStringSettingsValidator.cs:47-53, :56-72), failing with a message that lists both shapes because which one is missing depends on whether the host is a single-database monolith or a database-per-module one (ConnectionStringSettingsValidator.cs:38-45). DataSourcesSettings with its per-entry DataSourceEntrySettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceEntrySettings.cs:19) is the logical-to-physical source map for database-per-service, built directly from Get<Dictionary<...>> rather than through the options pipeline (MMCA.Common.Infrastructure/DependencyInjection.cs:85-89) because a root-level dictionary section does not bind that way, with a constructor that rejects an empty or reserved "Default" key (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourcesSettings.cs:27-40).

The rest of the platform sections follow the same discipline: MessageBusSettings and its MessageBusProvider enum (InProcess / RabbitMq / AzureServiceBus, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:236-252) that AddBrokerMessaging switches on, short-circuiting entirely for InProcess (MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:52-55) and otherwise Replace-ing both IMessageBus and IEventBus with their broker-backed counterparts (DependencyInjection.Messaging.cs:95, :101), with the whole local-emulator path for Azure Service Bus quarantined in ServiceBusEmulatorSupport (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/ServiceBusEmulatorSupport.cs:32) and entered only when the resolved connection string carries UseDevelopmentEmulator=true, a token a real namespace never has (ServiceBusEmulatorSupport.cs:12-18, ADR-066); OutboxSettings (batch size, retries, polling and processing intervals, lease, retention) consumed by the OutboxProcessor; PersistenceSettings, whose single CommandTimeoutSeconds defaults to the 30 seconds the framework applied implicitly before the section existed (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/PersistenceSettings.cs:21-22); the JWT/JWKS group (JwtSettings, the JwtSigningAlgorithm enum (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSigningAlgorithm.cs:21), and JwksSettings, whose Enabled defaults to false so an HMAC-only deployment does not start advertising an RSA key set by accident, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwksSettings.cs:22-25); and the delivery channels, SmtpSettings, PushNotificationSettings, NativePushSettings (ADR-044) and FileStorageSettings (ADR-045). The last two follow a stricter rule on purpose: their Add* methods bind the section and then no-op when it is disabled or incomplete (MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:87-93 and :119-130), so a host registers them unconditionally and a deployment switches the channel on by configuration alone. What makes that safe is that AddServices has already registered a working null-object default behind each of those contracts: NullNativePushSender and NullPushDeviceRegistrar (DependencyInjection.cs:331-332), and NullFileStorageService beside the real ImageSharpImageProcessor (DependencyInjection.cs:336-337). Application code therefore always resolves something, and a configured section merely upgrades the registration to AzureNotificationHubNativePushSender plus AzureNotificationHubDeviceRegistrar (DependencyInjection.Notifications.cs:98-99), whose wire payloads and 20-tag chunking live in the pure helper NativePushPayloads (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/NativePushPayloads.cs:10, :13), or to AzureBlobFileStorageService (DependencyInjection.Notifications.cs:139). [Rubric §2, Design Patterns] again: a null object, not a nullable dependency. AddPushNotifications does not share the no-op rule: it always adds SignalR, adds the Redis backplane when a redis connection string is present, and replaces the in-app notification sender and live-channel publisher with their SignalR-backed implementations (DependencyInjection.Notifications.cs:48-66). One binding is deliberately elsewhere: JwtSettings is bound by the API layer's AddCommonAuthentication (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.Authentication.cs:135-136), so a host that skips authentication never pays for a JWT section it does not have.

The three opt-in feature sections follow the same shape with one extra rule worth reading closely. SchedulerSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerSettings.cs:16) and AuditTrailSettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSettings.cs:16) each make Enabled (default false) the single gate over both the behavior and whether the backing table is mapped into the model at all (SchedulerSettings.cs:10-14, AuditTrailSettings.cs:10-15), so a host that never opts in keeps exactly the migrations it had. Their tunables are ranged the same way (PollingIntervalSeconds default 30 and LeaseSeconds default 300, SchedulerSettings.cs:28-43; RetentionDays default 90, AuditTrailSettings.cs:37-38), and per-job retiming lives in ScheduledJobOverrideSettings bound from Scheduler:Jobs:{Name} (SchedulerSettings.cs:60, :66-74). TenancySettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:50) adds the collection-binding subtlety: ResolutionOrder and ExcludedPathPrefixes bind as empty lists and the framework reads EffectiveResolutionOrder / EffectiveExcludedPathPrefixes instead (TenancySettings.cs:76, :106), because the configuration binder ADDS to a pre-populated collection rather than replacing it, so a non-empty default would leave a host running the framework's entries as well (TenancySettings.cs:41-48). Its per-tenant database routing is pure configuration: TenantEntrySettings (TenancySettings.cs:121) keys TenantDataSourceOverrideSettings (TenancySettings.cs:138) by physical data source name, and TenancySettingsValidator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettingsValidator.cs:24) fails the boot when an override names a source that does not exist, when it declares no connection string at all, or when the resolution order names TenantResolutionStrategy Host, which is defined but not implemented (TenancySettings.cs:21-27). Every one of those failures is a misconfiguration that would otherwise surface as silent cross-tenant behavior, which is exactly the bug class tenancy exists to prevent, so it is worth a failed boot. [Rubric §11, Security] is the category there.

Two composition questions are answered by a small resolver rather than by a settings property, because a bound property cannot distinguish "configured false" from "never configured" and the safe answer differs by environment. ApplicationNamespace (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:28) is the one name every piece of shared infrastructure is namespaced by: Redis cache keys, distributed-lock keys, the SignalR Redis backplane channel prefix and the broker's endpoint prefix (ApplicationNamespace.cs:7-11). It resolves the explicit Application:Namespace key when set (:31, :53-58), otherwise the host's application name, otherwise the "app" fallback (:34), and sanitizes the result to characters that are safe in a Redis key, a channel name and a queue name (:42-43). Composition reads it where the shared resource is wired: AddPushNotifications for the backplane channel prefix (MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:59), AddBrokerMessaging for the endpoint prefix (DependencyInjection.Messaging.cs:82), and the cache-key builder (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:83). The default is per-application rather than empty because two hosts pointed at one Redis or one broker otherwise share every keyspace, and NotificationHub ships in the framework, so its backplane channel name is identical in every consumer and a user-targeted push in one application reaches the other application's clients holding the same numeric user id (ApplicationNamespace.cs:13-26). SmtpTransportSecurity (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Mail/SmtpTransportSecurity.cs:26) has the same three-step shape for TLS: the explicit Smtp:EnableSsl key, then configuration, then secure unless the host says it is Development (SmtpTransportSecurity.cs:29, :37-49). SmtpEmailSender resolves it once at construction (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Mail/SmtpEmailSender.cs:57) through the overload that also logs one startup warning when a non-Development host turned TLS off for a configured relay, so that decision is visible in the logs of the deployment that made it (SmtpTransportSecurity.cs:51-58). Development keeps the permissive default because local development targets MailDev, which offers no TLS (SmtpTransportSecurity.cs:21-25). [Rubric §11, Security] covers both: the secure value is the one you get by configuring nothing.

The two routing attributes

Two attributes, both in MMCA.Common.Infrastructure, both Inherited = true so they ride down a configuration class hierarchy, encode where an entity is stored declaratively: the per-entity half of the database-per-service strategy (ADR-006). UseDataSourceAttribute (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/UseDataSourceAttribute.cs:12-17) names the engine (DataSource: SQL Server, PostgreSQL, Cosmos or SQLite) and is carried by the provider-specific configuration base classes, so choosing a base class chooses the engine with no change to the entity (see primer §2). UseDatabaseAttribute (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/UseDatabaseAttribute.cs:21-26) names the logical database on that engine; its documented resolution order (UseDatabaseAttribute.cs:9-14) is the attribute value, then the module name derived from the entity's namespace (the segment before Domain), then "Default". The persistence runtime (EntityDataSourceRegistry, DataSourceResolver, DbContextFactory) reads these attributes up front to map each entity to a physical source; those types are documented in the persistence chapter, but the markers that feed them live here because they are part of how a module declares its composition.

Shared user use-case bases: composition in the other direction

The chapter's last family is composition at the handler level rather than the container level. ADC and Store each own an Identity module, and thirteen of their account use cases had drifted into line-identical copies (or would have), so the workflow was hoisted into abstract bases that each app subclasses: ChangePasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:42, ADR-032), ChangePreferencesHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:23), GetUserPreferencesHandlerBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21), DeleteUserHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:62), the erasure workflow behind ADR-005, ExportUserDataHandlerBase<TUser, TQuery> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49), the data-subject access workflow, and the password-recovery pair ForgotPasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:36) and ResetPasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:41), which run the token issue-and-redeem flow described in ADR-091 and collapse every rejection to one Auth.InvalidResetToken error so the endpoint reveals nothing about which addresses hold accounts (ResetPasswordHandlerBase.cs:20-24). Both password-writing bases also revoke every live refresh session on the account once the new credential is saved, so a stolen refresh chain cannot outlive the remediation the user just performed (ChangePasswordHandlerBase.cs:30-32, :113; ResetPasswordHandlerBase.cs:36-38, :106; ADR-097).

Six more bases cover the account-security workflows a host opts into with AddTwoFactorAuthentication (MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:40) and AddEmailConfirmation (:68). The two-factor four are a deliberate two-step enrollment: BeginTwoFactorEnrollmentHandlerBase<TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/BeginTwoFactorEnrollmentHandlerBase.cs:35) mints a secret and stores it without activating the factor, so an authenticator that was never really keyed cannot lock the owner out, and re-running it on an enrolled account fails rather than silently re-keying a live secret (BeginTwoFactorEnrollmentHandlerBase.cs:14-25); ConfirmTwoFactorEnrollmentHandlerBase<TCommand> (ConfirmTwoFactorEnrollmentHandlerBase.cs:29) verifies a time-based code (a recovery code cannot confirm an enrollment, because the step exists to prove the app holds the secret) and generates the first recovery-code set in the same call that activates the factor, so an account is never in the state "second factor on, no way back in" (ConfirmTwoFactorEnrollmentHandlerBase.cs:14-23); DisableTwoFactorHandlerBase<TCommand> (DisableTwoFactorHandlerBase.cs:29) demands a live factor to turn the factor off, because otherwise whoever holds a stolen access token can strip the control that would have stopped them, and here a recovery code is accepted since a user who lost the device is exactly who needs it (DisableTwoFactorHandlerBase.cs:13-23); and RegenerateRecoveryCodesHandlerBase<TCommand> (RegenerateRecoveryCodesHandlerBase.cs:30) replaces the whole set rather than appending to it, so a list the user believes is copied stops working (RegenerateRecoveryCodesHandlerBase.cs:14-23). The email-confirmation pair mirrors the password-recovery pair's privacy rule: SendEmailConfirmationHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/EmailConfirmation/SendEmailConfirmationHandlerBase.cs:40) returns success for every outcome, including an unknown, already-confirmed, throttled or unsendable address, so the response carries no signal about which addresses hold accounts (SendEmailConfirmationHandlerBase.cs:20-27), and ConfirmEmailHandlerBase<TUser, TCommand> (ConfirmEmailHandlerBase.cs:33) collapses every rejection to one Authentication.InvalidConfirmationToken error and consumes the token before the save, matching ResetPasswordHandlerBase, because leaving it live until the write succeeds opens a replay window (ConfirmEmailHandlerBase.cs:16-26). Like the other bases these take the app's command record as a type parameter and read it only through this group's small contracts, and the aggregate constraint is the capability the workflow needs rather than a concrete type (IEmailConfirmableUser on ConfirmEmailHandlerBase.cs:37).

Each base is generic in the app's User aggregate and in the app's own command or query record, and reads that record only through the small contracts in this group: IUserScopedRequest (UserId, MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedRequest.cs:8), IUserScopedCommand<out TRequest> (adds the embedded payload, IUserScopedCommand.cs:13), and IUserOwnedRequest (adds CurrentUserId and CurrentUserRole, IUserOwnedRequest.cs:8). The commands stay app-side precisely because the two apps disagree on their pipeline attributes: ADC marks the password-change command ICacheInvalidating and Store does not (ChangePasswordHandlerBase.cs:19-24). Note that IUserScopedCommand<out TRequest> is deliberately not ICommandWithRequest<TRequest>: implementing the latter also opts a command into automatic CommandRequestValidator registration, which is a per-app decision, so implementing this one alone changes no pipeline behavior (IUserScopedCommand.cs:6-11).

The export base is the most instructive of the thirteen, because it is where the container-level and handler-level composition meet. It authorizes through UserOwnershipRule.CheckOwnership (ExportUserDataHandlerBase.cs:81-86), reads the account through GetReadRepository (ExportUserDataHandlerBase.cs:92-93), asks the subclass for the app-specific subject snapshot (:100), and then fans out sequentially over every injected IUserDataExportSection (:103-108), sequential on purpose because the sections share one scoped DbContext and because registration order is the published order of the document. A section that throws degrades to an envelope reporting Available = false rather than failing the export, which is the contract UserDataExportSectionResult encodes with its Complete / Unavailable factories (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:73, :91), with the caller-safe default text in UserDataExportSectionDefaults (IUserDataExportSection.cs:105-113). The result is a UserDataExportDTO that is PII by design and is therefore never logged or cached (ExportUserDataHandlerBase.cs:42-45).

Around those bases sit the small shared pieces: UserOwnershipRule (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserOwnershipRule.cs:21), the owner-or-privileged-role decision returning a Forbidden Error or null (UserOwnershipRule.cs:38) with the privileged-role test passed in already evaluated because each app owns its own role vocabulary (UserOwnershipRule.cs:15-19), while each base is itself a partial class that declares its own [LoggerMessage] audit lines (for example ChangePasswordHandlerBase.cs:131); SoftDeletedUserValidator<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/SoftDeletedUserValidator.cs:20), which answers ISoftDeletedUserValidator with one query-filter-bypassing existence check (SoftDeletedUserValidator.cs:31-34); and GetUserPreferencesQuery (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5), the one request record that was byte-identical in both apps and so became shared. [Rubric §34, Architecture Governance & Documentation] and [Rubric §1, SOLID] are the categories here: the variation points are explicit generic parameters and protected virtual hooks, so an app extends behavior without forking the workflow.

End-to-end: one host's boot

Reading MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs top to bottom shows the whole chapter cooperating. The host calls AddInfrastructure(builder.Configuration) (Program.cs:348), opts into the scheduler and the audit trail (Program.cs:352, :350), then calls AddModuleHost with the single assembly that declares ConferenceModule and a Serilog-backed ModuleLoader logger (Program.cs:372-375), and passes the resulting ModulesSettings to AddAPI (Program.cs:377). The whole handler-contributing sequence then goes inside one AddMmcaApplicationPipeline call (Program.cs:409-415): step one is moduleHost.RegisterModules (module discovery), step two replaces the disabled Engagement stub with a real gRPC client (AddEngagementBookmarkCountClient()), and step three is AddBrokerMessaging with its integration-event consumers, so MessageBusSettings Provider decides whether IMessageBus stays in-process or becomes the MassTransit-backed broker. Because this is the Conference service, only the Conference module is Enabled in its configuration; every other discovered module takes the RegisterDisabledStubs path. The pipeline call closes with AddApplicationDecorators() and seals the collection, so the decorators wrap the now-registered Conference handlers and any later handler registration throws rather than running bare. Afterwards the host adds module health checks from the loader (Program.cs:433) and finally app.Services.InitializeDatabaseAsync(moduleHost.ApplicationSettings, moduleHost.ModuleLoader) (Program.cs:446) applies migrations and runs the module seeders the loader collected. The exact same module assemblies, dropped into a monolith host with every module Enabled, would Kahn-sort into one in-process graph with no gRPC clients, which is precisely the reversibility ADR-008 is after.

AssemblyReference

MMCA.Common.Application · MMCA.Common.Application · MMCA.Common/Source/Core/MMCA.Common.Application/AssemblyReference.cs:5 · Level 0 · class (static)

  • What it is: a tiny static class exposing the assembly that contains it plus that assembly's simple name, for use as a stable anchor when something needs to say "scan the assembly this type lives in." This section covers the Application-layer copy; the framework ships a near-identical copy in every layer package, and the copies that belong to other groups get their own sections there.

  • Depends on: System.Reflection.Assembly (BCL) only. No first-party dependencies, and that purity is why it sits at Level 0.

  • Concept introduced, assembly-marker types for convention scanning. [Rubric §2, Design Patterns] assesses whether recurring problems are solved with recognised patterns; the marker (or anchor) type is the idiomatic way to hand an Assembly to a scanner without coupling to an incidental concrete class. [Rubric §1, SOLID] (DIP): registration code depends on a deliberate, meaningless token rather than on typeof(SomeRandomHandler).Assembly, so renaming or moving any real type never breaks the scan. Repeating the identical AssemblyReference plus ClassReference pair in every package keeps each assembly self-describing with no cross-layer reference at all.

  • Walkthrough: two public static readonly fields resolved once at type initialization. Assembly is typeof(AssemblyReference).Assembly (MMCA.Common/Source/Core/MMCA.Common.Application/AssemblyReference.cs:7); AssemblyName is Assembly.GetName().Name with a ?? string.Empty null-coalescing fallback (AssemblyReference.cs:8), so the field is never null even if the runtime reports no simple name. The Application copy carries no XML doc; the Domain copy is byte-identical in body but documents the type as existing "for Scrutor assembly-scanning registration and architecture tests" (MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:5-7, fields at :10-11).

  • Why it's built this way: a purpose-built anchor decouples scanning from any business type, and one per package means an assembly can be named without referencing anything inside it. The per-module Application assemblies follow the same convention (see the module copies in group-22 and group-24).

  • Where it's used: as the assembly source for Scrutor scans and FluentValidation discovery, usually through its non-static companion (next section) because those helpers are generic.

  • Caveats / not-in-source: the architecture-fitness map does not route through AssemblyReference for this layer. CommonArchitectureMap pins one anchor type per package, and for six of the seven mapped layers that anchor is a real type (Result, BaseEntity<>, DomainEventDispatcher, ApplicationDbContext, ApiControllerBase, ResultGrpcExtensions); only the UI layer uses a dedicated marker, UISharedAssemblyReference (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:21-27).


AuditTrailEntryDTO

MMCA.Common.Application · MMCA.Common.Application.Auditing · MMCA.Common/Source/Core/MMCA.Common.Application/Auditing/AuditTrailEntryDTO.cs:12 · Level 0 · record (sealed)

  • What it is: the read-side projection of one recorded change in an entity's history: which entity, which property, the before and after values, the operation, who changed it, when, and under what correlation id. It is what IAuditTrailReader hands back, and it is deliberately smaller than the infrastructure row it projects.

  • Depends on: the solution-wide identifier alias UserIdentifierType (global using UserIdentifierType = int;, MMCA.Common/Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1, the convention recorded in ADR-048) and BCL types (Guid, DateTime, string). Nothing else first-party: the persisted counterpart AuditTrailEntry lives in Infrastructure and is never referenced from here, which is why the DTO sits at Level 0.

  • Concept introduced, the read-model DTO that deliberately narrows its source row. [Rubric §9, API and Contract Design] assesses whether the shape a consumer sees is designed rather than leaked: the class-level remarks state the intent outright, "deliberately minimal in v1: it carries what a 'who changed what, and when' view needs and nothing an infrastructure table happens to also store" (AuditTrailEntryDTO.cs:6-11). [Rubric §3, Clean Architecture]: declaring the DTO in the Application layer is what lets the reader contract live above Infrastructure while the EF entity stays below it, so no consumer of the trail takes a persistence dependency. [Rubric §30, Compliance, Privacy and Data Governance] is the sharpest edge here: values are the invariant string forms captured at save time, and a value that belonged to a property carrying PiiAttribute reads as the redaction placeholder on both sides (AuditTrailEntryDTO.cs:9-11), because the interceptor that writes the row substitutes PiiRedactor.RedactedToken for OldValue and NewValue before persisting (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:324-325). Personal data never reaches the table, so it cannot leak through this DTO either.

  • Walkthrough: ten init properties on a sealed record, so the type gets structural equality and immutability from the compiler. Five are required, and that is the contract: Id (:15), EntityType, the full CLR type name of the changed entity (:18), EntityKey, the invariant string form of its primary key (:21), Operation, one of Added / Modified / Deleted (:36), and ChangedOn, the UTC instant (:45). The nullable ones each encode a real case: PropertyName is null on the summary row of a create or delete (:23-27), OldValue and NewValue are null when there is no value on that side (:30, :33), ChangedBy is null when the save carried no identity such as a background service or a seeder (:38-42), and CorrelationId is null when the change was recorded outside a traced request (:47-48).

  • Why it's built this way: ADR-075 is the decision record for the trail feature. A record rather than a class means a test can compare two projections by value, and the required markers make the five facts that every row must carry a compile-time obligation rather than a runtime null check.

  • Where it's used: the return element of IAuditTrailReader.GetForEntityAsync (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IAuditTrailReader.cs:37), and materialized by the single implementation AuditTrailReader, which projects it directly inside the EF Select so only these ten columns leave the database (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailReader.cs:66-78).

  • Caveats / not-in-source: no shipped endpoint or page returns this DTO. The reader's own remarks say so explicitly, "the framework ships the read, not the exposure ... because who may see an entity's history is an application decision" (IAuditTrailReader.cs:11-14), so today the only non-test consumer is the reader implementation itself.


ClassReference

MMCA.Common.Application · MMCA.Common.Application · MMCA.Common/Source/Core/MMCA.Common.Application/AssemblyReference.cs:11 · Level 0 · class

  • What it is: the non-static companion to AssemblyReference: an empty, instantiable class used wherever a generic type parameter needs an assembly anchor and a static class will not satisfy the constraint.

  • Depends on: nothing first-party; nothing from the BCL beyond object.

  • Concept: the companion half of the marker pattern introduced under AssemblyReference. C# static classes cannot be used as generic type arguments, and the registration helpers that take a marker are constrained to an instantiable reference type: ScanModuleApplicationServices<TAssemblyMarker>() declares where TAssemblyMarker : class (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:169-170) and so does MmcaApplicationPipelineBuilder.ScanModule<TAssemblyMarker>() (MmcaApplicationPipelineBuilder.cs:27-28). ClassReference fills that slot without weakening AssemblyReference's static-ness. [Rubric §33, Developer Experience] assesses how conventional the inner loop is: one token (ScanModuleApplicationServices<ClassReference>()) is the entire registration ceremony a new module needs.

  • Walkthrough: a single body-less type declaration, public class ClassReference; (MMCA.Common/Source/Core/MMCA.Common.Application/AssemblyReference.cs:11). No members. Its only meaningful property is the assembly it belongs to, read by the scanner through typeof(TAssemblyMarker).Assembly (DependencyInjection.cs:171) before it hands that Assembly to the assembly-typed overload. The Domain copy documents the same role, "anchor type used for assembly resolution when AssemblyReference cannot be used (e.g., generic type constraints that require a non-static class)" (MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:14-18).

  • Why it's built this way: keeping a separate non-static anchor sidesteps the static-class generic-argument restriction while leaving AssemblyReference static (and therefore impossible to instantiate accidentally). Every module's Application assembly defines its own ClassReference, so each module scans itself by passing its local copy.

  • Where it's used: as the TAssemblyMarker argument in ScanModuleApplicationServices<TAssemblyMarker>(). All three ADC module composition roots call services.ScanModuleApplicationServices<ClassReference>() with their own local copy (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:139, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:89), as do Store's three (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/DependencyInjection.cs:53, .../Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:51, .../Sales/MMCA.Store.Sales.Application/DependencyInjection.cs:65) and Helpdesk's single module (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/DependencyInjection.cs:34). The framework root AddApplication() passes the Application-layer copy to AddValidatorsFromAssemblyContaining<ClassReference>() (DependencyInjection.cs:49).


DecoratorPipelineSeal

MMCA.Common.Application · MMCA.Common.Application · MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:294 · Level 0 · class (private, sealed)

  • What it is: a private, empty marker class that DependencyInjection registers into the service collection as a singleton instance the moment AddApplicationDecorators() finishes. Its presence in the descriptor list is the record that the CQRS decorator pipeline has been closed on that collection. It is never resolved and never depended on: its only job is to be there.

  • Depends on: nothing. It has no members and no base type beyond object.

  • Concept introduced, an ordering invariant enforced by a marker registration. The framework's one hard composition rule is that AddApplicationDecorators() must run after every handler registration, because Scrutor's TryDecorate can only wrap registrations that already exist (see DependencyInjection for the full pipeline). A handler registered afterwards resolves completely unwrapped: no feature gate, no authorization, no validation, no timeout, no transaction, and nothing fails at startup to say so. [Rubric §15, Best Practices & Code Quality] assesses whether a rule that has to be obeyed is enforceable rather than merely documented: instead of leaving the ordering as a comment, the framework leaves a token in the container and has every handler-contributing entry point check for it first. [Rubric §15, Best Practices and Code Quality]: making the marker private means no consumer can register, resolve, or fake it, so the signal cannot be forged from outside. [Rubric §14, Testability]: the same marker is what VerifyDecoratorPipeline() reads to tell "no handler is wrapped" apart from "some handler is not wrapped", which are two different failures with two different messages.

  • Walkthrough

    • Declaration: private sealed class DecoratorPipelineSeal; (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:294), sitting outside the extension(IServiceCollection) block beside the three private helpers that use it, with its role written into the XML doc (:694-698).
    • SealPipeline (:712-713): a one-line services.TryAddSingleton(new DecoratorPipelineSeal()). Registering an instance rather than a type means nothing is ever constructed lazily, and TryAdd means a second call is a no-op at the descriptor level. It is called at the very end of AddApplicationDecorators(), after all thirteen TryDecorate calls (:145).
    • IsPipelineSealed (:701-710): a linear scan of the collection comparing descriptor.ServiceType to typeof(DecoratorPipelineSeal). No provider is built, so the check is safe to run mid-composition and costs one pass over the descriptor list.
    • ThrowIfPipelineSealed (:715-725): the guard itself. When the marker is present it throws an InvalidOperationException naming the offending call and spelling out both remedies, move the call ahead of AddApplicationDecorators() or compose the whole sequence with AddMmcaApplicationPipeline(...) (:719-723). Every entry point that can contribute a handler opens with it: AddApplicationDecorators itself (:117, which is what makes a second call throw), ScanModuleApplicationServices (:182), AddEntityCrud (:335), AddEntityUpdateVerb (:397), AddEntityUpdate (:448) and AddMmcaApplicationPipeline (:614).
    • The two deliberate non-guards: AddCommandRequestValidator<TCommand, TRequest>() (:475) and AddApplicationProfiling() (:565) do not check the marker. A validator is not a handler, and the profiling pair is an outer decoration that is supposed to be applied on top of the closed pipeline; ApplicationPipelineCompositionTests pins that second case as allowed behavior (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ApplicationPipelineCompositionTests.cs:139-144).
    • Read by VerifyDecoratorPipeline() (:649-656): before inspecting any handler descriptor it asks IsPipelineSealed, and when the answer is no it throws the distinct "the pipeline was never closed" message rather than listing every handler as undecorated.
  • Why it's built this way: a marker in the collection is the only piece of state that travels with an IServiceCollection, which is what the guard needs, because the ordering rule is a property of that collection and not of the process. Registering it as an instance keeps the check allocation-free at resolution time (nothing ever resolves it) and keeps it invisible to consumers, which is why it is private rather than internal.

  • Where it's used: only inside DependencyInjection.cs, by the four helpers above. Its observable effects are the exception messages tested in ApplicationPipelineCompositionTests (:114-131, :182-188).

  • Caveats / not-in-source: the seal records that the decorators ran, not that they ran correctly. A host that registers a handler after AddApplicationDecorators() through a path with no guard, for example a bare services.AddScoped<ICommandHandler<...>, ...>(), still slips through; catching that is exactly what VerifyDecoratorPipeline() exists for, and it has to be called explicitly by a fitness test.


IModuleSeeder

MMCA.Common.Application · MMCA.Common.Application.Modules · MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModuleSeeder.cs:8 · Level 0 · interface

  • What it is: the Application-layer contract for seeding a module's initial data at startup. ModuleName declares ownership and SeedAsync receives an IServiceProvider so the seeder can resolve whatever it needs. Implementations are auto-discovered by ModuleLoader and run, in module-dependency order, after every module has registered.

  • Depends on: BCL only (Task, IServiceProvider, CancellationToken).

  • Concept introduced, seeding at the right layer. [Rubric §3, Clean Architecture] assesses whether each concern lives in the layer that owns it. An Application seeder populates data through service interfaces and never touches a DbContext; a seeder that genuinely needs direct EF access implements the Infrastructure-layer IDbSeeder instead. The IServiceProvider parameter is deliberate: the loader holds the seeder for the lifetime of composition, so passing the provider (rather than a concrete scoped dependency) is what lets the caller control scoping. [Rubric §7, Microservices Readiness]: seeding is a separate contract from IModule, so a module that needs no reference data implements nothing extra, and an extracted service seeds only its own module.

  • Walkthrough: string ModuleName { get; } (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModuleSeeder.cs:13) must match the corresponding IModule.Name so the loader can correlate a seeder to its module and keep it in topological position; Task SeedAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) (:18) is the single work method, and the XML doc states it is called only for enabled modules (:16).

  • Where it's used: discovered by ModuleLoader into a case-insensitive dictionary keyed by ModuleName (ModuleLoader.cs:102-105) and kept only when the matching module is enabled (ModuleLoader.cs:129-132). The actual invocation happens at host startup: DatabaseInitializationExtensions calls moduleLoader.SeedAllAsync(scope.ServiceProvider, cancellationToken) after schema initialization and tenant-database initialization (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:137). Seeding deliberately runs on the default scope only, not once per tenant, and the comment above the call gives the reason: no module declares which seeders apply per tenant, and running one twice against a shared database is worse than not running it per tenant at all (:107-110). The implementations are one per data-owning module: ConferenceModuleSeeder (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:13) and IdentityModuleSeeder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:15) in ADC; CatalogModuleSeeder (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/CatalogModuleSeeder.cs:11), SalesModuleSeeder (.../Sales/MMCA.Store.Sales.API/SalesModuleSeeder.cs:14) and IdentityModuleSeeder (.../Identity/MMCA.Store.Identity.API/IdentityModuleSeeder.cs:12) in Store.


InternalCommandDeadLetter

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommandAdministration.cs:99 · Level 0 · record (sealed)

  • What it is: the read-model row IInternalCommandAdministration.ListDeadLettersAsync hands back for one internal-command row whose retry attempts are exhausted and that will never run again without an explicit requeue.

  • Depends on: BCL types only (Guid, string, DateTime, the nullable string? and DateTime?). Nothing first-party, which is why it sits at Level 0 despite being the return shape of a Level 3 interface.

  • Concept introduced, a narrow read projection over an operational queue. [Rubric §13, Observability and Operability] assesses whether an operator gets exactly what a triage screen needs: DataSource names which relational source the row lives in, CommandType and ScheduledOn/CreatedOn say what was supposed to run and when, Attempts and LastError say why it stopped, and DeadLetteredOn says when it stopped mattering. [Rubric §3, Clean Architecture]: declaring the record in Application, the same layer as IInternalCommandAdministration, keeps the administration contract free of an Infrastructure/EF dependency.

  • Walkthrough: eight positional parameters on a sealed record (IInternalCommandAdministration.cs:99-107): Id (Guid), DataSource (string), CommandType (string), ScheduledOn (DateTime), CreatedOn (DateTime), Attempts (int), LastError (string?, null only when a row was dead-lettered without ever throwing, for example after MaxAttempts was lowered), DeadLetteredOn (DateTime?).

  • Why it's built this way: a positional record gives structural equality for free, which the type's only production consumer, ListDeadLettersAsync's materialization, does not need but a test comparing expected rows does.

  • Where it's used: the element type of Task<Result<IReadOnlyList<InternalCommandDeadLetter>>> returned by IInternalCommandAdministration.ListDeadLettersAsync (IInternalCommandAdministration.cs:42), implemented by InternalCommandAdministration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandAdministration.cs).

  • Caveats / not-in-source: no shipped UI or API endpoint renders this type in this workspace; it is consumed only by the administration implementation and its tests.


InternalCommandNameAttribute

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/InternalCommandNameAttribute.cs:25 · Level 0 · class (sealed)

  • What it is: an attribute that gives an IInternalCommand implementation a stable, explicit serialization identity, stored in place of its CLR type name so the command can be renamed or moved without breaking rows already persisted.

  • Depends on: System.Attribute only.

  • Concept introduced, a stable wire identity decoupled from the CLR type name. [Rubric §29, Reliability and Resilience]: a durable job queue persists a command's type identity alongside its serialized payload, and a CLR type name is not a safe long-term identity, a rename or a namespace move changes it and orphans every already-scheduled row. InternalCommandNameAttribute breaks that coupling: the constructor validates and stores an explicit Name (:58, Validated helper at :66-70) that a resolver looks up independently of the type's runtime name.

  • Walkthrough: public sealed class InternalCommandNameAttribute(string name) : Attribute (:25), a primary-constructor attribute with one member, public string Name { get; } = Validated(name); (:58). Validated (:66-70) calls ArgumentException.ThrowIfNullOrWhiteSpace(name) before returning it, so a blank identity fails at attribute-application time (effectively at the first resolution, since attribute arguments are evaluated lazily) rather than silently landing in storage where it could never be resolved back to a type.

  • Why it's built this way: rejecting an empty name at construction, rather than allowing it and failing later at lookup, moves the failure to the earliest possible point and keeps InternalCommandNameResolver free of a null/blank branch.

  • Where it's used: read by InternalCommandNameResolver (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandNameResolver.cs) to translate between a command's declared name and its CLR type, and by InternalCommandMessage (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandMessage.cs) when persisting a scheduled row. IInternalCommand implementations are expected to carry it, though the interface itself does not enforce that at compile time.

  • Caveats / not-in-source: nothing in the IInternalCommand interface contract requires this attribute; a command with no InternalCommandNameAttribute falls back to whatever InternalCommandNameResolver does for an unnamed type, not documented at this type's own declaration site.


MmcaApplicationPipelineBuilder

MMCA.Common.Application · MMCA.Common.Application · MMCA.Common/Source/Core/MMCA.Common.Application/MmcaApplicationPipelineBuilder.cs:12 · Level 0 · class (sealed)

  • What it is: the small builder handed to the callback of AddMmcaApplicationPipeline(...). It collects every registration that has to happen between AddApplication() and AddApplicationDecorators(), which is to say everything that puts a command or query handler into the container: module assembly scans, a ModuleLoader run, cross-service gRPC clients, broker messaging.

  • Depends on: DependencyInjection (it calls ScanModuleApplicationServices on the collection it holds). Externals: Microsoft.Extensions.DependencyInjection.IServiceCollection and System.Reflection.Assembly.

  • Concept introduced, a builder whose constructor is the guard. [Rubric §33, Developer Experience] assesses how hard the framework makes it to do the right thing by default. The ordering rule this builder exists to protect is invisible at the call site: a handler registered after the decorators is silently unwrapped. Rather than documenting the order and hoping, the framework makes the only way to obtain a builder be inside the call that also runs the decorators afterwards. The constructor is internal (MmcaApplicationPipelineBuilder.cs:14), and the XML doc states the reason plainly: "not constructible on its own, because outside that call there is nothing keeping the decorators last" (:9-10). [Rubric §2, Design Patterns]: this is the Builder shape used for scoping rather than for object construction, the same idea as ASP.NET Core's own IEndpointRouteBuilder style callbacks. [Rubric §15, Best Practices & Code Quality]: a host's composition root now reads as one call whose body is a list of steps, so a reviewer can see at a glance whether a registration belongs inside the pipeline.

  • Walkthrough: four members, all trivially thin.

    • Services (:19), the collection under construction, exposed for a step that needs it directly.
    • ScanModule<TAssemblyMarker>() (:27-32, constrained where TAssemblyMarker : class at :28), which forwards to Services.ScanModuleApplicationServices<TAssemblyMarker>() and returns this for chaining. The marker is typically the module's own ClassReference.
    • ScanModules(params Assembly[] moduleAssemblies) (:41-51), the assembly-typed form for hosts that resolve their module set at runtime rather than naming a marker type per module; it null-guards the array (:43) and loops the assembly overload of the scanner (:45-48).
    • Register(Action<IServiceCollection> register) (:62-68), the escape hatch for an arbitrary step, null-guarded at :64. The doc names exactly what belongs here: a ModuleLoader.DiscoverAndRegister(...) call, cross-service gRPC clients, broker messaging, per-host handler overrides (:53-58).
  • Why it's built this way: the callback shape is what makes the ordering rule structural instead of advisory. AddMmcaApplicationPipeline runs AddApplication(), invokes the callback with a freshly constructed builder, and then returns AddApplicationDecorators() (MMCA.Common.Application/DependencyInjection.cs:207-216), so the decorators are last by construction. Everything that is not a handler registration (infrastructure, API, telemetry, options, health checks) deliberately stays outside the call, because its order relative to the decorators does not matter (MMCA.Common.Application/DependencyInjection.cs:195-196).

  • Where it's used: constructed in exactly one place, AddMmcaApplicationPipeline (MMCA.Common.Application/DependencyInjection.cs:207). Every ADC and Store service host composes through it: ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:409-415), Identity (.../MMCA.ADC.Identity.Service/Program.cs:288), Engagement (.../MMCA.ADC.Engagement.Service/Program.cs:278), Notification (.../MMCA.ADC.Notification.Service/Program.cs:215), Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:233), Sales (.../MMCA.Store.Sales.Service/Program.cs:234) and Identity (.../MMCA.Store.Identity.Service/Program.cs:211). The architecture fitness tests replay the same shape, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:52-53 and MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ApplicationPipelineCompositionTests.cs:34.

  • Caveats / not-in-source: the MMCA.Helpdesk host uses the builder too: AddMmcaApplicationPipeline at MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:121, with module discovery as a Register step (:125-133) and the broker wiring as a second one (:139), and the ordering rule stated as a comment above the call (:113-120). Writing the three-call sequence by hand remains a supported composition, just the unguarded one.


IModule

MMCA.Common.Application · MMCA.Common.Application.Modules · MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModule.cs:7 · Level 1 · interface

  • What it is: the contract every pluggable module implements: a Name, an optional Dependencies list, a RequiresDependencies flag, a Register method that wires the module's services, and an optional RegisterDisabledStubs method for the cross-module stubs used when the module is switched off.

  • Depends on: ApplicationSettings (Level 0, passed into Register); externally Microsoft.Extensions.DependencyInjection (IServiceCollection) and Microsoft.Extensions.Configuration (IConfigurationBuilder).

  • Concept introduced, the module system as a single composition contract. [Rubric §5, Vertical Slice] assesses whether features cluster into cohesive, self-contained boundaries: a module (Conference, Engagement, Identity, Notification, Catalog, Sales, Tickets) is the top-level cohesion unit, and it registers all of its own services (handlers, EF configurations, repositories, validators) through one Register call. [Rubric §7, Microservices Readiness] assesses independent deployability: modules declare dependencies by name (string), so ModuleLoader can compute a safe startup order with no compile-time reference between modules. When a dependency is disabled and RequiresDependencies is left at its false default, the depended-on module registers stubs through RegisterDisabledStubs so cross-module interfaces stay resolvable, which is precisely what lets the Conference service boot with DisabledBookmarkCountService standing in for Engagement's IBookmarkCountService.

  • Walkthrough: five members, three of them with default interface implementations, so a minimal module supplies only Name and Register. string Name { get; } (IModule.cs:12, required); IReadOnlyList<string> Dependencies => [] (:17, default empty); bool RequiresDependencies => false (:23, default tolerant); void Register(IServiceCollection, IConfigurationBuilder, ApplicationSettings) (:28, required); void RegisterDisabledStubs(IServiceCollection services) { } (:34, default no-op). Note that Register takes an IConfigurationBuilder, not a built IConfiguration, so a module can add its own configuration sources before its services bind them, which is exactly what the loader exploits to inject per-module JSON files.

  • Why it's built this way: ADR-059 is the decision record for this contract, and ADR-008 is the topology it enables ("each service is the monolith with one module enabled"). Making IModule the single composition boundary means extraction is a deployment concern rather than a rewrite, and default interface members keep the common case ceremony-free while leaving the extraction hooks available.

  • Where it's used: implemented by every module's API project, for example ConferenceModule (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:15), EngagementModule (.../Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:14), IdentityModule (.../Identity/MMCA.ADC.Identity.API/IdentityModule.cs:13), NotificationModule (.../Notification/MMCA.ADC.Notification.API/NotificationModule.cs:15), Store's CatalogModule / SalesModule / IdentityModule, and Helpdesk's single TicketsModule (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.API/TicketsModule.cs:13). Discovered, sorted, and invoked by ModuleLoader; the name is additionally pinned as a fitness rule by ModuleConformanceTestsBase, because renaming it silently disables the module's configuration and drops it from other modules' dependency graphs (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Layering/ModuleConformanceTestsBase.cs:42).


ModuleLoader

MMCA.Common.Application · MMCA.Common.Application.Modules · MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:16 · Level 2 · class (sealed, partial)

  • What it is: the engine of the module system. It reflects over the assemblies the host names to find every IModule and IModuleSeeder implementation, sorts the modules into dependency order with Kahn's topological sort, registers each enabled module into the DI container while recording stub registrations for the disabled ones, and afterwards can verify against the built container that every remote-declared dependency was actually re-wired.

  • Depends on: IModule (Level 1), IModuleSeeder (Level 0), ApplicationSettings (Level 0), ModulesSettings (Level 1). Externals: IServiceCollection / ServiceDescriptor / IConfigurationBuilder, Microsoft.Extensions.Logging (source-generated [LoggerMessage] methods, NullLogger<T>), System.Diagnostics.Stopwatch, System.Reflection.

  • Concept introduced, Kahn's topological sort for DI registration ordering. [Rubric §2, Design Patterns] assesses use of the right algorithm for the problem: ordering items so each appears after everything it depends on is textbook topological sort, and TopologicalSort (ModuleLoader.cs:282) implements the BFS-based Kahn variant. [Rubric §7, Microservices Readiness]: the loader is what makes partial enablement (one module per service host) work at all. [Rubric §15, Best Practices & Code Quality]: modules name their dependencies as strings and the loader resolves and sorts them at startup, so adding a module is purely additive, with no central registration list to edit. [Rubric §13, Observability and Operability]: eight [LoggerMessage] partial methods (:334-356) give allocation-free structured diagnostics of which modules loaded, in what order, with which satisfied or unsatisfied dependencies, how long each Register took, and which assemblies failed to scan or loaded only partially (those two at Error, :349-353).

  • Walkthrough

    • State (ModuleLoader.cs:18-22): three private lists, _enabledModules, _seeders, _disabledModuleNames, the first and third surfaced as the read-only EnabledModules (:25) and DisabledModuleNames (:28) properties. A fourth field, _stubRegistrations (:21), is a case-insensitive Dictionary<string, List<ServiceDescriptor>> recording exactly which descriptors each disabled module's stub registration added; _modulesSettings (:22) caches the settings for the post-build validation pass. Logger (:34) is an init-only ILogger<ModuleLoader> defaulting to NullLogger<ModuleLoader>.Instance, so the loader runs silently unless a host supplies one.
    • DiscoverAndRegister (:59-65) is the single entry point, and it takes the assemblies to scan as a required parameter. There is no ambient-scan overload, and the XML doc gives the reason: an AppDomain scan only sees assemblies already loaded, so a module assembly that is referenced but not yet touched by any code path would be silently absent from discovery (:49-53).
    • Discovery (:74-105): flattens moduleAssemblies through GetTypes() inside a try with two catches, and neither aborts the whole scan. A ReflectionTypeLoadException (the shape a missing transitive reference produces) is caught first (:81-88): it joins the non-null LoaderExceptions messages, logs LogAssemblyPartiallyLoaded at Error, and keeps every type that did load (ex.Types.OfType<Type>(), :87), so one unloadable type does not make the assembly's other modules vanish. Any other exception (:89-93) logs LogAssemblyScanFailed, also at Error and now with the exception attached, and skips that assembly. The code comment gives the reason for Error rather than Warning: a module that silently fails to register is an outage that looks like a configuration choice (:69-73). It then instantiates every concrete, non-abstract, non-interface IModule via Activator.CreateInstance (:97-100) and every IModuleSeeder into an OrdinalIgnoreCase dictionary keyed by ModuleName (:102-105).
    • Per-module loop (:110-133): for a module disabled per ModulesSettings.IsModuleEnabled, it logs, snapshots services.Count, calls module.RegisterDisabledStubs(services), stores the newly appended descriptors under the module's name (:118-120), records the name, and continues. An enabled module runs ValidateModuleDependencies then RegisterEnabledModule, and if a seeder with a matching name exists it is appended to _seeders (:129-132).
    • ValidateModuleDependencies (:136): computes the module's disabled dependencies (:142-144), subtracts those declared remote via ModulesSettings.IsDependencyRemote (:146-148), and throws InvalidOperationException only if a genuinely unsatisfied dependency remains and RequiresDependencies is true; the message spells out the three remediations, enable the module, disable this one, or add the name to Modules:{Name}:RemoteDependencies (:150-158). Otherwise it logs a warning per unsatisfied-but-tolerated dependency (:160-163) and an information line per remote-satisfied one (:165-168).
    • RegisterEnabledModule (:171): before calling module.Register, it adds the conventional per-module JSON configuration files modules.{name}.json and, when an environment name was supplied, modules.{name}.{environment}.json, both optional and reloadOnChange: true (:185-189); the name is lower-cased with ToLowerInvariant under a documented CA1308 suppression for the file-naming convention (:182-184). It times the Register call with a Stopwatch and logs the elapsed milliseconds (:191-194).
    • ValidateRemoteDependencies (:212): the post-build half of the extraction story. Given the built root provider, it creates a scope (:219) and, for every enabled module and every dependency that module declared remote, looks up the descriptors the disabled peer's stubs added and calls ValidateRemoteDependencyStubs (:221-233). That helper (:236) skips open generics, resolves each stub's ServiceType, and throws with a remediation message if it does not resolve at all (:244-249); if it resolves but is still the stub implementation type, it only logs a warning (:251-255), because a best-effort dependency may intentionally keep its stub. Configuration trust alone is not enough: a typo in a RemoteDependencies entry or a forgotten AddTypedGrpcClient would otherwise surface as a first-request failure or a silent no-op instead of at startup (:198-211).
    • SeedAllAsync (:266): awaits each collected seeder's SeedAsync in registration (that is, topological) order, with ConfigureAwait(false) (:268-271).
    • TopologicalSort (:282): builds modulesByName, inDegree, and a reverse-adjacency dependents map, all OrdinalIgnoreCase (:284-290); while building the graph it ignores dependencies on modules that were not discovered (:297-298), deferring those to registration-time validation. It seeds a Queue<string> with the zero-in-degree modules (:306-307) and drains it, decrementing each dependent's in-degree and enqueuing at zero (:310-321). If fewer modules were emitted than exist, the remainder form a cycle, and it throws InvalidOperationException naming them (:324-329).
  • Why it's built this way: convention over configuration. Discovery plus sort means no manual ordering and no module-registration list to keep in sync, which is the decision recorded in ADR-059 (a disabled module is represented by stub registrations rather than by absence, so a dependent always resolves something). Making the assembly list an explicit parameter rather than an ambient scan trades one line at the host for deterministic discovery.

  • Where it's used: constructed by ModuleHostExtensions.AddModuleHost, which attaches a logger when the host supplies one and registers the loader as a singleton (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:78-82), then hands it back on a ModuleHostContext whose RegisterModules step is the actual DiscoverAndRegister call (ModuleHostContext.cs:66-77). Every ADC and Store service registers that step inside its application pipeline, for example ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:373, :347-348) and Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:122, :233). Each of those hosts enables exactly one module in configuration; the MMCA.Helpdesk monolith instead constructs the loader itself with a console logger (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:107) and calls DiscoverAndRegister inside a Register step of its AddMmcaApplicationPipeline call, naming typeof(TicketsModule).Assembly as the one assembly to scan (:125-133). Seeding runs later, from DatabaseInitializationExtensions (DatabaseInitializationExtensions.cs:137).

  • Caveats / not-in-source: ValidateRemoteDependencies has no production caller today. It is exercised only by ModuleLoaderTests (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Modules/ModuleLoaderTests.cs:139, :151, :166); no Program.cs in this workspace calls it after builder.Build(), so a mis-declared RemoteDependencies entry still surfaces at first request rather than at startup unless a host opts in.


IInternalCommand

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommand.cs:38 · Level 3 · interface

  • What it is: the marker contract for a unit of deferred work in the internal-command durable job queue: a command that can be scheduled to run later, in-process, by IInternalCommandScheduler instead of executing immediately on the caller's own request.

  • Depends on: ICommand<Result> (group-05), so an IInternalCommand is, by construction, also an ordinary CQRS command that resolves an ICommandHandler<TCommand, Result> and runs through the same decorator pipeline as any other command.

  • Concept introduced, deferred execution without leaving the CQRS pipeline. [Rubric §29, Reliability and Resilience] assesses whether background work survives a process restart: an IInternalCommand scheduled through IInternalCommandScheduler.ScheduleAsync is durable, a row in the InternalCommands table, not an in-memory timer, so a host crash between scheduling and execution loses nothing. [Rubric §6, CQRS and Event-Driven]: because the interface only narrows ICommand<Result>, the framework does not need a second handler abstraction or a second decorator pipeline for deferred work; InternalCommandProcessor resolves and invokes ICommandHandler<TCommand, Result> exactly as the synchronous path does, so feature gating, authorization, validation, timeout and transaction all still apply.

  • Walkthrough: a single-line marker, public interface IInternalCommand : ICommand<Result>; (:38), with no members of its own. The result type is fixed to plain Result (no payload), which the type's own doc explains is deliberate: a deferred command runs with nobody waiting synchronously for a return value, so there is nothing to hand a caller.

  • Why it's built this way: narrowing rather than introducing a parallel handler contract keeps a command implementer's mental model single: "is this command run now or later" is a scheduling decision, not a different kind of command.

  • Where it's used: the constraint on IInternalCommandScheduler.ScheduleAsync's command parameter (IInternalCommandScheduler) and on InternalCommandDispatcher's resolution of ICommandHandler<TCommand, Result> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandDispatcher.cs). Implemented across both ADC and Store wherever a command is meant to run deferred, for example SendEmailConfirmationCommand (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SendEmailConfirmation/SendEmailConfirmationCommand.cs).

  • Caveats / not-in-source: the interface itself does not require an InternalCommandNameAttribute; whether an implementation needs one is a detail of InternalCommandNameResolver, not of this contract.


IInternalCommandAdministration

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommandAdministration.cs:17 · Level 3 · interface

  • What it is: the operator-facing contract over the internal-command queue: count what is pending, list what has dead-lettered, requeue dead letters, and purge completed rows on demand rather than waiting for the retention sweep.

  • Depends on: InternalCommandDeadLetter (Level 0, the element type of ListDeadLettersAsync's result); BCL (Guid, TimeSpan, IReadOnlyCollection<Guid>); the framework's Result<T> return convention.

  • Concept introduced, an administration surface as its own contract, separate from the scheduler. [Rubric §13, Observability and Operability] assesses whether an operational queue is inspectable and correctable without a direct database console: every method here answers a question an on-call engineer actually asks ("how much is backed up", "what died and why", "put it back", "clean up what finished"). [Rubric §1, SOLID] (ISP): this is a separate interface from IInternalCommandScheduler rather than a wider one, so a host that only ever schedules work never has to satisfy or fake the administration methods, and an administration UI depends on exactly the surface it needs.

  • Walkthrough: four methods, all Task<Result<T>> and all taking an optional dataSource filter (null means every source this host owns).

    • CountPendingAsync(string? dataSource, CancellationToken) (:123): counts rows not yet processed, not dead-lettered, attempts not exhausted, including rows whose scheduled instant is still in the future, because that is backlog the host has already accepted.
    • ListDeadLettersAsync(string? dataSource, int skip, int take, CancellationToken) (:137-141): a paged, oldest-first list of InternalCommandDeadLetter rows.
    • RequeueAsync(string? dataSource, IReadOnlyCollection<Guid>? ids, CancellationToken) (:158-161): resets attempts to zero and clears the dead-letter stamp and claim lease so the next poll cycle picks the row back up, deliberately keeping LastError (the doc's own reasoning: the failure reason is the first thing anyone asks after a requeue) and leaving the scheduled instant untouched so a requeued row is immediately due. ids null or empty requeues every dead letter in the selected scope.
    • PurgeProcessedAsync(string? dataSource, TimeSpan olderThan, CancellationToken) (:175-178): deletes completed rows older than the threshold; pending and dead-lettered rows are never touched by this call, only rows carrying a completion stamp.
  • Why it's built this way: every method takes an explicit dataSource filter rather than assuming one database, consistent with the framework's database-per-module model, so an administration surface mounted on a multi-source host can scope an operation to one source or run it across all of them with the same call.

  • Where it's used: implemented by InternalCommandAdministration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandAdministration.cs) and registered in MMCA.Common.Infrastructure's DependencyInjection.cs.

  • Caveats / not-in-source: no shipped ADC or Store admin page in this workspace calls this interface; its only exercised path is the Infrastructure implementation and its tests.


IDeleteBlobInternalCommand

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IDeleteBlobInternalCommand.cs:21 · Level 4 · interface

  • What it is: a narrow marker interface for the one recurring shape of deferred blob cleanup: an IInternalCommand whose only payload is the name of the blob to delete.

  • Depends on: IInternalCommand (Level 3), so any implementation is also schedulable through IInternalCommandScheduler and runs through the ordinary CQRS decorator pipeline like any other internal command.

  • Concept introduced, a shared contract for a recurring deferred-cleanup shape. [Rubric §1, SOLID] (ISP): rather than each caller inventing its own "delete this blob later" command shape, the interface pins the one property every such command needs, string BlobName { get; } (IDeleteBlobInternalCommand.cs:24), and leaves everything else (which container, which retry policy) to the concrete command and its handler. [Rubric §29, Reliability and Resilience]: naming the shape lets DeleteBlobInternalCommandHandlerBase<TCommand> be written once and reused by every blob-owning feature instead of duplicating the delete-then-tolerate-not-found logic per caller.

  • Walkthrough: a single-member interface, public interface IDeleteBlobInternalCommand : IInternalCommand (:21), narrowing IInternalCommand with one string BlobName { get; } property (:24-25) that names the blob to delete.

  • Why it's built this way: keeping the interface to one property is what lets the generic handler base below be written against TCommand : IDeleteBlobInternalCommand with nothing else to assume about the concrete command.

  • Where it's used: implemented by DeleteSessionAssetBlobInternalCommand (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/DeleteSessionAssetBlob/DeleteSessionAssetBlobInternalCommand.cs) and DeleteAvatarBlobInternalCommand (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommand.cs), each paired with a handler that derives from DeleteBlobInternalCommandHandlerBase<TCommand>.

  • Caveats / not-in-source: the interface makes no statement about which storage container or account the blob lives in; that is resolved entirely inside whichever IFileStorageService implementation the handler was constructed with.


DeleteBlobInternalCommandHandlerBase<TCommand>

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/DeleteBlobInternalCommandHandlerBase.cs:28 · Level 5 · class (abstract, partial)

  • What it is: the reusable handler body for any IDeleteBlobInternalCommand: delete the named blob, treat "already gone" as success rather than failure, and log every outcome distinctly.

  • Depends on: IDeleteBlobInternalCommand (Level 4, the TCommand constraint), ICommandHandler<TCommand, Result> (the contract it implements), IFileStorageService (constructor dependency, DeleteAsync) and ErrorType (NotFound, tested against the failure result). Externally, Microsoft.Extensions.Logging for its three source-generated [LoggerMessage] methods.

  • Concept introduced, a base class that turns "not found" into a non-failure. [Rubric §29, Reliability and Resilience] assesses whether a retryable job queue treats an already-completed side effect as success rather than as a reason to retry forever: a blob deleted by an earlier, since-crashed attempt (or removed out of band) must not keep the internal command dead-lettering. HandleAsync (DeleteBlobInternalCommandHandlerBase.cs:41-65) checks result.Errors.Any(e => e.Type == ErrorType.NotFound) (:53) and returns Result.Success() in that case (:56) instead of propagating the storage failure. [Rubric §15, Best Practices & Code Quality]: the class is abstract partial, generic over TCommand, and provides one override point, protected virtual string BlobKind => "Blob"; (:38), so a concrete handler needs only a class declaration and, optionally, a friendlier log label; DeleteSessionAssetBlobInternalCommandHandler and DeleteAvatarBlobInternalCommandHandler both derive from it with no additional logic of their own.

  • Walkthrough: a primary-constructor class taking IFileStorageService fileStorage and ILogger logger (:49-51), constrained where TCommand : IDeleteBlobInternalCommand (:53). HandleAsync(TCommand command, CancellationToken cancellationToken = default) (:62) null-guards the command (:64), calls fileStorage.DeleteAsync(command.BlobName, cancellationToken) (:66), and branches three ways: success logs LogBlobDeleted and returns the successful result (:68-72); a NotFound error logs LogBlobAlreadyGone and returns Result.Success() (:74-78); any other error logs LogBlobDeleteFailed with the first error's message (or "Unknown error" if the error list is empty, :84) and returns the original failed result (:80-85). The three [LoggerMessage] partial methods (:88-95) are Information, Information, and Warning respectively, each interpolating BlobKind and BlobName so the log line reads naturally for whichever concrete command is running.

  • Why it's built this way: factoring the delete-then-tolerate-not-found logic into a shared abstract base means every blob-owning feature gets the same retry-safety guarantee without re-deriving it, and the single BlobKind override point is enough for each concrete handler to produce its own readable log lines without overriding HandleAsync itself.

  • Where it's used: DeleteSessionAssetBlobInternalCommandHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/DeleteSessionAssetBlob/DeleteSessionAssetBlobInternalCommandHandler.cs) and DeleteAvatarBlobInternalCommandHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommandHandler.cs) both derive from DeleteBlobInternalCommandHandlerBase<TCommand> closed over their own command type.

  • Caveats / not-in-source: the class does not itself register as an ICommandHandler; registration happens through the ordinary ScanModuleApplicationServices scan picking up each concrete derived handler, not through anything specific to this base class.


IInternalCommandScheduler

MMCA.Common.Application · MMCA.Common.Application.InternalCommands · MMCA.Common/Source/Core/MMCA.Common.Application/InternalCommands/IInternalCommandScheduler.cs:16 · Level 4 · interface

  • What it is: the write side of the internal-command queue: schedule an IInternalCommand to run at a future instant or after a delay, instead of running it inline on the current request.

  • Depends on: IInternalCommand (Level 3, the type every scheduled payload must implement) and, through its return doc, IInternalCommandAdministration (the scheduled id is the handle the administration surface operates on). BCL (DateTimeOffset?, TimeSpan).

  • Concept introduced, transaction-aware durable scheduling. [Rubric §29, Reliability and Resilience]: scheduling a command is not fire-and-forget the way an in-memory Task.Delay would be, it is a database write that must be atomic with whatever aggregate change triggered it. The XML remarks on ScheduleAsync (:34-43) state the rule explicitly: with a transaction active on the target data source the row is only enrolled, and the caller's next save writes it; when no save follows (a command scheduled after the handler's last save), the transactional pipeline saves it just before the commit, so it still commits with the aggregate change. That pipeline refuses to commit any other change left unsaved, so a handler cannot lean on it to save its own work (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:16). With no transaction active the row is saved immediately, which flushes anything else pending on that context exactly as the unit of work's own save would. [Rubric §6, CQRS and Event-Driven]: scheduling inside an ITransactional command, or right after the caller's own save, is how a scheduled command and the state change that caused it commit or roll back together.

  • Walkthrough: two overloads, both returning Task<Result<Guid>> where the Guid is the scheduled row's id, the same id IInternalCommandAdministration later operates on.

    • ScheduleAsync(IInternalCommand command, DateTimeOffset? runAt = null, CancellationToken cancellationToken = default) (:45-48): runAt is the earliest instant the command may run, converted to UTC; null or already-past means "as soon as the processor next polls". The processor never runs a row before this instant but makes no promise about how quickly after it a busy queue gets there.
    • ScheduleAsync(IInternalCommand command, TimeSpan delay, CancellationToken cancellationToken = default) (:59-62): schedules after delay has elapsed; a delay of zero or less also means "as soon as possible".
    • Both fail when the command's payload cannot round-trip through JSON or the row cannot be written.
    • Both carry a SuppressMessage for analyzer rule RS0026 (multiple public overloads with optional parameters) (:44, :58), justified as a grandfathered public overload released after the v1.152 public-API baseline: changing either signature now would be a breaking change.
  • Why it's built this way: two overloads rather than one nullable-DateTimeOffset parameter give the common "run in N minutes" case a TimeSpan call site without the caller doing DateTimeOffset.UtcNow.Add(...) arithmetic itself, while the absolute-instant overload covers "run at this specific time".

  • Where it's used: called wherever a host wants to defer a command, for example ADC's AuthController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs), DeleteUserHandler, RemoveUserAvatarHandler, SetUserAvatarHandler (all under MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/), and Conference's DeleteEventHandler, DeleteSessionAssetHandler, UploadSessionAssetHandler, DeleteSessionHandler (under MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/). ADC's SessionSelectionController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs) also takes it. Implemented by InternalCommandScheduler (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandScheduler.cs) and registered with TryAddScoped in MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:161. Referenced by ADR-024, ADR-054, ADR-086, ADR-114 (the decision record for the internal-command queue itself) and ADR-121.

  • Caveats / not-in-source: the doc's transaction-aware persistence rule is stated in the interface's own XML remarks, not enforced by a compile-time type; a caller that intends transactional enrollment but is not actually inside an ITransactional command silently gets the immediate-save behavior instead.


DependencyInjection

MMCA.Common.Application · MMCA.Common.Application · MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:27 · Level 11 · class (static, C# extension(IServiceCollection))

  • What it is: the composition-root extension class that assembles the framework's entire Application layer into the DI container. The class is partial and its twelve public members are split across four files, each with its own extension(IServiceCollection services) block: DependencyInjection.cs (:27, AddApplication(), AddApplicationDecorators(), AddApplicationProfiling(), AddMmcaApplicationPipeline(configure), VerifyDecoratorPipeline()), DependencyInjection.ModuleScanning.cs (:15, ScanModuleApplicationServices<TAssemblyMarker>() and its Assembly overload), DependencyInjection.Crud.cs (:16, AddEntityCrud<...>(), AddEntityUpdateVerb<...>(), AddEntityUpdate<...>(), AddCommandRequestValidator<TCommand, TRequest>()) and DependencyInjection.Extensibility.cs (:11, AddUserDataExportSection<TSection>(), AddEventUpcaster<TSource, TTarget, TUpcaster>()). The five private helpers behind the pipeline seal, DecoratorPipelineSeal, IsPipelineSealed, SealPipeline, ThrowIfPipelineSealed, FormatUndecorated, stay in DependencyInjection.cs below the extension block (:289-333).

  • Depends on: the core singletons IDomainEventDispatcher / DomainEventDispatcher, IEventUpcasterRegistry / EventUpcasterRegistry, INavigationMetadataProvider, IEntityQueryPipeline; the marker ClassReference; the permission registry IPermissionRegistry / UnconfiguredPermissionRegistry and the parallel permission catalog fallback IPermissionCatalog (also defaulted to UnconfiguredPermissionRegistry); the open-generic handler contracts ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult>; the seven command decorators TransactionalCommandDecorator<TCommand, TResult>, TimeoutCommandDecorator<TCommand, TResult>, ValidatingCommandDecorator<TCommand, TResult>, CachingCommandDecorator<TCommand, TResult>, LoggingCommandDecorator<TCommand, TResult>, AuthorizationCommandDecorator<TCommand, TResult>, FeatureGateCommandDecorator<TCommand, TResult>; the six query decorators TimeoutQueryDecorator<TQuery, TResult>, ValidatingQueryDecorator<TQuery, TResult>, CachingQueryDecorator<TQuery, TResult>, LoggingQueryDecorator<TQuery, TResult>, AuthorizationQueryDecorator<TQuery, TResult>, FeatureGateQueryDecorator<TQuery, TResult>; the optional ProfilingCommandDecorator<TCommand, TResult> and ProfilingQueryDecorator<TQuery, TResult>; the scanned contract families IDomainEventHandler<in TDomainEvent>, IIntegrationEventHandler<in TIntegrationEvent>, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>, IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>; the generic write-side handlers CreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>, UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>, UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> and DeleteEntityHandler<TEntity, TIdentifierType> over UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>; the request-validator bridge CommandRequestValidator<TCommand, TRequest> over ICommandWithRequest<out TRequest>; the upcaster contract IEventUpcaster; the export contributor contract IUserDataExportSection; the builder MmcaApplicationPipelineBuilder; and the private marker DecoratorPipelineSeal. Externals: FluentValidation (AddValidatorsFromAssemblyContaining, AddValidatorsFromAssembly, IValidator<>), Scrutor (Scan, TryDecorate), Microsoft.Extensions.DependencyInjection.Extensions (TryAdd*, TryAddEnumerable), System.Reflection.

  • Concept introduced, the CQRS decorator pipeline wiring order (Scrutor TryDecorate). [Rubric §6, CQRS and Event-Driven] assesses whether cross-cutting handler concerns are applied uniformly; [Rubric §2, Design Patterns] covers the Decorator pattern itself (the individual decorators are taught in group-05). Scrutor's TryDecorate applies decorators in reverse registration order: the last registered call becomes the outermost wrapper. AddApplicationDecorators (DependencyInjection.cs:114) registers the seven command decorators in source order Transactional, Timeout, Validating, Caching, Logging, Authorization, FeatureGate (:134-140), which produces the execution nesting its own XML doc draws (:62-71):

    FeatureGateCommandDecorator            (outermost, short-circuits if the feature flag is off)
      -> AuthorizationCommandDecorator     (short-circuits with Forbidden if IRequiresPermission)
        -> LoggingCommandDecorator         (measures full pipeline duration of enabled features)
          -> CachingCommandDecorator       (invalidates cache only AFTER the transaction commits)
            -> ValidatingCommandDecorator  (short-circuits with Result.Failure before any budget)
              -> TimeoutCommandDecorator   (applies the command's own budget if IHasTimeout)
                -> TransactionalCommandDecorator (DB transaction if ITransactional)
                  -> ConcreteHandler       (the actual business logic)

    Queries get a six-deep chain, FeatureGate, Authorization, Logging, Caching, Validating, Timeout, handler (:143-148, drawn at :75-83). The ordering is not arbitrary, and the doc's design rationale gives a reason per position (:86-110): feature gating is outermost so a disabled feature costs nothing and answers identically for every caller rather than leaking which permission guards it; authorization sits directly inside it and outside caching, so a denied request neither reads nor populates the cache; logging measures only enabled executions; validation sits outside the transaction on the command side so a malformed command never opens one, but inside caching on the query side, because a cached entry can only exist because that query already passed validation once; cache invalidation sits outside validation so cache is cleared only after a valid committed mutation; and the timeout budget sits inside validation and outside the transaction so it covers the database work that actually hangs and cancels the transaction rather than leaving it open, while on the query side it is innermost so a cache hit never starts a budget. [Rubric §11, Security] is why the two authorization decorators are registered unconditionally with a TryAddSingleton<IPermissionRegistry, UnconfiguredPermissionRegistry>() fallback (:123, comment at :118-122), paired with the identical TryAddSingleton<IPermissionCatalog, UnconfiguredPermissionRegistry>() fallback added immediately after it (:129, comment at :125-128) so an administration surface mounted on a host with no permission model renders an empty catalog list rather than failing to activate: the pipeline cannot activate without a registry at all, so a host with no permission model still resolves every handler while a host that declared its grants (AddAuthorizationPolicies / AddPermissions) keeps its own registry and its own catalog. The order itself is the decision recorded in ADR-014.

  • Concept introduced, the ordering rule as an enforced invariant. This file is the one place where register-order versus execute-order inversion has to be held in mind, and it is also where the framework stops relying on a reader holding it. AddApplicationDecorators() ends by sealing the collection with DecoratorPipelineSeal (:150), every handler-contributing entry point opens with ThrowIfPipelineSealed (DependencyInjection.cs:116 inside AddApplicationDecorators, DependencyInjection.ModuleScanning.cs:49, DependencyInjection.Crud.cs:80, :142, :193, DependencyInjection.cs:209 inside AddMmcaApplicationPipeline), and AddMmcaApplicationPipeline(configure) (DependencyInjection.cs:207-216) packages the whole sequence so the decorators are last by construction. [Rubric §15, Best Practices & Code Quality] and [Rubric §14, Testability]: VerifyDecoratorPipeline() (:244) is the fitness hook that proves the result, and it works on descriptor shape alone, never building a provider, so a test does not have to register a double for every decorator dependency (:228-243).

  • Concept introduced, ScanModuleApplicationServices, the convention scanner. [Rubric §5, Vertical Slice] (one call wires a whole module's slice types) and [Rubric §14, Testability] (handler registration is reproducible in a test host with the same one call). Both overloads live in DependencyInjection.ModuleScanning.cs. The marker overload (:28-30, constrained where TAssemblyMarker : class at :29) just resolves typeof(TAssemblyMarker).Assembly and forwards to the Assembly overload (:46, null-guarded at :48), which runs nine Scrutor passes over that single assembly, each with a deliberate lifetime: domain event handlers (IDomainEventHandler<>, singleton, because they create their own scopes, :52-56), integration event handlers (IIntegrationEventHandler<>, singleton, :58-63), DTO mappers (IEntityDTOMapper<,,>, scoped, AsSelfWithInterfaces, :65-69), the opt-in DTO projectors (IEntityDTOProjector<,,>, scoped, :71-78, so an entity that has one gets server-side projection on its list reads and one that has none keeps materialize-then-map), request mappers (IEntityRequestMapper<,,>, scoped, :80-84), update appliers (IEntityUpdateApplier<,,>, scoped, :86-94), command-aware appliers (IEntityUpdateCommandApplier<,,,>, scoped, :96-103), command handlers (ICommandHandler<,>, scoped, :105-109) and query handlers (IQueryHandler<,>, scoped, :111-115), followed by AddValidatorsFromAssembly (:117). After the passes, a reflection loop (:121-135) finds every type in the assembly implementing ICommandWithRequest<TRequest>, constructs CommandRequestValidator<TCommand, TRequest> and IValidator<TCommand> with MakeGenericType, and TryAddTransients the pair (:131-134), so a command that embeds its own request DTO gets a bridging validator for free. TryAdd is load-bearing here: an explicit IValidator<TCommand> picked up by the earlier AddValidatorsFromAssembly pass always wins, which the inline comment states (:119-120).

  • Concept introduced, generic write-side registration. [Rubric §15, Best Practices & Code Quality]: all four members live in DependencyInjection.Crud.cs. AddEntityCrud<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>() (:74) replaces the three hand-written handler classes a straightforward CRUD aggregate would otherwise need, registering CreateEntityHandler, UpdateEntityHandler and DeleteEntityHandler closed over the aggregate's own types plus the update command's validator bridge (:82-99). Two details in its doc are the teaching points (:14-73). First, the registrations are closed generics, not open, because Scrutor's TryDecorate wraps concrete service types: an open ICommandHandler<,> registration would resolve completely undecorated and VerifyDecoratorPipeline() could not see it (:45-53). Second, everything is TryAdd, so an aggregate that outgrows one verb registers its own handler for that verb before this call and keeps the generic pair for the other two. AddEntityUpdateVerb<..., TApplier>() (:136) registers one verb of the update path discriminated by its applier type, and AddEntityUpdate<TCommand, ...>() (:187) does the same for a derived command that carries state beside the request. All three route their validator wiring through AddCommandRequestValidator<TCommand, TRequest>() (:220-226), the explicit form of what the scan's reflection bridge does for commands it can see. ADR-099 is the decision record.

  • Concept introduced, the accumulating contributor registration. [Rubric §30, Compliance, Privacy and Data Governance] assesses whether data-subject obligations are met by design: both members live in DependencyInjection.Extensibility.cs. AddUserDataExportSection<TSection>() (:38) is how each module contributes the slice of a person's data it owns to one export document (ADR-076). The mechanism is two lines, TryAddScoped<TSection>() then TryAddEnumerable(ServiceDescriptor.Scoped<IUserDataExportSection, TSection>()) (:41-42), and both halves matter. TryAddEnumerable de-duplicates by implementation type, so registering the same section twice adds it once while two different sections both survive, which a plain AddScoped would not guarantee. Scoped, not singleton, so a section runs inside the request's unit of work and may take repositories or gRPC clients (:27-29). Registration order is the order the sections appear in the export document (:20-26). AddEventUpcaster<TSource, TTarget, TUpcaster>() (:81-88) uses the identical idiom one lifetime up: TryAddEnumerable(ServiceDescriptor.Singleton<IEventUpcaster, TUpcaster>()), singleton because upcasters are pure functions over an event instance, with TSource and TTarget named explicitly so the compiler checks the shape at the registration site (ADR-090, rationale at :55-67).

  • Walkthrough

    • AddApplication() (DependencyInjection.cs:32): four TryAddSingleton calls, IDomainEventDispatcher (:34), IEventUpcasterRegistry (:40, registered unconditionally because with no upcasters it is an empty registry whose operations are the identity, so both delivery paths can depend on it without a null check, :36-39), INavigationMetadataProvider (:42) and IEntityQueryPipeline (:43), then AddValidatorsFromAssemblyContaining<ClassReference>() (:48) to register the framework's own validators, which a module-level scan would never reach because it only scans the module's own assembly (:45-47).
    • AddApplicationDecorators() (:114): guard, permission-registry fallback plus the parallel permission-catalog fallback, thirteen TryDecorate calls, seal.
    • ScanModuleApplicationServices<TAssemblyMarker>() (DependencyInjection.ModuleScanning.cs:28, constrained where TAssemblyMarker : class at :29) and ScanModuleApplicationServices(Assembly) (:46, null-guarded at :48): the nine-pass scanner plus the request-validator loop.
    • AddEntityCrud / AddEntityUpdateVerb / AddEntityUpdate / AddCommandRequestValidator (DependencyInjection.Crud.cs:74, :136, :187, :220): the generic write side.
    • AddUserDataExportSection<TSection>() (DependencyInjection.Extensibility.cs:38, constrained where TSection : class, IUserDataExportSection at :39) and AddEventUpcaster<TSource, TTarget, TUpcaster>() (:81): the two accumulating registrations. The export handler itself needs no registration here: apps subclass ExportUserDataHandlerBase<TUser, TQuery> in their own Application assembly and the scanner picks the subclass up as an IQueryHandler like any other (:32-36).
    • AddApplicationProfiling() (DependencyInjection.cs:160): optional, TryDecorates ProfilingCommandDecorator<,> and ProfilingQueryDecorator<,> on top (:162-163), for use with ApplicationSettings.UseMiniProfiler. It deliberately carries no seal guard, so it can be applied after the pipeline is closed.
    • AddMmcaApplicationPipeline(configure) (:207): guard, AddApplication(), invoke the callback with a new MmcaApplicationPipelineBuilder, return AddApplicationDecorators() (:209-215). The configure callback may be null for a host with no modules (:173-179).
    • VerifyDecoratorPipeline() (:244): checks the seal first (:246-251), then walks the collection keeping the last non-keyed closed-generic descriptor per ICommandHandler<,> / IQueryHandler<,> service type (:253-269, last-registration-wins is the container's own rule), and reports every surviving entry whose ImplementationFactory is null (:271-285). The doc explains why that test is sound: TryDecorate rewrites a decorated registration into a factory over its own keyed copy of the original, so an implementation type still sitting on the effective registration is proof nothing wrapped it, and the outermost decorator's own type cannot be read back at all because after decoration it exists only inside a closure (:228-243).
  • Why it's built this way: [Rubric §3, Clean Architecture]: registration lives in a static, partial DependencyInjection class at the composition root, so domain and Application types never reference the container. The pervasive TryAdd* and TryDecorate pattern lets a consuming app override any framework default simply by registering its own implementation first. Each of the four files carries its own extension(IServiceCollection services) block, the C# extension-member syntax the framework uses for its public DI surface (ADR-106, and primer §4 for the syntax itself).

  • Where it's used: by every consuming host. ADC and Store services call AddMmcaApplicationPipeline with the module-discovery step, the cross-service gRPC clients and the broker wiring inside the callback (for example MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:409-415, MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:233); MMCA.Helpdesk does the same, with module discovery (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:125) and the broker wiring (:139) inside the callback of its AddMmcaApplicationPipeline call (:121). ScanModuleApplicationServices<ClassReference>() is called from each module's own composition root (see ClassReference for the seven call sites). AddUserDataExportSection<TSection>() is called by ADC's Identity module for its two cross-module sections (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:53-54) and by Store's IdentityModule for its one (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/IdentityModule.cs:40). VerifyDecoratorPipeline() is called by the architecture fitness tests (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:68, MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ApplicationPipelineCompositionTests.cs:160).

  • Caveats / not-in-source: several other classes named DependencyInjection exist across the framework and the apps (Infrastructure, API, Grpc, UI, Notifications, and one per module) with the same name but different namespaces and methods. This section covers only the MMCA.Common.Application root at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:27; the others are documented in their own groups. AddApplicationProfiling() has no caller in any host in this workspace: its only callers are unit tests (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:113, :123).

AssemblyReference

MMCA.Common.Domain · MMCA.Common.Domain · MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:8 · Level 0 · class (static)

  • What it is: the Domain layer's assembly-marker anchor. A static class holding the assembly it lives in and that assembly's simple name, so anything that needs to say "the MMCA.Common.Domain assembly" can say it through a type that exists for no other reason. Its XML doc names the two intended consumers: Scrutor assembly-scanning registration and architecture tests (AssemblyReference.cs:5-7).

  • Depends on: System.Reflection.Assembly (BCL, imported at AssemblyReference.cs:1). Nothing first-party, which is why it sits at Level 0. Note that Domain is the innermost layer above MMCA.Common.Shared, so this marker could not depend on anything else even if it wanted to.

  • Concept introduced, assembly-marker types for convention scanning. [Rubric §2, Design Patterns] assesses whether recurring problems reach for a recognised, deliberate shape. When registration code or a fitness test needs "every type in this assembly", handing the scanner a purpose-built anchor (typeof(AssemblyReference).Assembly) is the idiomatic form: it is far more stable than pointing at a real business type that may later move to another project or be renamed. [Rubric §1, SOLID] (DIP): the scan depends on a stable, meaningless token rather than on a concrete domain type. The same two-type pair repeats verbatim in every layer package (MMCA.Common/Source/Core/MMCA.Common.Application/AssemblyReference.cs:5, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/AssemblyReference.cs:5, MMCA.Common/Source/Presentation/MMCA.Common.API/AssemblyReference.cs:8), so each assembly is self-describing with no cross-layer reference; those sibling copies have their own sections in this chapter and in Group 12.

  • Walkthrough: two public static readonly fields, both resolved once at type initialization. Assembly is typeof(AssemblyReference).Assembly (AssemblyReference.cs:10), a self-reference so the field can never point at the wrong assembly. AssemblyName is Assembly.GetName().Name with a ?? string.Empty fallback (AssemblyReference.cs:11), so the field is non-null even in the pathological case where the runtime reports no simple name.

  • Why it's built this way: static readonly rather than a property means the reflection call happens once per process, and the self-referencing typeof makes the anchor refactor-proof: moving the file inside the project changes nothing, and moving it out of the project is exactly the case you would want to notice.

  • Where it's used: nothing inside MMCA.Common references the Domain copy today. The Application copy backs AddValidatorsFromAssemblyContaining<ClassReference>() (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48) and the Infrastructure copy backs the entity-configuration scan FromAssemblyOf<ClassReference>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:135); the Domain layer registers nothing by convention, so its marker stays available rather than exercised.

  • Caveats / not-in-source: the XML doc names architecture tests as a consumer, but the per-repo architecture maps anchor the Domain layer with typeof(MMCA.Common.Domain.Entities.BaseEntity<>).Assembly instead (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:22, and identically at MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/HelpdeskArchitectureMap.cs:16). Whether a downstream consumer outside this workspace scans through this anchor is Not determinable from source here.


ClassReference

MMCA.Common.Domain · MMCA.Common.Domain · MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:18 · Level 0 · class

  • What it is: the non-static companion to the Domain layer's AssemblyReference, an empty instantiable class used wherever a generic type parameter needs an assembly anchor and a static class cannot satisfy the constraint.

  • Depends on: nothing first-party, and nothing from the BCL beyond object.

  • Concept: the companion half of the marker pattern taught in the preceding section. C# static classes cannot be used as generic type arguments, so any helper constrained to an instantiable reference type (Scrutor's FromAssemblyOf<T>(), FluentValidation's AddValidatorsFromAssemblyContaining<T>()) is handed ClassReference instead. The XML doc says exactly that: an anchor for assembly resolution when AssemblyReference cannot be used, for example under generic type constraints requiring a non-static class (AssemblyReference.cs:14-17). [Rubric §33, Developer Experience] assesses how conventional the inner loop is: one token means "this assembly" in every package, so wiring a new scan never involves hunting for a suitable real type.

  • Walkthrough: a single body-less type declaration, public class ClassReference; (AssemblyReference.cs:18). No members, no constructor, no interface. Deliberately not sealed and not static, because both would defeat its purpose as a generic argument for helpers that may construct it.

  • Where it's used: as with its static twin, the Domain copy has no call site inside MMCA.Common; the Application and Infrastructure copies are the ones the composition roots scan through (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:135).


IUserScopedRequest

MMCA.Common.Application · MMCA.Common.Application.Users · MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedRequest.cs:8 · Level 0 · interface

  • What it is: a one-member shape interface saying "this command or query targets a single user account". It exposes exactly UserIdentifierType UserId { get; } and nothing else.

  • Depends on: the solution-wide identifier alias UserIdentifierType (the convention recorded in ADR-048). Nothing else, first-party or external; the file has no using directives at all (IUserScopedRequest.cs:1).

  • Concept introduced, shape interfaces instead of shared request records. [Rubric §1, SOLID] assesses interface segregation and dependency inversion together, and this is the smallest possible instance of both: the shared Users use-case bases need one fact about the incoming message (which account it addresses), so that fact, and only that fact, becomes the contract. The XML doc states the motivation directly (IUserScopedRequest.cs:3-7): each app keeps its own command or query record, with its own ICacheInvalidating choice and its own docs, and simply adds this interface. [Rubric §15, Best Practices & Code Quality]: hoisting the behavior into a base class while leaving the record app-side is what let ADC and Store share the handlers without either app losing a per-app pipeline decision.

  • Walkthrough: a single interface with a single get-only property (IUserScopedRequest.cs:11). There is no base interface and no default member, so implementing it is free for a record that already has a UserId positional parameter.

  • Where it's used: extended by IUserOwnedRequest and IUserScopedCommand<out TRequest>, both Level 1 in this chapter. Implemented directly by the one shared query record in the framework, GetUserPreferencesQuery (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5), which is the only implementer inside MMCA.Common.


UserDataExportSectionDefaults

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ExportUserData · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:105 · Level 0 · class (static)

  • What it is: a static holder for one string constant: the sentence a data subject reads when a section of their export could not be produced and the app supplied no reason of its own.

  • Depends on: nothing. It is a single const string, which is why it is Level 0 even though the export pipeline around it is not.

  • Concept introduced, the caller-safe failure message. [Rubric §30, Compliance, Privacy and Data Governance] assesses whether privacy obligations are met in the product rather than in a policy document: a data-subject access request has a legal deadline, so a partially unavailable package that says so plainly is a better answer than a failed request (ADR-076). [Rubric §11, Security] assesses what leaves the trust boundary: this text reaches an end user, so it is deliberately generic and carries no exception message, stack trace, peer address, or connection string; the detail goes to the log instead, through the handler-local ExportSectionUnavailable source-generated log message on ExportUserDataHandlerBase<TUser, TQuery> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:202). [Rubric §13, Observability and Operability]: splitting the audience in two (generic sentence out, full exception in) is the pattern, not an oversight.

  • Walkthrough: one member, public const string UnavailableReason (IUserDataExportSection.cs:112-113), whose value is "This section could not be retrieved. The data is unchanged; the export can be requested again later." The XML doc records the intent (IUserDataExportSection.cs:107-111): the subject learns the section is incomplete and retryable, and learns nothing about the internal failure. const rather than static readonly because the value is compile-time and callers use it in default-parameter position and in object initializers.

  • Why it's built this way: two call sites need the identical wording, and having them share a constant is what keeps a degraded section indistinguishable whether the section itself reported unavailability or the handler caught an exception on its behalf.

  • Where it's used: as the fallback in UserDataExportSectionResult.Unavailable when the caller passes no reason (IUserDataExportSection.cs:99), and directly in ExportUserDataHandlerBase<TUser, TQuery>'s catch block when a section throws (ExportUserDataHandlerBase.cs:196).


ApplicationSettings

MMCA.Common.Application · MMCA.Common.Application.Settings · MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:8 · Level 0 · class (sealed)

  • What it is: the framework's global settings object, bound from the "ApplicationSettings" configuration section, with four knobs: UseMiniProfiler (MiniProfiler tracing), MaxPageSize (the ceiling the API applies to a paged list query), MaxExportRows (the ceiling on a single CSV export) and DatabaseInitStrategy ("Migrate" or "None").

  • Depends on: nothing first-party. System.ComponentModel.DataAnnotations for one [Range] attribute (ApplicationSettings.cs:1, :32), and the options binder at the composition root. That purity is why it sits at Level 0 despite being reachable from nearly every layer.

  • Concept introduced, options-pattern settings classes. [Rubric §17, DevOps & Deployment] assesses whether configuration is centralised and typed rather than read as magic-string keys scattered through the codebase. Every settings class in the framework follows the same three-part shape, and this is the simplest exemplar of it: a public static readonly string SectionName naming the configuration section (ApplicationSettings.cs:11) so no registration site spells the section out, init-only properties so the object is immutable once the binder has filled it, and defaults declared inline beside the property. Consumers take IOptions<ApplicationSettings> from the container or receive the bound instance by value; nothing injects IConfiguration to read these values. [Rubric §8, Data Architecture] shows up in the last knob: "Migrate" applies pending EF Core migrations, and "None" is the production setting that validates and fails startup when the schema is behind rather than silently migrating it (:35-42). [Rubric §12, Performance and Scalability] is why the two ceiling knobs exist at all: both stop one caller from turning a single request into a full-table scan.

  • Walkthrough: SectionName = "ApplicationSettings" (:11). UseMiniProfiler takes the implicit false default (:14). MaxPageSize = 500 (:17). MaxExportRows = 100_000 (:33) is the one property carrying a validation attribute, [Range(1, 10_000_000)] (:32), and its remarks explain both the number and the attribute's limits (:24-31): 100,000 rows is roughly a 10 to 25 MB file for a typical grid DTO, large enough that no real operational export hits the cap and small enough that one caller cannot pin a request thread to a full-table scan; the [Range] is honored only by hosts that opt into ValidateDataAnnotations on the options binding, so the export endpoint independently falls back to its own default when a host configures a non-positive value. The two ceilings compose rather than duplicate: the export endpoint page-loops the query service at MaxPageSize per page, so MaxExportRows bounds the whole file and not one page (:19-23). DatabaseInitStrategy = "Migrate" (:43).

  • Why it's built this way: static SectionName keeps registration DRY, and init immutability makes one bound instance safe to share across the process as a singleton IOptions<T> value and safe to hand to every module by value. Validating a bound value with an attribute rather than a hand-written guard keeps the ceiling declarative, while the endpoint-side fallback means a host that never opts into validation still cannot produce an unbounded export.

  • Where it's used: bound and validated by ModuleHostExtensions.AddModuleHost, which calls AddOptions<ApplicationSettings>().Bind(...).ValidateDataAnnotations().ValidateOnStart() and then reads the section a second time to get a plain instance, throwing when the section is absent (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:61-67). That instance is carried on ModuleHostContext.ApplicationSettings (ModuleHostContext.cs:44) and passed by value into every IModule.Register(services, configuration, applicationSettings) call, so a module reads global settings without resolving anything. Each knob then has a distinct consumer: MaxPageSize is resolved per request by EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> through IOptions<ApplicationSettings> with a 500 fallback when nothing is registered (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:59-65); MaxExportRows the same way, with DefaultMaxExportRows = 100_000 (EntityControllerBase.cs:472) used both when no settings exist and when a host configures a non-positive value (:78-85, :266, ADR-078); UseMiniProfiler gates MiniProfilerExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:18) and the profiling repository wrappers in RepositoryFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:34, :58); DatabaseInitStrategy drives the startup switch in DatabaseInitializationExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:105-115), where any third value throws an exception naming the two valid ones (:195).


ModuleSettings

MMCA.Common.Application · MMCA.Common.Application.Settings · MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModuleSettings.cs:6 · Level 0 · class (sealed)

  • What it is: the per-module configuration entry bound from Modules:{Name} in appsettings.json. Enabled (default true) controls whether the module's service tree is registered; RemoteDependencies lists dependency module names that are satisfied by an extracted remote service rather than an in-process module.

  • Depends on: BCL only (List<string> plus the options binder).

  • Concept introduced, the module-extraction boundary expressed as configuration. [Rubric §7, Microservices Readiness] assesses whether a module can be lifted into its own service without rewriting application code. ADR-008 is the why: when Catalog is extracted, the host sets "Catalog": { "Enabled": false }, and any module that still depends on it adds "RemoteDependencies": [ "Catalog" ]. ModuleLoader then treats that dependency as satisfied, lets the disabled module's RegisterDisabledStubs put the contract type into DI, and the host afterwards replaces the stub with a real gRPC client adapter. The XML doc walks exactly this Catalog/Sales example, including the sample JSON (ModuleSettings.cs:11-36). Extraction therefore becomes a configuration plus wiring change, not a code change.

  • Walkthrough: bool Enabled { get; init; } = true (ModuleSettings.cs:9), init-only so it cannot be mutated after binding. List<string> RemoteDependencies { get; set; } = [] (:38), and note this one is set, not init, because the IConfiguration binder needs a settable collection to populate; the resulting CA2227 ("collection properties should be read only") analyzer error is suppressed with an inline #pragma plus an explanatory comment (:37-39), an acknowledged and documented trade-off rather than an oversight.

  • Why it's built this way: a plain POCO bound by the options pattern keeps the configuration model decoupled from the module infrastructure, and the Enabled flag lets a deployment switch off a whole module without deleting code.

  • Where it's used: as the value type of ModulesSettings (the "Modules" dictionary), read for every discovered IModule by ModuleLoader during composition, and enumerated directly by ModuleControllerFeatureProvider when it filters out the controllers of disabled modules (MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:36-39).


QueryCachePipelineSettings

MMCA.Common.Application · MMCA.Common.Application.Settings · MMCA.Common/Source/Core/MMCA.Common.Application/Settings/QueryCachePipelineSettings.cs:20 · Level 0 · class (sealed)

  • What it is: the Application layer's narrow view of the Cache configuration section, carrying exactly one knob: how long a request that missed the cache waits for the per-key populate lock before giving up and running the handler uncached.

  • Depends on: BCL only (TimeSpan, Timeout.InfiniteTimeSpan). Bound by Infrastructure, read by CachingQueryDecorator<TQuery, TResult>.

  • Concept introduced, splitting one configuration section across two layers without a layer violation. [Rubric §3, Clean Architecture] assesses whether the dependency rule survives contact with real configuration. The rest of the Cache section (TTL policy, key prefix) is bound in Infrastructure beside CacheSettings, but the caching decorator lives in the Application layer, and Application cannot reference Infrastructure. Rather than duplicating a settings class or pushing the decorator down a layer, the framework declares this one-property class in Application and lets Infrastructure bind it: both types read the same Cache:PopulateLockTimeout key, so they cannot drift (QueryCachePipelineSettings.cs:3-14, and the reciprocal note on CacheSettings at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheSettings.cs:18). [Rubric §12, Performance & Scalability]: the same SectionName plus init shape as every other settings class here (ApplicationSettings introduces it). [Rubric §12, Performance and Scalability]: the knob exists for cache-stampede control, and the default deliberately preserves the strongest form of it. [Rubric §29, Resilience and Business Continuity]: the remarks call the behavior fail-open outright, "the value bounds how long a request waits, never whether it succeeds" (:16-18), which is the same posture every other cache failure takes in this framework.

  • Walkthrough: SectionName = "Cache" (:23), the same section Infrastructure's own cache settings bind to. DefaultPopulateLockTimeout = Timeout.InfiniteTimeSpan (:29), a static readonly so both the settings object and a hand-constructed decorator can reach the same fallback. TimeSpan PopulateLockTimeout { get; init; } = DefaultPopulateLockTimeout (:42). The remarks spell out the trade (:35-41): waiting indefinitely means exactly one request per key populates the entry and the rest are served from it, whereas a finite value bounds the wait so a pathologically slow populate cannot hold a queue behind it, at the cost of several requests running the same query at once. Zero or a negative value means no bound, exactly like the default.

  • Why it's built this way: keeping the knob in Application is what lets CachingQueryDecorator take it as a constructor dependency at all, and making the fallback a static readonly on the settings type is what lets the decorator's optional IOptions<> parameter degrade cleanly to framework behavior in a unit test or in a host that never called AddCaching.

  • Where it's used: bound by Infrastructure's AddCaching path, with validation when a configuration is supplied (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:48-51) and as a bare AddOptions<T>() when it is not, so IOptions<QueryCachePipelineSettings> always resolves to the framework defaults rather than failing the host (:56, comment at :33-38). Read by CachingQueryDecorator<TQuery, TResult>, which takes it as an optional constructor parameter (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:48), falls back to DefaultPopulateLockTimeout when it is absent (:81-82), and on a lock timeout logs, records a cache miss and runs the inner handler without caching the result (:84-92). Pinned by CacheSettingsTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Settings/CacheSettingsTests.cs:93, :110-111).


IUserOwnedRequest

MMCA.Common.Application · MMCA.Common.Application.Users · MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserOwnedRequest.cs:8 · Level 1 · interface

  • What it is: IUserScopedRequest plus the authenticated caller. It adds CurrentUserId and the caller's (nullable) role claim, which is exactly the input the shared owner-or-privileged-role check needs.

  • Depends on: IUserScopedRequest (Level 0, its base interface, IUserOwnedRequest.cs:8) and the UserIdentifierType alias.

  • Concept introduced, carrying the caller inside the message. [Rubric §11, Security] assesses whether authorization decisions are made deliberately and consistently rather than ad hoc. In this codebase resource-ownership authorization exists on two levels: at the API level as an action filter plus a specification (ADR-033), and at the handler level as this interface plus UserOwnershipRule. The controller projects the caller's claims into the command or query, and the handler decides. Putting the caller in the message rather than injecting a current-user service into the handler keeps the handler a pure function of its input, which is what makes the shared bases unit-testable with no HTTP context ([Rubric §14, Testability]).

  • Walkthrough: two added members. UserIdentifierType CurrentUserId { get; } (IUserOwnedRequest.cs:11) is the authenticated caller. string? CurrentUserRole { get; } (IUserOwnedRequest.cs:14) is the role claim, explicitly nullable because a token may carry no role at all. UserId is inherited, so an implementing record must expose both the target and the caller.

  • Why it's built this way: the XML doc names the goal (IUserOwnedRequest.cs:3-7), a single uniformly applied owner-or-privileged-role check across the account-deletion and data-export use cases in both apps. The role itself is deliberately not interpreted here: each app owns its own role vocabulary, so the interface carries the raw claim string and the rule takes an already-evaluated boolean.

  • Where it's used: the type constraint on all three consumers of the ownership rule: DeleteUserHandlerBase<TUser, TCommand> (.../DeleteUser/DeleteUserHandlerBase.cs:42), ExportUserDataHandlerBase<TUser, TQuery> (.../ExportUserData/ExportUserDataHandlerBase.cs:55), and the API-layer DataExportControllerBase<TQuery> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:64). It is also the parameter type of UserOwnershipRule.CheckOwnership. Implemented app-side by each export query (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataQuery.cs:12, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataQuery.cs:13) and by each delete command, both of which pair it with ICacheInvalidating (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:17).


IUserScopedCommand<out TRequest>

MMCA.Common.Application · MMCA.Common.Application.Users · MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:13 · Level 1 · interface

  • What it is: IUserScopedRequest plus an embedded request payload. A command implementing it says "I target this user, and here is the DTO the caller sent".

  • Depends on: IUserScopedRequest (Level 0, its base interface). Nothing external.

  • Concept, covariant shape interfaces and the deliberate non-overlap with ICommandWithRequest<TRequest>. The type parameter is declared out TRequest (IUserScopedCommand.cs:13), so IUserScopedCommand<DerivedRequest> is usable where IUserScopedCommand<BaseRequest> is expected. The more instructive part is the XML doc's warning (IUserScopedCommand.cs:6-11): this interface is deliberately separate from ICommandWithRequest<out TRequest>, even though both expose a Request property, because ICommandWithRequest also opts the command into automatic CommandRequestValidator<TCommand, TRequest> registration (see ScanModuleApplicationServices in DependencyInjection, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:119-131). That is a per-app decision: ADC and Store agree on it for the password change and disagree on it for preferences. A command may implement both; implementing this one alone changes no pipeline behavior. [Rubric §1, SOLID]: two interfaces because there are two responsibilities, shape versus pipeline opt-in, even though they would collapse neatly into one.

  • Walkthrough: one added member, TRequest Request { get; } (IUserScopedCommand.cs:16), on top of the inherited UserId.

  • Why it's built this way: the split is what lets one shared handler base read any app's command uniformly while each app keeps its own validation and cache-invalidation posture. Store's change-password command implements both interfaces (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:13) while its preferences command implements only this one (.../ChangePreferences/ChangePreferencesCommand.cs:12); ADC's equivalents add ICacheInvalidating on top (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:16, .../ChangePreferences/ChangePreferencesCommand.cs:15).

  • Where it's used: the constraint on ChangePasswordHandlerBase<TUser, TCommand> (.../ChangePassword/ChangePasswordHandlerBase.cs:29) and ChangePreferencesHandlerBase<TUser, TCommand> (.../ChangePreferences/ChangePreferencesHandlerBase.cs:27), and the two constraints on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:48-49), where the base reads the app-supplied command back only through this interface and its own doc records that the preferences query has no equivalent marker (UserAccountAuthControllerBase.cs:29).


UserDataExportSectionResult

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ExportUserData · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:47 · Level 1 · record (sealed)

  • What it is: what one IUserDataExportSection hands back: either its payload, or the fact that the payload could not be produced plus a caller-safe reason.

  • Depends on: UserDataExportSectionDefaults (Level 0, for the fallback reason string). BCL only otherwise (object, ArgumentException). It is the application-layer twin of the wire DTO UserDataExportSectionDTO that the handler copies it into.

  • Concept introduced, "unavailable" as a first-class value rather than an exception. [Rubric §29, Resilience, Reliability and Business Continuity] assesses how a system behaves when a dependency is down. In a fan-out over N contributors, an exception is the wrong currency: it either aborts the whole package or gets swallowed. Modelling the negative outcome as a value (Available = false plus a reason) means the degraded case is visible in the document the data subject receives, and the caller cannot forget to handle it. [Rubric §2, Design Patterns]: the two named static factories are the only construction paths, so an instance is always in one of exactly two legal shapes, the same private-construction discipline value objects use elsewhere in this codebase.

  • Walkthrough

    • Four init members: required string SectionName (IUserDataExportSection.cs:50), required bool Available (:53), object? Data (:60), and string? UnavailableReason (:67). The two required members are the ones a result is meaningless without; the two nullable ones are mutually exclusive in practice.
    • Data is typed object? on purpose (:55-59): every contributor publishes its own DTO shape and the export is serialized as one JSON document, so the payload type cannot be known here. Its doc marks it PII by design: never log it, never cache it.
    • UnavailableReason (:62-66) is documented as reaching the data subject, so it must never carry internal exception detail.
    • Complete(sectionName, data) (:73-83) guards with ArgumentException.ThrowIfNullOrWhiteSpace(sectionName) (:75) and returns Available = true. Note that a null payload is legal here: a section that provably holds nothing for this user returns a complete result with an empty body, which is a truthful answer rather than an unknown one (IUserDataExportSection.cs:11-14).
    • Unavailable(sectionName, reason = null) (:91-101) applies the same name guard (:93) and falls back to UserDataExportSectionDefaults.UnavailableReason when the caller supplies none (:99).
  • Why it's built this way: a sealed record with init members gives structural equality and immutability for free, which matters because these instances flow straight into a document that must not be mutated after assembly (ADR-076).

  • Where it's used: the return type of IUserDataExportSection.ExportAsync (IUserDataExportSection.cs:38), consumed by ExportUserDataHandlerBase<TUser, TQuery>'s RunSectionAsync, which copies its four fields into a UserDataExportSectionDTO (ExportUserDataHandlerBase.cs:177-183). Produced by every concrete section, for example Store's SalesUserDataExportSection returning Complete with an empty payload for an account with no linked customer (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/SalesUserDataExportSection.cs:45) and with the mapped payload otherwise (:56).


ModulesSettings

MMCA.Common.Application · MMCA.Common.Application.Settings · MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModulesSettings.cs:7 · Level 1 · class (sealed)

  • What it is: a Dictionary<string, ModuleSettings> subclass bound from the "Modules" configuration section, with two helper methods over the map: IsModuleEnabled(moduleName) and IsDependencyRemote(consumerModule, dependencyModule).

  • Depends on: ModuleSettings (Level 0, the entry/value type).

  • Concept: the same options-pattern shape, but realised by subclassing the dictionary so appsettings.json can express an arbitrary map of module name to settings without a hand-written model class per module. [Rubric §7, Microservices Readiness]: IsDependencyRemote is the extracted-service hook, and when a module's dependency is met by a remote service rather than an in-process module, it returns true and ModuleLoader treats the dependency as satisfied.

  • Walkthrough: SectionName = "Modules" (ModulesSettings.cs:10). IsModuleEnabled (:18-19) is TryGetValue followed by settings.Enabled, so a module absent from configuration is treated as disabled, not enabled, which the XML doc states explicitly (:12-15). IsDependencyRemote (:30-32) does TryGetValue for the consumer, then settings.RemoteDependencies.Contains(dependencyModule, StringComparer.OrdinalIgnoreCase), case-insensitive so deployment configuration need not match casing exactly.

  • Why it's built this way: subclassing Dictionary<,> rather than wrapping one keeps the binder's job trivial (the section is literally a map) while still giving the two questions the loader asks a named, testable home instead of leaving TryGetValue chains scattered through composition code.

  • Where it's used: bound and validated alongside ApplicationSettings by ModuleHostExtensions.AddModuleHost, which falls back to an empty map when the section is absent (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:69-76) and carries it on ModuleHostContext (ModuleHostContext.cs:47). Consumed by ModuleLoader for both the enable check and the remote-dependency bypass (ModuleLoader.cs:112, :132, :136, :213), and by ModuleControllerFeatureProvider to keep MVC from mapping a disabled module's controllers (MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:28-29, :36-41); it is also the optional first parameter of AddAPI (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:45).


IUserDataExportSection

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ExportUserData · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:20 · Level 2 · interface

  • What it is: the contributor contract for a data-subject export. One implementation is one module (or one cross-service peer client) that holds personal data keyed by user and can hand it over on request.

  • Depends on: UserDataExportSectionResult (Level 1, its return type) and the UserIdentifierType alias. Nothing external beyond Task and CancellationToken.

  • Concept introduced, the accumulating-registration extension point. [Rubric §5, Vertical Slice] assesses whether a feature's pieces cluster inside the boundary that owns them: the export document is assembled centrally, but what each module contributes is written and registered by that module, so adding personal data to a module means adding one section, not editing a central projection. [Rubric §7, Microservices Readiness]: a section may be backed by an in-process repository read or by a gRPC client to an extracted peer, and the export handler cannot tell the difference. [Rubric §30, Compliance, Privacy and Data Governance]: this is the mechanism by which "all personal data we hold about you" stays complete as modules are added, which is the obligation ADR-076 exists to discharge. [Rubric §29, Resilience]: sections are explicitly best-effort, and the contract says so in its own remarks (IUserDataExportSection.cs:8-14): a throwing section degrades to Available = false and the export still succeeds, because one unreachable peer must never deny a data subject the rest of their data.

  • Walkthrough: two members.

    • string SectionName { get; } (IUserDataExportSection.cs:27), the stable name the section is published under in the document ("Engagement", "Sales"). The doc calls it part of the contract rather than a label to reword, because it appears verbatim in the package a data subject reads.
    • Task<UserDataExportSectionResult> ExportAsync(UserIdentifierType userId, CancellationToken cancellationToken = default) (IUserDataExportSection.cs:38-40). Its doc states the tolerance explicitly: throwing is permitted, and the handler degrades the section, but returning UserDataExportSectionResult.Unavailable is preferred where the reason is known (:34-37).
    • Registration is a separate one-liner: AddUserDataExportSection<TSection>() does TryAddScoped<TSection>() plus TryAddEnumerable(ServiceDescriptor.Scoped<IUserDataExportSection, TSection>()) (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:38-44). TryAddEnumerable is the load-bearing call: registrations accumulate across modules while the same type registered twice is added once, and registration order becomes document order (DependencyInjection.Extensibility.cs:19-26). The lifetime is scoped, so a section runs inside the request's unit of work and may take scoped dependencies (DependencyInjection.Extensibility.cs:27-30).
  • Why it's built this way: the alternative, a central export handler that knows every module's data, would couple the Identity module to every other module and would break the moment one of them was extracted into its own service. Fan-out over an injected IEnumerable<IUserDataExportSection> keeps that knowledge inside each module and turns extraction into a change of what the section calls, not of who contributes.

  • Where it's used: injected as IEnumerable<IUserDataExportSection> into ExportUserDataHandlerBase<TUser, TQuery> (ExportUserDataHandlerBase.cs:51). Implemented by EngagementUserDataExportSection and NotificationUserDataExportSection, registered in that order (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:53-54), and by Store's SalesUserDataExportSection (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/SalesUserDataExportSection.cs:22), registered by its Identity module (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/IdentityModule.cs:40). Two more implementations exist only as test doubles that pin the degradation contract, ThrowingSection and CancellingSection (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:309 and :320).

  • Caveats / not-in-source: whether a given section's peer is reachable in a given environment is a deployment fact, not a source fact. The source settles only that an unreachable peer degrades one section.


UserOwnershipRule

MMCA.Common.Application · MMCA.Common.Application.Users · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserOwnershipRule.cs:21 · Level 2 · class (static)

  • What it is: a single static method encoding the self-service authorization rule shared by every use case that acts on one account on behalf of its owner: the caller must be the account owner, or hold the app's privileged role. It returns null when allowed and a ready-made Error when not.

  • Depends on: IUserOwnedRequest (Level 1, the parameter type), plus Error and ErrorType from MMCA.Common.Shared.Abstractions (imported at UserOwnershipRule.cs:1).

  • Concept introduced, the "return the error, do not throw" authorization helper. [Rubric §11, Security] assesses whether authorization is uniform and auditable. The idiom being hoisted here (caller is not the owner and has no bypass role, therefore forbidden) had been written out four times across the two apps, in account deletion and data export in each, which the XML doc records as the motivation (UserOwnershipRule.cs:9-14). [Rubric §2, Design Patterns]: it is deliberately a plain static helper, not a base class or a decorator, because at the time of the hoist the two data-export handlers were expected to stay app-level and still needed the identical decision and the identical error shape. [Rubric §1, SOLID]: the role test arrives already evaluated as a bool, so the helper never learns either app's role vocabulary (UserRole.IsAdmin, or whatever the app's privileged role is), and every such test is case-insensitive on the app side because a role claim may carry any casing (UserOwnershipRule.cs:15-19). This is the Application-layer counterpart to the API-layer ownership axis of ADR-033; that ADR records the filter and specification forms and does not name this helper.

  • Walkthrough: one method, static Error? CheckOwnership(IUserOwnedRequest request, bool callerHasPrivilegedRole, string code, string message, string source) (UserOwnershipRule.cs:38-43). It guards with ArgumentNullException.ThrowIfNull(request) (:45), then evaluates one conditional expression: if request.CurrentUserId == request.UserId || callerHasPrivilegedRole, return null (allowed); otherwise return Error.Forbidden(...) with the caller-supplied code, message, and source, and with target fixed to nameof(IUserOwnedRequest.UserId) (:47-53). Fixing the target while parameterising code, message, and source is what keeps the error payload identical to the four hand-written copies it replaced, since each of those reported its own handler name as the source.

  • Why it's built this way: returning Error? rather than throwing keeps the caller on the framework's Result pattern (ADR-013): the handler wraps it as Result.Failure(forbidden) and the API layer maps it to 403 through the usual error mapping, with no exception unwinding on an expected authorization outcome.

  • Where it's used: both shared account-scoped bases call it first thing in HandleAsync. DeleteUserHandlerBase<TUser, TCommand> passes HasDeletePrivilege(command.CurrentUserRole) and the code "User.DeleteForbidden" with the message "You can only delete your own account." (.../DeleteUser/DeleteUserHandlerBase.cs:62-67); ExportUserDataHandlerBase<TUser, TQuery> passes HasExportPrivilege(query.CurrentUserRole) and the code "User.ExportForbidden" with the message "You can only export your own account data." (.../ExportUserData/ExportUserDataHandlerBase.cs:81-86). Both pass their own HandlerName as the source, so the error still names the concrete app handler.


ExportUserDataHandlerBase<TUser, TQuery>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ExportUserData · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49 · Level 8 · class (abstract partial, generic)

  • What it is: the shared data-subject export workflow (GDPR/CCPA access and portability). It authorizes the caller, loads the account read-only, asks the subclass for the app's own snapshot of that account, fans out best-effort over every registered IUserDataExportSection, and assembles one JSON-ready package the data subject can be handed.

  • Depends on: IUnitOfWork and, through it, IReadRepository<TEntity, TIdentifierType>; IUserDataExportSection (injected as an IEnumerable); IUserOwnedRequest (the TQuery constraint) and UserOwnershipRule; AuditableAggregateRootEntity<TIdentifierType> (the TUser constraint); IQueryHandler<in TQuery, TResult> (the contract it implements); Result / Error; the Shared-layer DTOs UserDataExportDTO and UserDataExportSectionDTO (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15 and :61); and UserDataExportSectionDefaults. Externals: TimeProvider and ILogger (both injected through the primary constructor, ExportUserDataHandlerBase.cs:50-53), plus the [LoggerMessage] source generator from Microsoft.Extensions.Logging for its one private log method (:201-202).

  • Concept introduced, the template-method handler that owns the workflow and delegates only what is genuinely app-specific. [Rubric §2, Design Patterns] assesses deliberate pattern use: this is Template Method applied to a use case, with exactly three extension points (one abstract role check, one abstract projection, one virtual tail) and everything else fixed. [Rubric §15, Best Practices & Code Quality]: the two apps previously carried near-identical export handlers; hoisting the workflow left each subclass with the two decisions that actually differ. [Rubric §30, Compliance, Privacy and Data Governance]: the assembled document is PII by design, so its class doc states it must never be logged or cached, and notes that the caching query decorator is not applicable because the query implements no IQueryCacheable (ExportUserDataHandlerBase.cs:42-45). [Rubric §29, Resilience]: the fan-out degrades per section rather than failing, since a data-subject request carries a legal deadline (ADR-076). [Rubric §14, Testability]: because authorization arrives inside the query and time arrives as TimeProvider, the whole workflow is exercised with mocks and no host (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:17).

  • Walkthrough

    • Shape and constraints (ExportUserDataHandlerBase.cs:49-55): a primary-constructor abstract partial class (partial so the logging source generator can supply the body of its private log method) taking IUnitOfWork, IEnumerable<IUserDataExportSection>, TimeProvider, and ILogger, implementing IQueryHandler<TQuery, Result<UserDataExportDTO>>, with TUser : AuditableAggregateRootEntity<UserIdentifierType> and TQuery : IUserOwnedRequest.
    • CurrentFormatVersion = "1.0" (:61) is stamped into the envelope. Its doc is precise about scope (:57-60): it versions the envelope only, so an app changing its own subject or section payloads does not move it.
    • UnitOfWork is exposed protected (:64) purely so a subject-snapshot override can read further aggregates. HandlerName is virtual and defaults to GetType().Name (:71), so a subclass that kept the pre-hoist class name reports the identical error payload it did before.
    • HandleAsync (:74-122). It null-guards the query (:78), then runs the ownership gate through UserOwnershipRule.CheckOwnership with HasExportPrivilege(query.CurrentUserRole) and the code "User.ExportForbidden" (:81-90), failing fast on rejection.
    • The read (:92-98): unitOfWork.GetReadRepository<TUser, UserIdentifierType>() then GetByIdAsync. The class doc calls this out as a deliberate change from the two pre-hoist app copies, which used the read-write repository: this is a query handler, it never saves, and a no-tracking read is the correct choice for a projection (:37-41). A missing account returns Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name) (:96-97).
    • The subject snapshot (:100): one await on the abstract BuildSubjectSnapshotAsync.
    • The fan-out (:102-108): a plain foreach over the injected sections, awaited one at a time. The comment states both reasons (:102-103): sections share the scoped unit of work and its DbContext, which is not thread-safe, and registration order is the published order of the document. This is the one place where the obvious "parallelize the I/O" instinct is wrong.
    • The envelope (:110-117): FormatVersion, GeneratedOn = timeProvider.GetUtcNow(), UserId, the subject, and the section envelopes.
    • The tail (:119): OnExportCompletedAsync, then Result.Success(export) (:121).
    • RunSectionAsync (:166-199) is the degradation boundary. It calls the section, copies the four result fields into a UserDataExportSectionDTO (:177-183), and catches with the filter when (ex is not OperationCanceledException) (:185), so cancellation is not degradation and propagates as cancellation. On any other exception it logs the full exception through its own private source-generated ExportSectionUnavailable (:190, declared at :201-202 at LogLevel.Warning with the template "Data-subject export section {Section} unavailable for user {UserId}; export continues with Available=false") and returns an envelope with Available = false and the generic UserDataExportSectionDefaults.UnavailableReason (:192-197): detail to the log, nothing internal to the subject.
    • The three extension points: HasExportPrivilege(string?) (abstract, :130), the role that bypasses ownership; BuildSubjectSnapshotAsync(TUser, TQuery, CancellationToken) (abstract, :144-147), which fields of the account are portable personal data, asynchronous and given the query because an app may need to read a second owned aggregate (:22-25); and OnExportCompletedAsync(...) (virtual, defaulting to Task.CompletedTask, :159-164), the app's tail for an access-log row or a metric, documented as not mutating the export and as owning its own failures because the export has already succeeded (:149-153).
  • Why it's built this way: ADR-076 records the decision, and the shape deliberately mirrors DeleteUserHandlerBase<TUser, TCommand>: the same ownership gate through the same helper, the same privileged-role hook. The credential fields are excluded from the snapshot by contract (:132-136): a password hash and salt, a refresh token, and an external-provider key are secrets, not portable personal data. The degraded-section log is Warning, not Error, on purpose: a degraded section is an expected, handled outcome of a best-effort fan-out and the request itself still succeeds (:201). Declaring that log method on the class itself keeps it next to the only call site, while the log category is whatever ILogger the app subclass passes to the primary constructor.

  • Where it's used: subclassed in each app's Identity Application assembly, where the convention scanner picks the concrete subclass up as an ordinary IQueryHandler with no extra registration (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:111-115, and the point is spelled out in the export-section registration doc at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:31-36). ADC's ExportUserDataHandler closes it over User and ExportUserDataQuery and overrides only the two abstract members (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30, privilege = UserRole.IsOrganizer at :38, snapshot at :41); Store's does the same with its own User (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:34). Behaviour is pinned by ExportUserDataHandlerBaseTests, including a section that throws (.../ExportUserDataHandlerBaseTests.cs:138) and a section that cancels (:217).

  • Caveats / not-in-source: the framework also ships an abstract [FeatureGate]-d endpoint, DataExportControllerBase<TQuery> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:60-61), but neither app subclasses it: both kept their own export endpoints on top of this handler base, which the ADR records as an unadopted part of the decision.


SoftDeletedUserValidator<TUser>

MMCA.Common.Application · MMCA.Common.Application.Users · MMCA.Common/Source/Core/MMCA.Common.Application/Users/SoftDeletedUserValidator.cs:20 · Level 8 · class (sealed, generic)

  • What it is: the one shared implementation of ISoftDeletedUserValidator. It answers a single question, "does a row for this user exist and is it soft-deleted", in one query that deliberately bypasses the global soft-delete query filter.

  • Depends on: ISoftDeletedUserValidator (the interface it implements, from MMCA.Common.Application.Interfaces.Infrastructure.Auth, imported at SoftDeletedUserValidator.cs:1), IUnitOfWork (injected via primary constructor), IRepository<TEntity, TIdentifierType> (obtained from the unit of work), and AuditableAggregateRootEntity<TIdentifierType> as the TUser constraint (SoftDeletedUserValidator.cs:3, :20). Its Level 8 position is inherited from that repository and unit-of-work chain, not from any complexity of its own.

  • Concept introduced, closing a generic over the app's aggregate instead of subclassing. [Rubric §11, Security] assesses whether a revoked principal actually loses access: stateless JWT means a token stays valid until it expires, so ADR-047 (BR-133, named in the class doc at SoftDeletedUserValidator.cs:8) adds a middleware that rejects an authenticated caller whose account has since been soft-deleted, and this class is the lookup behind it. [Rubric §8, Data Architecture]: the check must see rows the rest of the application cannot, so it passes ignoreQueryFilters: true to punch through the soft-delete global filter for this one predicate. [Rubric §15, Best Practices & Code Quality]: because the type is generic over TUser rather than abstract, an app supplies only a type argument at registration and needs no per-app subclass (SoftDeletedUserValidator.cs:12-18).

  • Walkthrough: a primary-constructor class taking IUnitOfWork unitOfWork, constrained where TUser : AuditableAggregateRootEntity<UserIdentifierType> (SoftDeletedUserValidator.cs:20-21). The single method IsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken cancellationToken = default) (:23-25) resolves the repository through unitOfWork.GetRepository<TUser, UserIdentifierType>() (:27), then returns repository.ExistsAsync(u => u.Id == userId && u.IsDeleted, ignoreQueryFilters: true, cancellationToken: cancellationToken) with ConfigureAwait(false) (:30-33). Note it is GetRepository off the unit of work rather than a constructor-injected IRepository<,>, which is the framework-wide rule for repository access, and the predicate is written against the open type parameter but closes over the concrete entity at run time, so EF translates it exactly as a hand-written app query would.

  • Why it's built this way: one query answers both halves of the question (exists and is deleted), so the middleware pays a single round trip and cannot mistake "unknown user" for "deleted user" (SoftDeletedUserValidator.cs:30). Expressing the predicate against TUser keeps the framework free of any reference to an app's User aggregate while still producing a fully translated EF query.

  • Where it's used: registered per app, closed over that app's User aggregate: services.TryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>() in MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:38 and MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:43. The consumer is SoftDeletedUserMiddleware, which resolves the interface lazily per request via context.RequestServices.GetService<ISoftDeletedUserValidator>() (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:114, rationale in its own doc at :43), so a host that registers no validator degrades to a no-op instead of failing. Each app also pins the behaviour directly, across the deleted, live, unknown, and cancellation cases (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/SoftDeletedUserValidatorTests.cs:15, tests at :26, :42, :58, and :74).

AssemblyReference

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/AssemblyReference.cs:5 · Level 0 · class (static)

  • What it is: a tiny static class that exposes the assembly it lives in plus that assembly's simple name. It is the Infrastructure layer's assembly-marker anchor, a deliberate, business-free type whose only job is to name "this assembly" for convention-based scanning.

  • Depends on: System.Reflection.Assembly (BCL) only (AssemblyReference.cs:1). No first-party dependencies, which is why it sits at Level 0.

  • Concept introduced, assembly-marker types for convention scanning. [Rubric §2, Design Patterns] assesses whether recurring problems use recognised patterns; when a registration or test needs "every type in this assembly", handing the scanner a purpose-built anchor (typeof(AssemblyReference).Assembly) is the idiomatic form, far more stable than typeof(SomeRandomHandler).Assembly pointing at a real class that might move or be renamed. [Rubric §1, SOLID] (DIP): registration code depends on a stable, meaningless token rather than a concrete business type, so refactoring real Infrastructure types never breaks a scan. The same shape (AssemblyReference + ClassReference) repeats in every layer package (Application, Domain, API, and here in Infrastructure) so each assembly is self-describing without any cross-layer reference.

  • Walkthrough: two public static readonly fields resolved once at type initialization (AssemblyReference.cs:7-8): Assembly via typeof(AssemblyReference).Assembly (AssemblyReference.cs:7), and AssemblyName via Assembly.GetName().Name with a ?? string.Empty fallback (AssemblyReference.cs:8) so the field is never null even when the runtime reports no simple name.

  • Why it's built this way: a purpose-built anchor decouples scanning from any business type, and repeating the identical shape in every package keeps each assembly self-describing without cross-layer references.

  • Where it's used: the Scrutor entity-configuration scan inside DependencyInjection.AddInfrastructure uses FromAssemblyOf<ClassReference>() (the non-static companion, below); the NetArchTest architecture maps pin this assembly through the same anchor.


ClassReference

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/AssemblyReference.cs:11 · Level 0 · class

  • What it is: the non-static companion to AssemblyReference in the Infrastructure layer, an empty, instantiable class used wherever a generic type parameter needs an assembly anchor and a static class will not satisfy the constraint.

  • Depends on: nothing first-party; nothing from the BCL beyond object.

  • Concept: the companion half of the marker pattern taught under AssemblyReference. C# static classes cannot be used as generic type arguments, so any registration helper constrained to an instantiable reference type (for example Scrutor's FromAssemblyOf<T>()) is handed ClassReference instead of AssemblyReference. [Rubric §33, Developer Experience] assesses how conventional the inner loop is: one token stands in for "this assembly" everywhere, so a developer wiring a new scan never has to hunt for a suitable real type.

  • Walkthrough: a single-line body-less type declaration at AssemblyReference.cs:11 (public class ClassReference;). No members.

  • Where it's used: DependencyInjection.AddInfrastructure calls services.Scan(scan => scan.FromAssemblyOf<ClassReference>()...) (DependencyInjection.cs:134-138, the anchor itself at :112) to discover every IEntityTypeConfigurationBase<TEntity, TIdentifierType> in the Infrastructure assembly and register it as its implemented interfaces with a scoped lifetime.


InProcessLockHandle

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:79 · Level 0 · class (private nested, sealed)

  • What it is: the disposable release token that InProcessDistributedLock hands back on a successful acquire. Disposing it removes exactly the key that acquisition added, exactly once.

  • Depends on: nothing first-party. BCL only: the owner's ConcurrentDictionary<string, byte> and the key are passed into its primary constructor; Interlocked provides the once-only latch, IAsyncDisposable/ValueTask the shape.

  • Concept introduced, the acquisition handle as a scope token. [Rubric §2, Design Patterns] assesses whether recurring problems use recognised patterns deliberately; returning a disposable instead of exposing a Release(key) method is the scope-bound-resource shape the IDistributedLock contract mandates, and it is what lets await using release the lock even when the guarded work throws. The token is also what makes release owner-scoped: the handle closes over the key it added, so it cannot free anybody else's acquisition.

  • Walkthrough: the primary constructor takes the owner's held dictionary and the key (InProcessDistributedLock.cs:79); a single int _released field (InProcessDistributedLock.cs:81) is the latch. DisposeAsync (InProcessDistributedLock.cs:83-91) runs Interlocked.Exchange(ref _released, 1) == 0 and only then calls held.TryRemove(key, out _) (InProcessDistributedLock.cs:85-88), so a second disposal is a no-op and the contract's "disposal is idempotent" clause holds. It returns ValueTask.CompletedTask (InProcessDistributedLock.cs:90) because removing a key from a ConcurrentDictionary is synchronous, so there is no state machine to allocate.

  • Why it's built this way: an interlocked latch rather than a plain bool because a handle can be disposed from more than one thread (an await using unwind plus an explicit dispose), and the removal has to happen exactly once, or a late second dispose would evict a key a different caller has since acquired.

  • Where it's used: constructed by InProcessDistributedLock.TryAcquireAsync (InProcessDistributedLock.cs:63) and returned as the contract's IAsyncDisposable?; the API IdempotencyFilter is the caller that disposes it.


RedisLockHandle

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:91 · Level 0 · class (private nested, sealed)

  • What it is: the release token for a RedisDistributedLock acquisition. Disposing it runs a compare-and-delete Lua script that removes the Redis key only while it still carries this acquisition's token.

  • Depends on: nothing first-party. Externals: StackExchange.Redis (IDatabase, RedisKey, RedisValue, RedisResult) and Microsoft.Extensions.Logging; Interlocked for the latch. Same handle shape as InProcessLockHandle, with real asynchronous work in the release.

  • Concept: the owner-token release, the half of the SET NX PX lock that keeps it honest (the acquire half is taught under RedisDistributedLock). [Rubric §13, Observability & Operability] assesses whether a system reports its own degradation: a release that finds nothing to delete is exactly the case where the guarded section outran its time-to-live and stopped being exclusive, so the handle logs a warning naming the key rather than swallowing it. The same applies when the release itself fails: it is best-effort by the IDistributedLock contract, so the fault is logged as a warning and the holder's already-completed work is not turned into a failure.

  • Walkthrough: the primary constructor captures the IDatabase, the already-qualified RedisKey, this acquisition's RedisValue token, and a logger (RedisDistributedLock.cs:91-95), with the same int _released latch (RedisDistributedLock.cs:97). DisposeAsync (RedisDistributedLock.cs:99-130) returns immediately when the latch was already set (RedisDistributedLock.cs:101-104); otherwise it evaluates ReleaseScript with that key and token inside a try (RedisDistributedLock.cs:111-115). The script is if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end (RedisDistributedLock.cs:36-37), one round trip that compares and deletes atomically on the server. A 0 result means the key was already gone or is owned by someone else now, so there is nothing to release and LogLockAlreadyExpired warns (RedisDistributedLock.cs:117-122, message text at RedisDistributedLock.cs:84). Any exception from the release (a Redis fault or timeout) is caught by a deliberate broad catch (Exception) under a justified CA1031 suppression (RedisDistributedLock.cs:124-129) and logged through LogLockReleaseFailed (RedisDistributedLock.cs:87-88) instead of propagating, because the release runs after the guarded work has already committed and the lock expires on its own time-to-live (RedisDistributedLock.cs:106-110). The code comment notes RedisTimeoutException is not a RedisException, hence the broad catch.

  • Why it's built this way: a plain DEL would let a caller whose lock had already expired free the next holder's lock, which is precisely the double execution the lock exists to prevent (RedisDistributedLock.cs:32-34). Doing the comparison inside a Lua script makes compare-and-delete atomic server-side instead of a racy get-then-delete from the client (ADR-017).

  • Where it's used: constructed by RedisDistributedLock.TryAcquireAsync (RedisDistributedLock.cs:72). RedisDistributedLockTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Concurrency/RedisDistributedLockTests.cs:15) asserts the acquire/release pairing against a mocked IDatabase, including the qualified key the handle deletes.


UseDatabaseAttribute

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/UseDatabaseAttribute.cs:22 · Level 0 · class (sealed attribute)

  • What it is: a declarative attribute placed on an entity type configuration class to name the logical data source (database) that entity targets. It is the "which database" half of the database-per-microservice routing story; the sibling UseDataSourceAttribute is the "which engine" half.

  • Depends on: System.Attribute (BCL) only. Its resolved logical name is consumed downstream by the data-source machinery (DataSourceResolver, EntityDataSourceRegistry) and mapped to a connection string through the DataSources configuration entries modelled by DataSourceEntrySettings / DataSourcesSettings.

  • Concept introduced, declarative database-per-service routing. [Rubric §8, Data Architecture] assesses how the model maps to physical stores; [Rubric §7, Microservices Readiness] assesses whether a module can be lifted out with its own database (ADR-006). The attribute's XML doc (UseDatabaseAttribute.cs:8-18) spells out the three-step resolution order for an entity's logical name: (1) this attribute on the concrete configuration class (inherited); (2) the module name derived from the entity namespace, the segment before Domain; (3) the literal "Default", the top-level ConnectionStrings section. A logical name with no DataSources entry (or whose connection string equals the top-level one) collapses onto the Default physical source (UseDatabaseAttribute.cs:15-17), so a host that configures nothing behaves exactly like a single-database monolith. This "convention with an explicit override" shape is the load-bearing idea: most modules never apply the attribute and ride the namespace convention.

  • Walkthrough:

    • [AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)] (UseDatabaseAttribute.cs:21). Inherited = true is deliberate: annotating a per-module configuration base class propagates the database assignment to every derived configuration, so a module can pin all its entities to one database in a single place. AllowMultiple = false forbids an ambiguous second assignment.
    • Primary-constructor parameter name (UseDatabaseAttribute.cs:22), the logical name (for example "Conference", the example the doc itself uses at :20).
    • Name get-only property (UseDatabaseAttribute.cs:25) initialized from that parameter, the value the resolver reads.
  • Why it's built this way: an attribute keeps the database choice declarative and co-located with the entity configuration rather than buried in a registration method, and Inherited = true turns per-module assignment into one annotation instead of one per entity (ADR-006, database per microservice).

  • Where it's used: applied on concrete EF entity type configuration classes in the modules; read up front by the eager EntityDataSourceRegistry so routing does not depend on a model having been built.


ApplicationNamespace

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Configuration · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:28 · Level 0 · class (static, partial)

  • What it is: the single shared value that isolates one application's Redis keys, message-bus queue names, and SignalR channel prefix from every other application on the same infrastructure. Never empty, so isolation is automatic rather than a setting a host has to remember to set (ApplicationNamespace.cs:43-47).

  • Depends on: IConfiguration and IHostEnvironment (both optional parameters), and its own [GeneratedRegex] source-generated partial property (:40-41). BCL only.

  • Concept: resolution with a fallback chain rather than a required setting. [Rubric §11, Security] and [Rubric §7, Microservices Readiness]: two applications sharing one broker or one Redis instance used to be able to derive identical resource names and collide (SEC-Common-53, SEC-Common-54), so this type exists to give every consumer one namespace they can all agree on without each of them inventing its own default.

  • Walkthrough: two public constants, one private constant, one generated regex, and two static methods.

    • ConfigurationKey = "Application:Namespace" (:29): the explicit override.
    • Fallback = "app" (:32): used only when neither configuration nor the host environment names the application.
    • HostApplicationNameKey = "applicationName" (:38): the key the generic host writes the application name to (WebHostDefaults.ApplicationNameKey), read directly when no IHostEnvironment is at hand.
    • Resolve(configuration, environment) (:51-66): prefers the explicit ConfigurationKey (sanitized); otherwise falls back to environment.ApplicationName, then to the raw applicationName configuration key; otherwise returns Fallback. Never returns empty.
    • Sanitize(name) (:75-81): strips every character the UnsafeCharacters regex flags ([^a-zA-Z0-9_-]+, :40) to - and trims leading/trailing -, because a Redis key tolerates almost anything while a MassTransit queue name and a SignalR channel prefix do not, and the whole point is one value all four consumers accept.
  • Why it's built this way: a value that is never empty means every consumer (cache key prefix, message-bus endpoint prefix, push channel prefix) gets isolation whether or not a host ever sets Application:Namespace, closing the two security findings (SEC-Common-53, SEC-Common-54) without asking every consumer to invent its own default.

  • Where it's used: read by CacheKeyPrefixOptions's CacheKeyNamespace.From(IServiceProvider) for the per-application default Redis key namespace (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:83-87), by MessageBusSettings.EndpointPrefix's resolution inside AddBrokerMessaging when the setting is unset (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:81-83), and referenced from DependencyInjection.Messaging.cs for the broker endpoint formatter. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Configuration/ApplicationNamespaceTests.cs.


SmtpSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Mail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Mail/SmtpSettings.cs:9 · Level 0 · class (sealed)

  • What it is: the Smtp section: host, port, credentials, TLS toggle, and the default sender and recipient addresses. Validated by data annotations at startup (SmtpSettings.cs:5-8).

  • Depends on: System.ComponentModel.DataAnnotations for [Range] (SmtpSettings.cs:1). Consumed by SmtpEmailSender, the IEmailSender implementation.

  • Concept: the validate-on-start gate taught under ConnectionStringSettings, here guarding a numeric range rather than a cross-section rule. [Rubric §15, Best Practices & Code Quality] assesses whether avoidable failures are moved earlier: [Range(1, 65535)] on Port (SmtpSettings.cs:21) means a configuration typo such as 0 or 70000 fails the host at boot with a named error instead of surfacing as a socket exception the first time the application sends mail. Note also the named constant instead of a magic number: DefaultSmtpPort = 25 is a public static readonly field (:15) used as the property's own default (:22), so the value is discoverable and testable rather than inlined. [Rubric §11, Security]: Password is a plain string (:28), so it is only ever as safe as the configuration provider that supplies it (user-secrets or Key Vault, never a committed appsettings.json).

  • Walkthrough: two static fields then eight { get; init; } members.

    • SectionName = "Smtp" (SmtpSettings.cs:12) and DefaultSmtpPort = 25 (:15).
    • Host (:18), Port (:22), Username (:25), Password (:28), EnableSsl (:31, defaulting to false), From (:34), To (:37), TimeoutSeconds (:53, [Range(1, 600)], default 30). Every string defaults to string.Empty, so an absent section binds cleanly; only Port and TimeoutSeconds are range-checked. To is documented as the default recipient used by the no-argument SendAsync overload (:36-37).
    • EnableSsl no longer defaults to "off" when the key is unset: SmtpTransportSecurity resolves the effective value, turning TLS on outside Development (SEC-Common-54); the property itself still carries a plain false default because it only records what the host configured, not the resolved posture (:16-24).
    • TimeoutSeconds exists because the .NET default of 100 seconds outlives most callers' own budget: a relay that accepts the TCP connection and then stops answering parks the request thread for the full 100s, and a notification burst parks one per message; 30 seconds keeps a stalled relay inside the caller's timeout instead of outliving it (:41-49).
    • The consumer is deliberately plain: SmtpEmailSender takes IOptions<SmtpSettings> in its primary constructor and snapshots .Value into a readonly field (SmtpEmailSender.cs:12-14), then builds a fresh SmtpClient per send from Host/Port plus a NetworkCredential and the resolved TLS decision (:25-29), and a MailMessage from From (:32-35).
  • Why it's built this way: annotations plus ValidateOnStart cost one line at registration and move an entire class of misconfiguration from run time to boot, the contract ADR-070 describes. Resolving TLS through SmtpTransportSecurity instead of a hard-coded default keeps the failure mode fail-closed outside Development (ADR-122).

  • Where it's used: bound with .ValidateDataAnnotations().ValidateOnStart() in AddInfrastructure (DependencyInjection.cs:79-82); read by SmtpEmailSender.


PeriodicBackgroundService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hosting/Background/PeriodicBackgroundService.cs:20 · Level 0 · class (public abstract partial)

  • What it is: the base class for fixed-interval background sweeps. It contributes four things a hand-written BackgroundService loop usually gets wrong: an enablement gate evaluated once at startup, a startup delay so the host finishes initializing first, a loop whose cycle failures are logged (through an overridable hook) without killing the loop, and every wait routed through an injected TimeProvider so a test can drive it with a fake clock (PeriodicBackgroundService.cs:20-102).

  • Depends on: Microsoft.Extensions.Hosting.BackgroundService as the base, TimeProvider and ILogger as its two primary-constructor parameters (PeriodicBackgroundService.cs:20-22), and the LoggerMessage source generator for its two log methods (:97-101), which is what makes the class partial.

  • Concept introduced, the template method with an injected clock. [Rubric §2, Design Patterns] assesses whether a pattern is used where it carries its weight, and this is a textbook template method: the base owns the invariant algorithm (gate, delay, loop, catch, wait) and the subclass supplies only the variable parts through Interval, IsEnabled, StartupDelay, LogCycleFailure, and ExecuteCycleAsync. [Rubric §29, Resilience and Business Continuity] assesses whether a background component survives its own failures: the catch at :136-139 is the whole answer, because an unhandled exception inside ExecuteAsync silently ends a hosted service for the lifetime of the process, and a reconciliation sweep that stops running is a failure nobody notices until the data is wrong. [Rubric §13, Observability & Operability] assesses whether an override can keep its own event id: LogCycleFailure is virtual (:100-101) precisely so a subclass can log its own message and event id (for a dashboard that keys on it) while still relying on the base loop to call it. [Rubric §14, Testability] assesses whether time-dependent code can be tested without waiting: Task.Delay(..., timeProvider, ...) (:118, :143) means a FakeTimeProvider advances the loop instantly, which is exactly what the tests do (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/PeriodicBackgroundServiceTests.cs:23, :66).

  • Walkthrough

    • Interval is abstract (PeriodicBackgroundService.cs:80): every subclass must state its own cadence, and nothing defaults it.
    • StartupDelay is virtual with a 15 second default (PeriodicBackgroundService.cs:86). The doc says why it exists: let the application finish initializing before background work competes with it. Tests override it to shorten the wait (PeriodicBackgroundServiceTests.cs:117).
    • IsEnabled is virtual and defaults to true (PeriodicBackgroundService.cs:93). It is read once, at the top of ExecuteAsync, and a false logs and returns (:110-114), so the service occupies no thread and no timer for the rest of the process. Flipping the toggle at runtime does not restart it.
    • LogCycleFailure(exception) is virtual (PeriodicBackgroundService.cs:100-101): the default writes one generic Error naming the service through the generated LogCycleError. The doc comment on the source spells out why it is overridable: a subclass can keep its own message and its own event id, which dashboards and tests key on.
    • ExecuteCycleAsync(stoppingToken) is the abstract single sweep (PeriodicBackgroundService.cs:50), and its contract is stated in the doc comment: exceptions are logged and do not stop the loop.
    • ExecuteAsync (PeriodicBackgroundService.cs:53-95) is the algorithm. The startup delay is wrapped in its own try whose catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) returns cleanly (:65-68), so a host stopped during the delay does not log a shutdown as an error. The loop then runs until cancellation (:70), awaiting the cycle, breaking on a shutdown cancellation (:76-80), routing any other exception through LogCycleFailure (:81-84), and waiting the interval under the same cancellation-aware guard (:86-93).
    • The two [LoggerMessage] methods (PeriodicBackgroundService.cs:97-101) are source-generated: LogDisabled at Information, LogCycleError at Error with the exception attached and a message that states the recovery ("it will retry on the next interval"). Both stamp GetType().Name, so the log line names the concrete sweep, not the base class; LogCycleError is now called through LogCycleFailure rather than directly from the loop.
  • Why it's built this way: the class doc draws the boundary explicitly (PeriodicBackgroundService.cs:12-16): this is for periodic reconciliation and cleanup work, and it is deliberately not what the outbox processor uses, because the outbox's signal-driven smart wait does not fit a fixed interval. The background-work posture is ADR-052, and a fixed-interval sweep is a different thing again from the cron-style recurring scheduler of ADR-074, which is why this base stays as small as it is.

  • Where it's used: no production type in MMCA.Common or MMCA.ADC derives from it today. ScheduledJobRunner is the closest relative and deliberately is not a subclass: it derives BackgroundService directly and only names this class in a doc comment, to record that its own 15 second startup delay matches this one (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:65-69). The base is exercised directly by a CountingSweep test double that overrides all four extension points (PeriodicBackgroundServiceTests.cs:103-121).

  • Caveats / not-in-source: nothing in this base coordinates two replicas running the same sweep, so any subclass owns that question itself. Contrast ScheduledJobRunner, which answers it with a database claim lease rather than by inheriting one.


InProcessDistributedLock

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:31 · Level 1 · class (internal, sealed, partial)

  • What it is: the fallback IDistributedLock for a host with no Redis connection registered. It serializes callers inside this one process, and it says so in the log the first time anybody uses it.

  • Depends on: IDistributedLock (the contract it implements, from MMCA.Common.Application.Interfaces, imported at InProcessDistributedLock.cs:4), ILogger<InProcessDistributedLock> injected through the primary constructor (InProcessDistributedLock.cs:31), and its own nested InProcessLockHandle. BCL: ConcurrentDictionary, Interlocked, Stopwatch, Task.Delay.

  • Concept introduced, the degraded implementation that announces itself. [Rubric §12, Performance & Scalability] assesses whether the design survives horizontal scale-out: each replica gets its own instance of this class and therefore its own held-key table, so with more than one replica a section guarded by this lock still runs once per replica (InProcessDistributedLock.cs:11-15). That is correct for a single-replica deployment, for local development, and for tests, and wrong for anything else, which is why the fallback is not silent. [Rubric §13, Observability & Operability] assesses whether an operator can see a degraded mode: the first acquisition emits a [LoggerMessage]-generated warning that names both the cause and the fix (InProcessDistributedLock.cs:75-76, "no IConnectionMultiplexer is registered ... Register a Redis client (AddRedisClient) to make it exclusive across replicas").

  • Walkthrough:

    • State. PollInterval is 25 ms (InProcessDistributedLock.cs:34), the gap between acquisition attempts while waiting for a holder. _held is a ConcurrentDictionary<string, byte> with StringComparer.Ordinal (InProcessDistributedLock.cs:36), used as a set: the value byte is a placeholder and only key presence matters. _degradationWarned is the warn-once flag (InProcessDistributedLock.cs:39).
    • Guards and the warning. TryAcquireAsync (InProcessDistributedLock.cs:42-73) rejects a blank key, a non-positive ttl, and a negative wait (InProcessDistributedLock.cs:48-50), then flips the flag with Interlocked.Exchange(ref _degradationWarned, 1) == 0 so a steady state warns once rather than per request (InProcessDistributedLock.cs:52-55).
    • The acquire loop (InProcessDistributedLock.cs:59-72). _held.TryAdd(key, 0) is the atomic test-and-set: it succeeds only for the caller that inserts the key, and that caller gets an InProcessLockHandle (InProcessDistributedLock.cs:61-63). Otherwise, if Stopwatch.GetElapsedTime(startedAt) >= wait the method returns null (InProcessDistributedLock.cs:66-69), which is what makes wait: TimeSpan.Zero a single non-blocking attempt exactly as the contract promises. Otherwise it awaits Task.Delay(PollInterval, cancellationToken) and retries (InProcessDistributedLock.cs:71).
    • Exact keys, not stripes. The remarks explain the one design choice that differs from KeyedSemaphoreStripe (InProcessDistributedLock.cs:18-25): stripes let two unrelated keys share a semaphore, which is harmless for a caller that waits indefinitely but not for a bounded wait, where the false sharing turns into a spurious "held elsewhere" answer for a key nobody holds. The table stays bounded by the number of locks held right now, not by every key the process has ever seen, because the handle removes the entry on release.
    • ttl is accepted and ignored (InProcessDistributedLock.cs:26-29). It is validated (InProcessDistributedLock.cs:49) but never used: the TTL exists to bound a holder that died without releasing, and here the holder is a task in this process, so if the process dies the table dies with it.
  • Why it's built this way: ADR-017 makes the lock a registration rather than an optional dependency, so a host always resolves something; this type is the honest floor of that guarantee. Logging the degradation once, instead of silently behaving like a lock, is what keeps "we have a distributed lock" from becoming a false belief in a multi-replica deployment.

  • Where it's used: registered by AddCaching in the Infrastructure composition root when no IConnectionMultiplexer is resolvable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:99-101, inside the IDistributedLock factory at :84-98; see DependencyInjection), which covers MMCA.Helpdesk, local single-process runs, and tests. Behavior is pinned by InProcessDistributedLockTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Concurrency/InProcessDistributedLockTests.cs:12).


UseDataSourceAttribute

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/UseDataSourceAttribute.cs:13 · Level 1 · class (sealed attribute)

  • What it is: the companion attribute to UseDatabaseAttribute. Where that one names the logical database, this one declares the database engine (DataSource: CosmosDB, Sqlite, or SQL Server) an entity type configuration targets. It is Level 1 rather than Level 0 because, unlike the pure-BCL UseDatabaseAttribute, it references the first-party DataSource enum.

  • Depends on: the DataSource enum (from MMCA.Common.Application.Interfaces.Infrastructure.Persistence, imported at UseDataSourceAttribute.cs:1) and System.Attribute (BCL).

  • Concept: engine selection for the multi-engine persistence layer, the sibling of the logical-name routing introduced under UseDatabaseAttribute. [Rubric §8, Data Architecture] again: the framework supports SQL Server, Cosmos, and SQLite simultaneously, and this attribute is how a configuration announces which engine's rules apply. In practice it is carried on the provider-specific configuration base classes (EntityTypeConfigurationSQLServer/Cosmos/Sqlite), so a concrete configuration inherits its engine, while UseDatabaseAttribute selects which database on that engine.

  • Walkthrough:

    • [AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)] (UseDataSourceAttribute.cs:12), same inheritance and single-use semantics as UseDatabaseAttribute so a provider base class propagates the engine to derived configurations.
    • Primary-constructor parameter dataSource (UseDataSourceAttribute.cs:13) of type DataSource.
    • DataSource get-only property (UseDataSourceAttribute.cs:16) exposing the chosen engine. The XML doc (UseDataSourceAttribute.cs:5-10) records that it is read by DataSourceService at model-building time to populate the entity-to-data-source cache that UnitOfWork uses to route each entity to the correct ApplicationDbContext.
  • Why it's built this way: keeping the engine on an attribute (inherited from a provider base class) means an entity's engine and database are both declarative metadata the registry can scan up front, which is what lets routing happen without first building an EF model (ADR-006).

  • Where it's used: on the per-engine EntityTypeConfiguration* base classes and, through inheritance, every concrete configuration under them; read by DataSourceService / EntityDataSourceRegistry.


SmtpTransportSecurity

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Mail · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Mail/SmtpTransportSecurity.cs:26 · Level 1 · class (static, partial)

  • What it is: the resolver behind SmtpSettings.EnableSsl. It answers whether an SMTP session must negotiate TLS, treating an unset Smtp:EnableSsl key as "secure unless the host is Development" rather than as "off" (SEC-Common-54).

  • Depends on: IConfiguration, IHostEnvironment, ILogger, and SmtpSettings (the second overload's parameter). [LoggerMessage] source generation for the warning.

  • Concept: fail-closed configuration for a security-relevant default. [Rubric §11, Security] and [Rubric §22, Compliance & Governance]: leaving Smtp:EnableSsl unset used to mean cleartext, because SmtpSettings.EnableSsl is a plain bool defaulting to false. This type reads the raw configuration key rather than the bound settings object, because the settings object cannot distinguish "configured false" from "never configured" once binding has happened (SmtpTransportSecurity.cs:39-40); only the presence of the key in configuration can. Development is the one carved-out exception, consistent with the workspace's dev-only-relaxations-fail-closed policy (ADR-122).

  • Walkthrough: two overloads of Resolve and one generated log method.

    • Resolve(configuration, environment) (:37-49): reads Smtp:EnableSsl (EnableSslConfigKey = "Smtp:EnableSsl", :29); if it parses as a bool, that value wins; otherwise returns environment?.IsDevelopment() != true, so a null environment is also treated as non-Development (secure by default).
    • Resolve(settings, configuration, environment, logger) (:65-84): calls the first overload, and when the resolved value is false, the host is not Development, settings.Host is set, and a logger was supplied, emits one LogCleartextSmtp warning naming the config key and the SMTP host before returning the same resolved value.
    • LogCleartextSmtp (:686-690): [LoggerMessage(EventId = 5401, Level = Warning)], message text states plainly that credentials and message bodies, "password-reset tokens included," travel in cleartext to the named host.
  • Why it's built this way: a security-relevant default that only reveals itself the first time someone inspects network traffic is exactly the failure mode ADR-122 exists to close off; resolving from the raw configuration key rather than the bound bool is what lets "unset" and "explicitly false" resolve differently at all.

  • Where it's used: SmtpSettings.EnableSsl's XML doc references it as the resolver of record (SmtpSettings.cs:29); called from SmtpEmailSender to decide the SmtpClient's TLS posture, and from SensitiveDataLoggingGate for the analogous decision on a different setting. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Mail/SmtpTransportSecurityTests.cs.


RedisDistributedLock

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:24 · Level 2 · class (internal, sealed, partial)

  • What it is: the cross-replica IDistributedLock, implemented as the standard SET key token NX PX ttl Redis lock against a single Redis instance.

  • Depends on: IConnectionMultiplexer and the rest of StackExchange.Redis (IDatabase, RedisKey, RedisValue, RedisResult), ILogger<RedisDistributedLock>, and an optional CacheKeyNamespace (RedisDistributedLock.cs:24-27), which is what puts this type at Level 2 rather than Level 1. It returns its nested RedisLockHandle.

  • Concept: the acquire half of the lock taught in two pieces with RedisLockHandle. [Rubric §12, Performance & Scalability] assesses scale-out correctness: moving the lock into Redis is what makes "only one of these runs at a time" true across replicas instead of true per process, which is the whole reason the abstraction exists (see InProcessDistributedLock for the degraded alternative). [Rubric §29, Resilience & Business Continuity] assesses failure behavior: the expiry carried on the SET is the crash guard (a holder that dies releases by expiry rather than wedging the key forever, RedisDistributedLock.cs:12-17), and the class documents that it is deliberately single-instance, not Redlock (RedisDistributedLock.cs:19-23), inheriting Redis's failover behavior, which is exactly why the contract is documented as best-effort. The release side honors the same posture: a fault while releasing is caught and logged by the handle (RedisDistributedLock.cs:124-129, see RedisLockHandle) rather than failing the already-committed guarded work.

  • Walkthrough:

    • State. KeyPrefix is "lock:" (RedisDistributedLock.cs:30) so lock entries cannot collide with cache entries in a shared instance. ReleaseScript (RedisDistributedLock.cs:36-37) is the compare-and-delete Lua taught under RedisLockHandle. PollInterval is 50 ms (RedisDistributedLock.cs:40); unlike the in-process poll, each retry here is a network round trip. _keys falls back to CacheKeyNamespace.None when no namespace was injected (RedisDistributedLock.cs:42), so the Cache:KeyPrefix option (when configured) qualifies lock keys the same way it qualifies cache keys.
    • Argument guards. TryAcquireAsync (RedisDistributedLock.cs:45-82) applies the same three checks as the in-process implementation: non-blank key, ttl greater than zero, non-negative wait (RedisDistributedLock.cs:51-53).
    • Key and token. The physical key is _keys.Qualify(string.Concat(KeyPrefix, key)) (RedisDistributedLock.cs:55). The token is a fresh Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) minted per acquisition (RedisDistributedLock.cs:59); the release script matches on it, and that is what makes a release owner-scoped instead of "delete whatever is there now" (RedisDistributedLock.cs:57-58).
    • The acquire loop (RedisDistributedLock.cs:64-81). StringSetAsync(redisKey, token, ttl, keepTtl: false, When.NotExists, CommandFlags.None) (RedisDistributedLock.cs:66-68) is a single atomic conditional set carrying the expiry, so exactly one replica can win a key. On success it returns a RedisLockHandle closing over the database, key, token, and logger (RedisDistributedLock.cs:70-73); once Stopwatch.GetElapsedTime(startedAt) >= wait it returns null (RedisDistributedLock.cs:75-78); otherwise it delays one PollInterval and retries (RedisDistributedLock.cs:80).
  • Why it's built this way: ADR-017 replaced the process-local guard around the idempotency filter's execute-then-store window with this, because a striped semaphore stops serializing anything once a service runs more than one replica. Choosing the one-instance SET NX PX lock over Redlock is a stated trade: simpler, dependent on a single Redis, and paired with a contract that tells callers never to lean on it for an invariant persistence can enforce.

  • Where it's used: selected by AddCaching whenever an IConnectionMultiplexer is resolvable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:88-102, see DependencyInjection), passing the same CacheKeyNamespace the distributed cache gets (DependencyInjection.Caching.cs:67, :91). The one in-framework caller is the API IdempotencyFilter; RedisDistributedLockTests covers the acquire and release commands against a mocked IDatabase.

  • Caveats / not-in-source: whether a given deployed environment actually supplies the redis connection string is an infrastructure/config fact, not a source fact, so "which implementation is live in environment X" is Not determinable from source here; the source only settles that the presence of a registered IConnectionMultiplexer decides it.


DependencyInjection

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:47 · Level 17 · class (static, extension, partial)

  • What it is: the single composition root for the entire Infrastructure layer. A static partial class split across six files by concern (DependencyInjection.cs, .Auth.cs, .Caching.cs, .Jobs.cs, .Messaging.cs, .Notifications.cs), each carrying its own C# preview extension(IServiceCollection services) block, so the registration surface reads as one type while the methods live next to the settings and services they wire. DependencyInjection.cs itself carries AddInfrastructure(IConfiguration) (:56); .Caching.cs carries AddCaching(IConfiguration?) (DependencyInjection.Caching.cs:26) and AddCommonHybridCache(Action<HybridCacheOptions>?) (:137); .Jobs.cs carries AddScheduledJobs(IConfiguration) (DependencyInjection.Jobs.cs:37), AddScheduledJob<TJob>() (:72) and AddAuditTrail(IConfiguration) (:108); .Notifications.cs carries AddNotificationInfrastructure() (DependencyInjection.Notifications.cs:26), AddPushNotifications(IConfiguration) (:41), AddNativePushNotifications(IConfiguration) (:82) and AddAzureBlobFileStorage(IConfiguration) (:114); .Messaging.cs carries AddBrokerMessaging(IConfiguration, Action?) (DependencyInjection.Messaging.cs:43) and AddTypedServiceClient<TInterface, TImplementation>(string) (:141), plus five private static helpers below its extension block (:179, :202, :241, :337, :358); .Auth.cs carries the four opt-in identity completions (DependencyInjection.Auth.cs:17-186); and DependencyInjection.cs itself also carries AddMultiTenancy(IConfiguration) (:266), AddServices() (:284), AddEntityConfigurationAssembly(Assembly) (:338) and AddStronglyTypedIds(params Assembly[]) (:366).

  • Depends on: nearly every Infrastructure type below it, wired by interface. Persistence: DbContextFactory, PhysicalDbContextFactory, DataSourceService, DataSourceResolver, EntityDataSourceRegistry, DefaultEntityConfigurationAssemblyProvider, EFQueryableExecutor, SqlServerUniqueConstraintViolationDetector, EFRepository<TEntity, TIdentifierType>, RepositoryFactory, UnitOfWork, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, TenantSaveChangesInterceptor. Messaging/outbox: IMessageBus/InProcessMessageBus/BrokerMessageBus, IEventBus/InProcessEventBus/BrokerEventBus, OutboxSignal, OutboxProcessor, OutboxCleanupService, OutboxDisabledNoticeService, OutboxAdministration, EfInboxStore/NoOpInboxStore/InboxDisabledWarningService, ServiceBusEmulatorSupport. Cross-cutting: ICacheService with DistributedCacheService/MemoryCacheService/HybridCacheService, IDistributedLock with RedisDistributedLock/InProcessDistributedLock, IJwksProvider/RsaJwksProvider, TokenService, LoginProtectionService, PasswordResetTokenService, EFRefreshSessionStore with RefreshSessionCleanupService, EventUpcasterStartupValidator, ScheduledJobRunner, IAuditTrailReader/AuditTrailReader, TenantContext, CorrelationContext, JwtForwardingDelegatingHandler. Settings: ConnectionStringSettings with ConnectionStringSettingsValidator, DataSourcesSettings/DataSourceEntrySettings, MessageBusSettings, OutboxSettings, PersistenceSettings, SmtpSettings, JwksSettings, CacheSettings, QueryCachePipelineSettings, LoginProtectionSettings, PasswordResetSettings, RefreshSessionSettings, SchedulerSettings, AuditTrailSettings, TenancySettings with TenancySettingsValidator, PushNotificationSettings, NativePushSettings, FileStorageSettings. Externals: MassTransit v8 (pinned by policy), StackExchange.Redis, Microsoft.Extensions.Caching.Hybrid, Microsoft.AspNetCore.SignalR, Microsoft.Azure.NotificationHubs, Azure.Storage.Blobs / Azure.Identity, Microsoft.Extensions.Http.Resilience, Scrutor.

  • Concept introduced, the mega-composition-root plus the swap-at-the-edge extraction pattern. [Rubric §3, Clean Architecture] assesses whether wiring lives at the edge rather than in the core: every concrete Infrastructure choice is registered here, not in Application or Domain. [Rubric §12, Performance & Scalability] and [Rubric §7, Microservices Readiness]: the method bodies are the framework's default posture, and each optional capability (broker, scheduler, audit trail, tenancy, hybrid cache, push, native push, blob storage) is a separate opt-in method a host layers on, so the same package runs as a monolith or as an extracted service without recompiling the core. The default everywhere is TryAdd*, meaning a host can pre-register its own implementation and the framework will not clobber it; the places that deliberately break that rule are the broker swap (Replace, DependencyInjection.Messaging.cs:95 and :99), AddCommonHybridCache (RemoveAll, DependencyInjection.Caching.cs:167), and the two push registrations that overwrite their own null defaults (DependencyInjection.Notifications.cs:65-66), and each says so in a comment. [Rubric §17, DevOps]: registering a capability is consistently NOT the same as enabling it, the scheduler and the audit trail both stay inert until their Enabled flag is true (DependencyInjection.Jobs.cs:31-35, :89-95), so a host ships the registration and an environment turns it on. [Rubric §15, Best Practices & Code Quality]: the two places where a misconfiguration would be silent instead throw or warn at startup, which is the running theme of this file.

  • Walkthrough (in registration order):

    • AddInfrastructure (:56-236) is the entry point. It registers the model-facing singletons (:58-59) and the three EF save interceptors as singletons because they are stateless with per-save state in a ConditionalWeakTable keyed by context (:61-69), including TenantSaveChangesInterceptor, which is registered unconditionally so a host can never leave the write-side tenancy guard half-wired (:66-69). It also registers IRawSqlQueryExecutor/EFRawSqlQueryExecutor scoped (:110), the sibling of IQueryableExecutor for the reads LINQ cannot express, scoped rather than singleton because it reaches the scope's own context through IDbContextFactory so a raw statement shares the caller's connection and any open transaction; and IConcurrencyConflictDetector/EfCoreConcurrencyConflictDetector TryAddSingleton (:120), the sibling classifier to the unique-constraint detector for a save rejected by a moved concurrency token. Near the end it calls the private AddInternalCommands helper (:222, ADR-114), then AddServices() (:224), then decorates ICurrentUserService with ImpersonatingCurrentUserService via TryDecorate (:226-233), guarded on whether ScopedUserOverride is already registered so a host whose modules each call AddInfrastructure does not stack one decorator per call.
    • Settings binding, and the one rule annotations cannot express. ConnectionStringSettings binds through AddOptions(...).ValidateDataAnnotations().ValidateOnStart() (:71-74), then ConnectionStringSettingsValidator is added through TryAddEnumerable (:81-82) with the reason inline (:76-80): "a host must reach some database" spans the ConnectionStrings section AND the DataSources one, so a SQLite-only host declaring its databases as named sources is legitimate while a host declaring none anywhere is not. The same TryAddEnumerable idiom recurs for every validator and startup check in the file, so two modules calling AddInfrastructure never run one validation twice.
    • The named-data-sources note (:84-88) is load-bearing: DataSourcesSettings is built directly from configuration.GetSection(...).Get<Dictionary<string, DataSourceEntrySettings>>() rather than through AddOptions, because a root-level dictionary section does not bind through the options pipeline. The resolver and the eager entity registry follow immediately (:89-90).
    • The physical-factory warning (:97-103): DbContextFactory is scoped (one per request) and PhysicalDbContextFactory is a singleton that must never be converted to EF context pooling, because each raw context carries per-source constructor state that pooling would silently reuse across databases. Beside them sit the stateless query executor (:105) and the unique-constraint classifier (:112-115), the latter TryAdded so a host on another engine can register its own first and keep it.
    • The Scrutor scan (:128-132) discovers every IEntityTypeConfigurationBase<TEntity, TIdentifierType> in the Infrastructure assembly via FromAssemblyOf<ClassReference>() (:129) and registers each as its implemented interfaces, scoped to match the DbContext lifetime, closing the loop back to ClassReference. AddCaching(configuration) is called immediately after (:134).
    • The auth block (:148-175) binds LoginProtectionSettings, PasswordResetSettings and RefreshSessionSettings with their scoped services, and gates one hosted service: RefreshSessionCleanupService is registered only when RefreshSessions:Enabled is true (:171-175), on the same flag that maps the table. The comment names the failure it avoids (:168-170): an unconditional registration would start an hourly sweep in every service of a modular host, all but one of which has no table to sweep.
    • Startup validation of the upcaster graph (:188-193): EventUpcasterStartupValidator is an IHostedService added through TryAddEnumerable, so a duplicate source, a self-map or a cycle fails the host at start rather than dead-lettering the first retired-contract message (ADR-090).
    • The outbox is a transport decision (:195-220). OutboxSignal is always registered (:195). Then the message-bus section is read eagerly (:203-204), passed to the private EnsureOutboxAvailableForProvider guard (:205), and MessageBusSettings.IsOutboxEnabled decides between the two hosted outbox services (OutboxProcessor plus OutboxCleanupService, :207-211) and a single OutboxDisabledNoticeService (:214). The comment states the trade in full (:197-202): a broker deployment cannot deliver without the outbox, while a single-process host would pay two hosted services, a table and a poll loop for a hop it never takes, and the OutboxMessages table stays mapped either way so flipping the flag is never a migration (ADR-100). The operator surface OutboxAdministration is scoped, because it creates one child scope per data source it visits (:217-220). The method then calls the private AddInternalCommands helper (:222) and AddServices() (:224).
    • AddCaching (DependencyInjection.Caching.cs:26-105) does two probes in one method, and its IConfiguration parameter is optional. With configuration it binds the key-prefix options plus CacheSettings and the Application layer's QueryCachePipelineSettings (:41-51); without it, both are registered bare so IOptions<T> still resolves to framework defaults instead of failing a host that called the parameterless overload (:54-57, with the reasoning at :30-38). The cache: if an IDistributedCache is registered and is not the no-op MemoryDistributedCache (:62), it builds DistributedCacheService over it plus any IConnectionMultiplexer, the optional CacheKeyNamespace and the TTL settings (:64-73); otherwise MemoryCacheService (:77), where the keyspace is private to the process so no prefix is needed. Both factories now resolve the namespace with CacheKeyNamespace.From(sp) (:67, :91) rather than pulling IOptions<CacheKeyPrefixOptions> out by hand at the call site, so the resolution logic lives once on the type instead of being repeated at every registration lambda. The lock: RedisDistributedLock when a multiplexer resolves (:87-93), InProcessDistributedLock otherwise (:95-97). The comment explains why the lock is registered next to the cache at all (:80-83): its one in-framework caller, the API idempotency filter, pairs the two, since the lock guards the execute-then-store window the cache entry closes.
    • AddCommonHybridCache (DependencyInjection.Caching.cs:141-184) is the opt-in two-level cache (ADR-077). It calls AddHybridCache (:139), then configures HybridCacheOptions through the options pipeline rather than the AddHybridCache callback (:145-159), because the TTL policy now comes from the bound Cache section and the callback has no service provider to read it from (:141-144); the host's own hook runs last so it can override anything the framework set (:158). It then deliberately does RemoveAll<ICacheService>() before AddSingleton (:161-177) so the call wins whether it runs before or after AddInfrastructure. The remarks are honest about the cost of that choice (:125-130): RemoveAll does not distinguish the framework's registration from a host's own custom ICacheService, so calling this is a statement that the two-level cache IS the cache.
    • AddScheduledJobs (DependencyInjection.Jobs.cs:37-51) and AddScheduledJob<TJob> (:72-78) wire the recurring-job feature (ADR-074). The first binds SchedulerSettings with validation (:39-42) and registers ScheduledJobRunner via TryAddEnumerable rather than AddHostedService (:44-48), so a host or two modules calling it twice cannot run two runners racing for the same rows. The second registers one job scoped, both as the concrete type and into the accumulating IEnumerable<IScheduledJob> (:75-76); the doc spells out that the job is scoped because the runner resolves it in a fresh scope per execution and it must hold no state between runs (:65-70).
    • AddAuditTrail (DependencyInjection.Jobs.cs:108-127) binds AuditTrailSettings (:110-113), registers the trail interceptor as a singleton for the same statelessness reason as the other three (:115-117), the scoped reader (:119), and, notably, AddScheduledJob<AuditTrailCleanupJob>() (:124). Registering the retention job here rather than in AddScheduledJobs keeps the two features independent, and the remarks state the operational consequence plainly (:96-102): without the scheduler the trail still records every change and nothing is ever purged, so AuditTrail:RetentionDays is inert.
    • AddMultiTenancy (DependencyInjection.cs:277-289) binds TenancySettings and adds TenancySettingsValidator through TryAddEnumerable (:273-275). What it switches on is resolution, not isolation: the filter, the interceptor and ITenantContext are always present and inert until a tenant resolves (:244-251), and a Tenancy:Tenants:{id}:DataSources:{sourceName} override naming a source that does not exist fails startup rather than silently falling back to the shared database (:257-264).
    • The four opt-in identity completions (ADR-116), in DependencyInjection.Auth.cs. AddTwoFactorAuthentication(configuration) (DependencyInjection.Auth.cs:40) binds TwoFactorSettings and registers ITwoFactorService/TotpTwoFactorService singleton (pure over its arguments) and ITwoFactorAuthenticator/TwoFactorAuthenticator scoped (shares the request's unit of work); it registers no ITwoFactorStore deliberately, because the account's secret and recovery hashes belong to the consumer's own User aggregate. AddEmailConfirmation(configuration) (DependencyInjection.Auth.cs:68) binds EmailConfirmationSettings and registers IEmailConfirmationTokenService/EmailConfirmationTokenService scoped, plus TimeProvider.System through TryAddSingleton (:79), because the token service stamps expiries from the injected clock: AddServices registers the same clock (DependencyInjection.cs:324), and the TryAdd keeps this call self-sufficient without displacing a host's own TimeProvider; calling it changes nothing about sign-in until the host's User also implements IEmailConfirmableUser and sets RequireConfirmedEmail. AddLegalAcceptance(configuration) (DependencyInjection.Auth.cs:104) binds LegalAcceptanceOptions from the Legal section (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceOptions.cs:15) and nothing else: it registers no ILegalAcceptanceService, because the accepted version and instant belong to the app's own User aggregate, so the consumer supplies that implementation plus a controller deriving from LegalAcceptanceControllerBase. It deliberately omits ValidateOnStart, unlike its siblings, since the single Legal:CurrentTermsVersion setting has no invalid value (any version string turns the feature on, null or whitespace turns it off). The options reach registration through AuthenticationValidators as an optional constructor argument, so with the setting unset the call changes nothing. AddStoredPermissionGrants(configuration) (DependencyInjection.Auth.cs:139) binds PermissionGrantSettings, registers the PermissionGrantModelGate singleton (its presence is what tells ApplicationDbContext to map the PermissionGrant table for the one data source named by Authentication:PermissionGrants:DataSourceName), the EF grant store, a PermissionGrantCache singleton that answers as the read cache, the invalidator and the refresh IHostedService all from one instance (so an edit and the reads that follow it cannot see two different snapshots), IRoleAdministrationService/StoredPermissionRoleAdministrationService, and decorates whatever IPermissionRegistry is already registered with LayeredPermissionRegistry via TryDecorate; it must be called AFTER AddAuthorizationPolicies() for that decoration to have anything to wrap, and falls back to registering an empty compiled registry first when TryDecorate finds nothing, so the stored layer still works standalone.
    • AddServices (DependencyInjection.cs:295-340) registers the small services and encodes a subtle lifetime lesson: TokenService is a singleton (:303) with a six-line comment explaining why (:297-302): a scoped lifetime disposed the RSA handle at end-of-request while IdentityModel's static CryptoProviderCache still held the cached signature provider wrapping it, throwing ObjectDisposedException on the next RS256 sign. [Rubric §11, Security] (correct signing-key lifecycle). It also sets the default IEventBus to InProcessEventBus (:305) and the default IMessageBus to InProcessMessageBus (:311), registers ITenantContext unconditionally with the reasoning inline (:290-294), and wires the inert no-op defaults for push (:315-316), native push (ADR-044, :318-321) and file storage (ADR-045, :323-326) so hosts can call the opt-in methods unconditionally. The image processor beside the storage default is the exception: it is dependency-free, so it is always the real one (:326).
    • The opt-in channels. AddPushNotifications (DependencyInjection.Notifications.cs:41-70) binds the settings (:43-46), adds SignalR (:48), adds the Redis backplane only when a redis connection string exists (:51-62), and replaces the null senders (:65-66). AddNativePushNotifications (:82-102) and AddAzureBlobFileStorage (:114-142) both re-read their section eagerly with .Get<T>() (:87, :119) because the decision whether to register at all has to be made at composition time, and both return early on an incomplete section (:88-93, :120-130), which is what makes an unconfigured environment a no-op instead of a startup crash. The absolute-URI check at :125-126 is worth copying: an empty-string ServiceUri binds to a relative Uri, so only { IsAbsoluteUri: true } counts.
    • AddBrokerMessaging (DependencyInjection.Messaging.cs:43-127) is the extraction pivot. It reads MessageBusSettings, falling back to new MessageBusSettings() when the section is absent (:47-48); on InProcess it returns immediately (:50-53), leaving the in-process bus in place; otherwise it re-runs the outbox guard (:58, with the comment at :55-57: a service host that wires the broker without the full infrastructure registration must still fail loudly), resolves the connection string (:60), calls AddMassTransit (:62-89) and then Replaces the scoped IMessageBus with BrokerMessageBus (:93) and IEventBus with BrokerEventBus (:99), the deliberate exception to the TryAdd rule, because the in-process bus must not run alongside the broker. Inside the MassTransit callback, unless MessageBusSettings.PreserveDefaultEndpointNames is set, it installs a KebabCaseEndpointNameFormatter with includeNamespace: false (:77-85) carrying either the configured EndpointPrefix or, when that is unset, the resolved ApplicationNamespace (SEC-Common-53), because every service on a shared broker would otherwise derive the same queue name from the same consumer type and collide; PreserveDefaultEndpointNames exists solely so a deployment predating 1.188.0 can keep its bare pre-existing queue names across the upgrade.
    • The inbox branch (DependencyInjection.Messaging.cs:108-124) chooses the consumer-side dedup store from settings.IsInboxEnabled, the resolved posture rather than the raw flag (unset means ON for a broker, Messaging/MessageBusSettings.cs:141): EfInboxStore scoped when on (DependencyInjection.Messaging.cs:110), and when off the singleton NoOpInboxStore plus an InboxDisabledWarningService hosted service (:112-117). That second registration is the whole point of the branch: a disabled dedup store looks exactly like an enabled one until a duplicate side effect reaches a customer, so the posture costs one startup Warning to make visible (:114-116).
    • The five private helpers (DependencyInjection.Messaging.cs:202-384) sit outside the extension block. EnsureOutboxAvailableForProvider (:179-186) throws when a broker transport is paired with an explicitly disabled outbox, and the message says why in operational terms: the outbox is the only publish path a broker deployment has, so the alternative is every cross-service event vanishing while the service looks healthy. ResolveBrokerConnectionString (:202-211) applies an explicit precedence: MessageBus:ConnectionString, then ConnectionStrings:rabbitmq (what Aspire injects), then ConnectionStrings:messaging; without that fallback MassTransit would default to localhost:5672 and never reach the Aspire-allocated container port. ConfigureBrokerTransport (:241-324) does the per-transport wiring, calling ApplyBackpressure (:274, :314) right before cfg.ConfigureEndpoints(context) on both the RabbitMQ and the Azure Service Bus branches. ApplyBackpressure (:337-348) is the new fifth helper: it applies PrefetchCount and ConcurrentMessageLimit to the bus factory configurator, range-guarding each in code (a positive check) rather than by [Range], because AddBrokerMessaging binds the section with Get<MessageBusSettings>(), which never runs DataAnnotations. BuildRedeliveryIntervals (:358-361) maps the configured seconds to TimeSpans, dropping non-positive entries because a zero interval would schedule an immediate redelivery and turn the second retry level into a hot loop. The first three carry a justified IDE0051 suppression documenting a Roslyn false positive: the analyzer in SDK 10.0.201+ does not see references crossing the extension-block boundary.
    • Two levels of retry, and one asymmetry between transports. [Rubric §29, Resilience & Business Continuity]: every receive endpoint gets an exponential-backoff UseMessageRetry policy driven by MessageBusSettings (DependencyInjection.Messaging.cs:292-296 for RabbitMQ, :309-313 for Azure Service Bus). Above it sits second-level UseDelayedRedelivery, which reschedules a message through the broker over RedeliveryIntervalsSeconds (one minute, ten minutes, one hour by default) so an outage measured in hours does not dead-letter the event; it is registered before UseMessageRetry so the retry filter stays innermost and every immediate attempt is exhausted first (:256-267). The asymmetry is deliberate: Azure Service Bus schedules messages natively, so redelivery is applied unconditionally there (:299-307), while RabbitMQ needs the rabbitmq_delayed_message_exchange plugin that the Aspire development container does not ship, so it is gated behind EnableDelayedRedelivery, default false (:260). Right after each UseMessageRetry call, ApplyBackpressure applies PrefetchCount and ConcurrentMessageLimit to the same configurator before cfg.ConfigureEndpoints(context) runs (:274, :314).
    • The emulator detour (DependencyInjection.Messaging.cs:307-320). On the Azure Service Bus branch, the host call is not unconditional: ServiceBusEmulatorSupport.IsEmulatorConnectionString decides between ConfigureEmulatorHost(cfg, connectionString, settings.EmulatorAdminEndpoint) (:290-291) and the production cfg.Host(connectionString) (:295). The comment states the property that makes this safe (:284-287): the emulator token no real namespace emits is the only way in, so the production path is reached byte for byte as before (ADR-066).
    • AddTypedServiceClient (DependencyInjection.Messaging.cs:155-181) wires a typed HttpClient to Aspire service discovery (http://{serviceName}, :151-152), attaches JwtForwardingDelegatingHandler (:148, :153) so the inbound bearer token flows downstream, and adds the standard Polly resilience handler (:156); the S5332 suppression at :150 documents the deliberate cleartext in-cluster address, and the doc notes gRPC is preferred for service-to-service contracts (:131-135).
  • Why it's built this way: the extension(IServiceCollection) syntax keeps every Infrastructure registration in one file without a proliferation of static helper classes (ADR-106), and pushing all concrete choices into one composition root at the layer edge is what keeps Application and Domain free of framework references (ADR-006 for the database-per-service wiring, ADR-007/ADR-008 for the broker/extraction path). See the DI-sequence note in MMCA.Common/CLAUDE.md: hosts call AddApplicationDecorators() last so Scrutor can decorate handlers already registered, but the relative position of AddInfrastructure is not otherwise ordering-sensitive, and AddCommonHybridCache is explicitly documented as order-independent in both directions (DependencyInjection.Caching.cs:121-128).

  • Where it's used: called from each service host's Program.cs (the reference apps and the extracted MMCA.ADC.* service hosts) after AddApplication(); the optional methods (AddScheduledJobs, AddAuditTrail, AddMultiTenancy, AddCommonHybridCache, AddBrokerMessaging, AddPushNotifications, AddNativePushNotifications, AddAzureBlobFileStorage) are added by the specific hosts that need those capabilities. AddScheduledJobsTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/AddScheduledJobsTests.cs:15) pins the double-registration behavior of the scheduler pair.

  • Caveats / not-in-source: the exact set of consuming Program.cs files is in the downstream apps (MMCA.ADC / MMCA.Store / MMCA.Helpdesk), not in this repository, so the precise call sites are Not determinable from source here.

NativePushPayloads

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/NativePushPayloads.cs:10 · Level 0 · class (internal static)

  • What it is: the pure helper that builds the two platform-native JSON payloads Azure Notification Hubs forwards (FCM v1 for Android, APNs for iOS) and the user:{id} tag expressions a user-targeted send is addressed to (NativePushPayloads.cs:10). It holds no state and touches no hub client, which is exactly why the payload shapes and the 20-tag chunking rule can be asserted in a unit test.

  • Depends on: System.Text.Json for serialization, System.Globalization.CultureInfo for the tag rendering, and the solution-wide UserIdentifierType alias. No first-party service types at all.

  • Concept introduced, the third notification channel's wire contract. [Rubric §9, API and Contract Design] assesses whether the shape of a message crossing a boundary is defined in one place rather than assembled ad hoc at each call site, and [Rubric §14, Testability] assesses whether the parts worth testing can be tested without their infrastructure. The framework already carries a durable per-user notification inbox and a transient SignalR push; native OS-level delivery (ADR-044) is the third, and it is the only one whose payload is dictated by an external vendor. FCM v1 wants a message envelope with a notification block; APNs wants a reserved aps block with an alert inside it and any application data as sibling top-level keys. Those two shapes are the whole contract, so they live here as two string-returning functions rather than inside the sender that calls a hub client.

  • Walkthrough

    • MaxTagsPerExpression is the constant 20 (NativePushPayloads.cs:13), and the doc above it names the reason: the hub caps a tag expression at 20 tags, so a larger audience must be chunked rather than sent as one expression.
    • BuildFcmV1Payload(title, body, metadata) (NativePushPayloads.cs:16-28) builds notification from the title and body, attaches data only when the metadata dictionary is non-empty (:22-25), and serializes the whole thing under a message root (:27).
    • BuildApnsPayload(title, body, metadata) (NativePushPayloads.cs:31-53) builds the aps.alert block, then lifts each metadata pair to a top-level custom key, which is where APNs expects application data. The guard at :44-48 is the interesting line: a metadata key literally named aps is skipped, because writing it would clobber the alert block and produce a notification the device shows as nothing.
    • BuildUserTagExpressions(userIds) (NativePushPayloads.cs:59-63) is a three-step LINQ pipeline: map each id to its tag, Chunk by MaxTagsPerExpression, and join each chunk with ||. One expression per chunk, each an OR over at most 20 user tags.
    • UserTag(userId) (NativePushPayloads.cs:66-67) renders user:{id} with CultureInfo.InvariantCulture through string.Create. Invariant formatting is not cosmetic here: the tag stamped on an installation at registration time and the tag used to target a send at delivery time must be byte-identical, and a numeric identifier formatted under a culture with digit grouping would not be.
  • Why it's built this way: the class doc states the intent directly (NativePushPayloads.cs:5-9). Keeping this pure means the vendor payload shapes are covered by fast tests that need no notification hub, no credentials, and no network, which matters for a channel that is inert by default in every environment a developer runs locally.

  • Where it's used: only by the two Azure implementations in this group. AzureNotificationHubNativePushSender calls all three build members (AzureNotificationHubNativePushSender.cs:21-24, :36-37), and AzureNotificationHubDeviceRegistrar calls UserTag both when stamping an installation (AzureNotificationHubDeviceRegistrar.cs:41) and when verifying ownership before a delete (:92). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Notifications/Push/NativePushPayloadsTests.cs.

  • Caveats / not-in-source: the type is internal, so the payload shapes are not part of the package's public surface and a consumer cannot compose against them. That is deliberate for a vendor-dictated format, but it does mean a host wanting a different payload shape replaces INativePushSender wholesale rather than reusing this.


NativePushSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/NativePushSettings.cs:9 · Level 0 · class (sealed)

  • What it is: the NativePush section for OS-level push delivery through Azure Notification Hubs: an on/off flag, the hub connection string, and the hub name.

  • Depends on: nothing first-party; its values feed Microsoft.Azure.NotificationHubs.NotificationHubClient.

  • Concept: the same ship-inert, enable-by-configuration shape taught under FileStorageSettings, with a concrete operational reason attached. [Rubric §17, DevOps] assesses whether deployment and enablement can be sequenced independently: the XML doc records that a hub is provisioned with Enabled false until the FCM v1 service account and APNs auth key are uploaded to it (NativePushSettings.cs:3-8), so infrastructure lands before credentials do and neither step blocks a release. [Rubric §29, Resilience]: the disabled path leaves the framework's default sender in place rather than failing startup, so a missing credential degrades the channel instead of the host.

  • Walkthrough: SectionName = "NativePush" (NativePushSettings.cs:12); Enabled (:15); nullable ConnectionString, documented as a Listen+Send+Manage rule (:18); nullable HubName (:21).

    • AddNativePushNotifications binds the options (DependencyInjection.Notifications.cs:84-85), then re-reads the section eagerly and returns early unless all three values are present, using a property pattern so an unbound section and a disabled one take the same exit (DependencyInjection.Notifications.cs:87-93).
    • Only then does it register the hub client from the connection string and hub name (:661-663) and swap INativePushSender and IPushDeviceRegistrar to their Azure implementations (:664-665), among them AzureNotificationHubNativePushSender.
  • Why it's built this way: ADR-044 chose a configuration-gated channel precisely so hosts can register it unconditionally.

  • Where it's used: AddNativePushNotifications (DependencyInjection.Notifications.cs:82-102).


BrokerMetrics

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:18 · Level 0 · class (internal, static)

  • What it is: the OpenTelemetry instrument set for the broker transport. One meter carrying two counters: integration events that exhausted their retries and faulted, and outbox publishes the circuit breaker refused to even attempt.

  • Depends on: System.Diagnostics.Metrics (BCL) only (BrokerMetrics.cs:1). Nothing first-party. It is the exact same shape as SchedulerMetrics, for a different feature.

  • Concept: the one-meter-per-feature instrument holder, taught under SchedulerMetrics. What is worth teaching here is which two numbers were chosen. [Rubric §13, Observability & Operability] assesses whether a running system can be understood from outside: broker delivery is asynchronous and out of band, so a consumer that keeps failing produces no failed HTTP response anywhere, and the only evidence is a message quietly landing in an error queue. broker.fault.count is therefore the natural alert target for consumer health (BrokerMetrics.cs:25-28). The second counter exists because the class doc insists on a distinction an operator would otherwise have to infer: a publish rejected by an open circuit never reached the broker at all (BrokerMetrics.cs:35-41), which is different from a publish that reached it and failed. [Rubric §29, Resilience & Business Continuity]: those rejected rows stay leased and are retried on a later cycle, so the counter measures fail-fast behavior working as designed rather than data loss.

  • Walkthrough:

    • MeterName = "MMCA.Common.Broker" (BrokerMetrics.cs:21) and the single static Meter built from it (:23). The doc carries the same never-create-a-second-meter warning as the scheduler (BrokerMetrics.cs:12-16), with the failure mode spelled out: a duplicate instance publishes a parallel set of instruments under one name, and a listener enabling one silently misses the measurements recorded on the other.
    • FaultCounter (BrokerMetrics.cs:30-33), broker.fault.count, unit messages, tagged by event_type.
    • CircuitOpenCounter (:42-45), broker.circuit.open.count, same unit and tag.
  • Why it's built this way: internal static readonly instruments created once at type initialization means the recording sites resolve nothing from DI. The meter name is duplicated as a literal in MMCA.Common.Aspire (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.Telemetry.cs:312) because that package has no reference to Infrastructure, and the doc records that duplication rather than letting the next reader discover it (BrokerMetrics.cs:8-11). The comment above the Aspire list states the operational consequence: these instruments are inert in a host that stays on the in-process bus (Extensions.Telemetry.cs:297-307).

  • Where it's used: FaultIntegrationEventConsumer<TEvent> adds to FaultCounter right after logging the fault (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/FaultIntegrationEventConsumer.cs:50-52), and OutboxProcessor adds to CircuitOpenCounter on the BrokenCircuitException branch of its publish path (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:598-604), where the surrounding comment explains why the counter is per row while the log line is per cycle (OutboxProcessor.cs:551-556, :661-665). Both recording sites are pinned by tests that listen on the meter name: FaultIntegrationEventConsumerTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/FaultIntegrationEventConsumerTests.cs) and OutboxProcessorTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/OutboxProcessorTests.cs).


[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

ServiceBusEmulatorSupport

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/ServiceBusEmulatorSupport.cs:32 · Level 0 · class (internal, static)

  • What it is: everything the Azure Service Bus transport needs in order to run against the official local emulator instead of a real namespace, collected in one type so the production branch in ConfigureBrokerTransport stays a single unconditional cfg.Host(connectionString) (ServiceBusEmulatorSupport.cs:8-11). It answers two questions: is this connection string an emulator one, and if so, how do we hand MassTransit the two clients it needs.

  • Depends on: nothing first-party. Externals: Azure.Messaging.ServiceBus (ServiceBusClient), Azure.Messaging.ServiceBus.Administration (ServiceBusAdministrationClient), and MassTransit's IServiceBusBusFactoryConfigurator (ServiceBusEmulatorSupport.cs:1-4). BCL: Uri, Interlocked, CultureInfo. Its one caller is DependencyInjection's private ConfigureBrokerTransport, and its input comes from MessageBusSettings (ConnectionString plus EmulatorAdminEndpoint).

  • Concept introduced, detection that keys off the artifact rather than the environment. [Rubric §17, DevOps] assesses whether development and production run the same paths: the point of the emulator branch is that a local stack exercises the same transport production uses, instead of substituting RabbitMQ and discovering Service Bus specific behavior only after deployment. [Rubric §11, Security] assesses blast radius: the branch is entered only when the resolved connection string carries UseDevelopmentEmulator=true, a token a real namespace never emits, so no production deployment can reach any of this by accident. That is stated as the reason detection is not keyed off an environment name or a separate flag, either of which somebody can set in the wrong place (ServiceBusEmulatorSupport.cs:12-18). [Rubric §32, Dependency & Supply-Chain]: the whole type exists because of a pinned dependency version. MassTransit v8 has no vendor emulator mode (that shipped in v9, which the workspace excludes because it needs a commercial license), so the only way onto the emulator is the custom-clients Host overload, where the caller builds both the data-plane and the management-plane client itself (ServiceBusEmulatorSupport.cs:19-30).

  • Walkthrough:

    • The four pieces of state. EmulatorMarker is the literal "UseDevelopmentEmulator=true" (ServiceBusEmulatorSupport.cs:37). EmulatorEntityQuota is one hour, the ceiling the emulator enforces on entity time-to-live and auto-delete-on-idle (:42). EmulatorHostAddress is sb://localhost/ (:48) and the doc is careful about what it is: it names the bus for the Host overload, not a network location, because both clients are already bound to the emulator's actual ports (:44-47). _entityQuotasApplied is the once-per-process latch (:54).
    • IsEmulatorConnectionString (:62-64) is the whole detection: a null-guard and a Contains with StringComparison.OrdinalIgnoreCase, so casing in the connection string cannot smuggle a stack onto the wrong branch.
    • ConfigureEmulatorHost (:86-101) is the entry point. It lowers the process-global quotas (:93), derives the management-plane connection string (:95), and calls the custom-clients overload with the host address, a new ServiceBusClient for the data plane and a new ServiceBusAdministrationClient for the management plane (:97-100). The CA2000 suppression above it (:82-85) is worth reading rather than skipping: MassTransit takes ownership of the client for the life of the bus, which is the life of the process, so disposing it here would close the connection before the first publish.
    • ApplyEmulatorEntityQuotas (:108-118) flips Interlocked.Exchange(ref _entityQuotasApplied, 1) != 0 and returns early on the second call (:110-113), then writes three MassTransit statics down to the one-hour quota: DefaultMessageTimeToLive, BasicMessageTimeToLive and AutoDeleteOnIdle (:115-117). MassTransit v8's own defaults sit far above the emulator's ceiling (366 days TTL, 427 days auto-delete), so without this every entity it tries to provision is rejected (:26-29). Because these are process-global statics, the method is deliberately never reached on the real-namespace path: a production bus keeps MassTransit's defaults (:103-107).
    • BuildAdminConnectionString (:131-156) derives the management-plane string from the AMQP one by swapping in the admin endpoint's host and port. The validation is stricter than "is it an absolute URI" and says why in a comment (:133-135): localhost:5300 is an absolute URI, with localhost read as the scheme and no host at all, so the check also requires the scheme to be http or https (:136-139). A missing or malformed endpoint throws an InvalidOperationException whose message names the setting, the reason (v8 needs a management client on a second port) and the fix, including that an Aspire AppHost gets it for free (:141-143). The rebuild itself is a split on ; that replaces only the Endpoint= segment and leaves everything else in place (:145-155), which is what keeps the shared-access key name and value byte-identical across both clients: composing a fresh second string is one silent typo away from an admin client that cannot provision anything (:120-126).
  • Why it's built this way: ADR-066 records the local Service Bus emulator path as part of transport selection, and ADR-016 records the MassTransit v8 pin that forces the custom-clients overload and the quota lowering. Keeping all of it in one internal static type is what lets the production branch of ConfigureBrokerTransport stay a one-liner, so a reader of the transport wiring sees the production path first and the development affordance as an explicit detour.

  • Where it's used: ConfigureBrokerTransport calls IsEmulatorConnectionString and, on a match, ConfigureEmulatorHost(cfg, connectionString, settings.EmulatorAdminEndpoint) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:313-314, the production cfg.Host(connectionString) in the else at :295). The admin endpoint is bound from MessageBus:EmulatorAdminEndpoint (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:49), which an Aspire AppHost sets from the emulator resource (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:289-291, alongside MessageBus__Provider=AzureServiceBus and the AMQP connection string). The test tier applies the same quota lowering from its own fixture, ServiceBusEmulatorFixtureBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Fixtures/ServiceBusEmulatorFixtureBase.cs:74), which pins the emulator image (:61). ServiceBusEmulatorSupportTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/ServiceBusEmulatorSupportTests.cs:14) covers marker detection including casing, the endpoint swap, the loud failure on a missing or non-HTTP admin endpoint, and the one-hour quota constant.

  • Caveats / not-in-source: whether a given AppHost run uses the emulator or RabbitMQ is a host and environment decision, so "which transport a developer is on right now" is Not determinable from source here; the source only settles that the emulator branch is reachable exactly when the resolved connection string carries the marker.


[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

MessageBusProvider

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:236 · Level 0 · enum

  • What it is: the transport selector for the cross-service message bus. It lives in the same file as MessageBusSettings, immediately below it.

  • Depends on: nothing.

  • Concept: the transport-choice-at-the-edge invariant. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness] both hinge on application code never naming a broker: handlers publish through IMessageBus, and only this enum plus the registration that reads it decide whether that lands in-process or on a wire. The three values are also a deployment ladder: monolith, dev microservices, production microservices.

  • Walkthrough: InProcess = 0 (MessageBusSettings.cs:241), the modular-monolith default served by InProcessMessageBus; RabbitMq = 1 (:209), MassTransit on RabbitMQ for development microservice deployments and tests; AzureServiceBus = 2 (:214), MassTransit on Azure Service Bus for production.

    • AddBrokerMessaging re-reads the section eagerly, substituting a default instance when it is absent (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:49-50), and returns without touching the container when the value is InProcess (:50-53).
    • The transport configuration then switches on the same value to pick UsingRabbitMq (DependencyInjection.Messaging.cs:271-300) or UsingAzureServiceBus (:279-317), with InProcess as an explicit arm rather than a fall-through (:319-322).
    • The enum also gates delivery policy, not just the client type: MessageBusSettings.RedeliveryIntervalsSeconds (MessageBusSettings.cs:211, defaulting to [60, 600, 3600]) is applied unconditionally on Azure Service Bus, which has native scheduled delivery, and only when EnableDelayedRedelivery is set on RabbitMQ, which needs the delayed-message-exchange plugin (:188-193).
  • Why it's built this way: a zero-valued InProcess means an absent MessageBus section binds to the monolith behavior, so adding the section is opt-in rather than mandatory (ADR-008). Here the enum ordinal genuinely is the default path, alongside the property initializer at MessageBusSettings.cs:17.

  • Where it's used: MessageBusSettings.Provider (MessageBusSettings.cs:17) and the AddBrokerMessaging branches cited above.


[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

FaultEndpointConfigurator

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging.Consumers · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:189 · Level 0 · class (private sealed, nested)

  • What it is: a forwarding wrapper around MassTransit's IEndpointRegistrationConfigurator, nested inside IntegrationEventConsumerExtensions. It hands the fault consumer the same endpoint settings the integration-event consumer received, except that an explicit Name gets the -fault suffix, so the two consumers never share a queue (IntegrationEventConsumerExtensions.cs:180-226).
  • Depends on: MassTransit's IEndpointRegistrationConfigurator and the private FaultEndpointSuffix constant, "-fault" (IntegrationEventConsumerExtensions.cs:16). The wrapped inner configurator arrives through the primary constructor (:189).
  • Concept introduced, decorating a configurator to rename one setting. [Rubric §10, Messaging & Integration Architecture] assesses whether the pipeline gives each consumer its own queue and failure path. A caller that configures the real consumer's endpoint (for example Name, PrefetchCount, ConcurrentMessageLimit) would, if the same callback were applied to the fault consumer unchanged, bind both consumers to one queue. This wrapper applies the identical callback but rewrites the one property that must differ.
  • Walkthrough
    • Name setter writes value + FaultEndpointSuffix to inner (IntegrationEventConsumerExtensions.cs:191-194).
    • Temporary, PrefetchCount, ConcurrentMessageLimit, ConfigureConsumeTopology and InstanceId setters forward unchanged (:196-215).
    • Both AddConfigureEndpointCallback overloads forward to inner (:217-221).
    • It is built in AddFaultConsumer<TEvent> (IntegrationEventConsumerExtensions.cs:168-178) only when the caller supplied an endpoint callback: faultConsumer.Endpoint(endpoint => configureEndpoint(new FaultEndpointConfigurator(endpoint))) (:176).
  • Why it's built this way: the members of IEndpointRegistrationConfigurator are set-only, so an explicit interface implementation cannot add getters; the S2376 analyzer is suppressed with that justification (IntegrationEventConsumerExtensions.cs:185-188).
  • Where it's used: only by AddFaultConsumer<TEvent> in the same file; it has no usage outside it. It pairs with FaultIntegrationEventConsumer<TEvent>, the consumer it configures.

[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

AzureNotificationHubNativePushSender

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/AzureNotificationHubNativePushSender.cs:14 · Level 1 · class (public sealed partial)

  • What it is: the real implementation of INativePushSender, sending both the FCM v1 and the APNs payload for every notification through an Azure Notification Hubs client, either to a set of users (resolved by tag) or to every installation (AzureNotificationHubNativePushSender.cs:14-44).

  • Depends on: Microsoft.Azure.NotificationHubs.INotificationHubClient and ILogger<T> as its two primary-constructor parameters (AzureNotificationHubNativePushSender.cs:14-16), NativePushPayloads for the payload and tag construction, and INativePushSender as the contract.

  • Concept introduced, one logical notification, two platform sends. [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether a capability that varies by platform is normalized behind one application-facing call. A caller says "notify these users, with this title and body"; it does not know or care which of them are on iOS. This class turns that into two hub calls per audience chunk, one per platform, and the hub decides which installations each applies to. [Rubric §29, Resilience and Business Continuity] applies through the delivery posture named in the class doc: native delivery is best effort, and the calling handler wraps it in a non-fatal catch, which is the side-effect contract of ADR-096. A push that fails does not fail the command that triggered it.

  • Walkthrough

    • SendToUsersAsync (AzureNotificationHubNativePushSender.cs:19-31) builds each payload once (:21-22), then loops the tag expressions produced by NativePushPayloads (:24), awaiting an FcmV1Notification send and an AppleNotification send per chunk (:26-27). For an audience of 45 users that is 3 chunks and 6 hub calls, and the payload serialization happened twice in total, not twelve times.
    • BroadcastAsync (AzureNotificationHubNativePushSender.cs:34-40) is the untagged form: the same two notification types, built inline, with no tag expression, so the hub fans out to every installation it holds (:36-37).
    • Both paths end with the same source-generated Information log carrying the title (AzureNotificationHubNativePushSender.cs:30, :39, declared at :42-43). The wording is precise: the notification was handed to the hub. Actual delivery to a device is asynchronous and outside this process.
    • Every await uses ConfigureAwait(false), which is the library policy of ADR-049.
  • Why it's built this way: sending the two payloads unconditionally rather than looking up each user's platform keeps the sender free of any device registry of its own. The hub already knows each installation's platform from the registration written by AzureNotificationHubDeviceRegistrar, so a payload that does not apply to a given installation is simply not delivered to it. That is the division of labour ADR-044 records: the hub is the device registry, and this codebase stores no push tokens.

  • Where it's used: registered only by AddNativePushNotifications(configuration) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:82-102), and only when the NativePush section is present with Enabled: true, a connection string, and a hub name (:88-93); the hub client itself is built from the connection string in the same call (:95-97) and this sender replaces the inert default at :98. MMCA.ADC's Notification module makes that call unconditionally (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/DependencyInjection.cs:38), so the channel is switched on by configuration alone. The consuming application code is SendPushNotificationHandler (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:47, :151), where the best-effort catch lives.

  • Caveats / not-in-source: there is no dedicated unit test file for this class under MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Notifications/Push/; the logic it owns beyond the hub calls (payload shapes, tag chunking) is tested through NativePushPayloads. Whether a given deployment has working platform credentials is a provisioning question, not determinable from source.


NullNativePushSender

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/NullNativePushSender.cs:10 · Level 1 · class (public sealed)

  • What it is: the inert default for INativePushSender. Both members return Task.CompletedTask and do nothing else (NullNativePushSender.cs:13-18).

  • Depends on: INativePushSender only.

  • Concept introduced, the null-object default for an optional channel. [Rubric §1, SOLID] assesses whether callers depend on abstractions rather than on the presence of a configured backend, and [Rubric §15, Best Practices & Code Quality] assesses whether the "not configured" case costs conditional code at every call site. The alternative to this class is an INativePushSender? that every handler null-checks, or a host that throws at resolution time when no hub is configured. Both push a deployment concern into application code. Registering a do-nothing implementation as the default means the third channel always resolves, the send handler is written once with no branch, and enabling native push in an environment is purely a configuration change. The same posture appears in NullFileStorageService, NullPushDeviceRegistrar, and, outside this group, NullPushNotificationSender.

  • Walkthrough: SendToUsersAsync (NullNativePushSender.cs:13-14) and BroadcastAsync (:17-18) are expression-bodied returns of Task.CompletedTask. There is no logging, deliberately: a per-notification "push was skipped" line in a host that never intends to enable the channel is noise, not signal.

  • Why it's built this way: ADR-044 records that the framework pipeline ships inert by default and each consumer switches it on by provisioning a hub. This class is that decision expressed in code, and the DI comment says so in one line (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:329).

  • Where it's used: registered by AddInfrastructure through TryAddTransient (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:331), immediately alongside NullPushDeviceRegistrar (:580). AddNativePushNotifications uses plain AddTransient for the Azure pair (:678-679), so the later registration wins and replaces this one.

  • Caveats / not-in-source: because the swap is "last registration wins" rather than a removal, both descriptors remain in the collection when native push is enabled. Anything resolving IEnumerable<INativePushSender> would see the no-op as well as the real sender; no first-party code does.


PushNotificationSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/PushNotificationSettings.cs:8 · Level 1 · class (sealed)

  • What it is: the PushNotifications section: the on switch, the SignalR hub path, the regular expression a channel key must match before a client may join or leave a channel, and the per-user cap on simultaneous hub connections (PushNotificationSettings.cs:8-47).

  • Depends on: NotificationScopeKey from MMCA.Common.Shared.Notifications (:1), for the Pattern constant that supplies the default. See NotificationScopeKey. No externals beyond a single [Range(0, 10_000)] data annotation on MaxConnectionsPerUser (PushNotificationSettings.cs:45), which is what ValidateDataAnnotations() checks at startup.

  • Concept introduced: allow-listing client-supplied group names. [Rubric §11, Security] and [Rubric §26, Front-End Security] both apply. A hub method that takes a channel key and calls Groups.AddToGroupAsync is joining a SignalR group named by the CLIENT. With no constraint, a caller could join any group the server publishes to and receive another event's or another session's traffic. ChannelKeyPattern is that constraint (PushNotificationSettings.cs:23-33).

    [Rubric §15, Best Practices & Code Quality] shows up in where the default comes from. The property does not hard-code a regex: it defaults to NotificationScopeKey.Pattern (PushNotificationSettings.cs:33, the constant "^(event|session):[0-9]+$" at NotificationScopeKey.cs:32), the SAME constant the producers NotificationScopeKey.ForEvent and ForSession format against (NotificationScopeKey.cs:44, :43). Producer and guard therefore cannot drift apart by editing one of them; a host that overrides the pattern from configuration takes that alignment on itself, which the doc says outright (:30).

    [Rubric §11, Security] also covers MaxConnectionsPerUser: an [Authorize] hub without a connection cap lets one authenticated user open unbounded WebSockets and exhaust the replica, denying live notifications to everyone else (PushNotificationSettings.cs:35-46). 0 opts a host back out of the cap entirely.

  • Walkthrough: one static field and four init properties.

    • SectionName = "PushNotifications" (PushNotificationSettings.cs:11).
    • Enabled (:14): plain bool, default false.
    • HubPath (:21): defaults to "/hubs/notifications". Its doc comment (:16-20) carries a coupling: the JWT bearer handlers accept the query-string access_token on this path (and under /hubs), and a host that moves the hub elsewhere must also set RateLimiting:HubPathPrefixes, which the rate limiter reads separately.
    • ChannelKeyPattern (:33). Enforcement lives in NotificationHub: EnsureValidChannelKey pulls a Regex out of a static ConcurrentDictionary cache keyed on the pattern string (NotificationHub.cs:177-179) and throws HubException("Invalid channel key.") on an empty or non-matching key (NotificationHub.cs:181-184); both JoinChannelAsync (:46) and LeaveChannelAsync (:62) call it before touching Groups. The compiled regex carries a one-second match timeout (NotificationHub.cs:44, :73), so a pathological configured pattern cannot hang the connection, and the cache means join and leave do not recompile per call (NotificationHub.cs:46-47).
    • MaxConnectionsPerUser (:46): [Range(0, 10_000)] (:45), default 20; 0 disables the cap (PushNotificationSettings.cs:35-38). Tagged SEC-ADC-25 in the doc comment: the hub is [Authorize] but had no OnConnectedAsync override, so a single valid user token could open unbounded WebSockets and exhaust the replica, stopping live notifications for everyone (:39-44).
  • Why it's built this way: a configurable pattern rather than a hard-coded one lets each application define its own channel taxonomy without forking the hub, while the shipped default is restrictive rather than permissive. Sourcing that default from the producer's own constant is the cheaper half of the same idea: the safe configuration is also the zero-configuration one.

  • Where it's used: bound with .ValidateDataAnnotations().ValidateOnStart() in AddPushNotifications (MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:43-46), which then registers SignalR (:48), adds the Redis backplane when a redis connection string is present (:51-61), and replaces the null implementations with SignalRPushNotificationSender and SignalRLiveChannelPublisher (:65-66). The settings object itself is injected as IOptions<PushNotificationSettings> and read by NotificationHub in two places: EnsureValidChannelKey (NotificationHub.cs:178) and OnConnectedAsync (:56), which reads MaxConnectionsPerUser into cap, tracks a static ConnectionsPerUser count keyed by Context.UserIdentifier, and throws HubException("Too many concurrent connections for this account.") when a new connection would push the count over cap (:59-68); the rejected connection's slot is given back immediately so an aborted connection cannot lock the user out once it drains (:64-66). The cap is skipped when cap <= 0 or the identifier is empty (:59). The default is pinned by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Settings/SettingsTests.cs:347-348 and an override by :352, :357; the connection cap itself is covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Hubs/NotificationHubConnectionCapTests.cs.

  • Caveats: this class stands alone. There is no IPushNotificationSettings abstraction in the current source, so a consumer that wants the values takes the concrete options type.


MessageBusSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:11 · Level 1 · class (sealed)

  • What it is: the MessageBus section. It selects the transport, says how to reach the broker, how to namespace its queues, how hard to retry a failing consumer, and whether the two store-and-forward guards (the transactional outbox and the consumer-side idempotency inbox) are on (MessageBusSettings.cs:5-10). Despite the name it is the single switch for the whole event delivery posture, not just for the wire protocol.

  • Depends on: MessageBusProvider, the transport enum declared at the bottom of the same file (MessageBusSettings.cs:236-252). Externals: System.ComponentModel.DataAnnotations for the [StringLength] and [Range] guards (:1).

  • Concept introduced: a tri-state flag that resolves from the transport. Two of the properties here are bool?, not bool, and each has a computed companion. EnableInbox (:117) is the raw configured value and IsInboxEnabled (:125) is what every framework component actually reads: EnableInbox ?? Provider != MessageBusProvider.InProcess. EnableOutbox (:151) and IsOutboxEnabled (:159) work identically. The point of the third state is that "the host said nothing" and "the host said false" are different facts. Unset means "let the transport decide", which turns both features ON for a broker and OFF in-process, and an explicit value always wins in both directions. That is why a settings class that could have been a bag of booleans carries derived properties: the resolution rule lives once, next to the data, instead of being repeated at every read site.

    [Rubric §6, CQRS and Event-Driven] assesses how reliably a state change turns into a delivered event. Both resolved flags default ON for a broker deliberately, and the XML doc argues the case rather than just stating it: broker delivery is at-least-once by contract, so an ack after a network blip, a redelivery after a lease expiry, or an outbox row republished after a crash all hand the same event to the same handlers twice, and with the inbox off every one of those becomes a duplicate side effect (:98-109, ADR-021). Symmetrically, a single-process host dispatches every event inside the process that raised it, so the store-and-forward hop buys it two background services, a table and a poll loop for nothing (:133-141).

    [Rubric §29, Resilience and Business Continuity] assesses whether transient failures are absorbed before they become lost messages. This class configures two independent retry tiers. RetryLimit / RetryMinIntervalSeconds / RetryMaxIntervalSeconds feed the in-process exponential UseMessageRetry filter applied to every broker receive endpoint (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:292-296 for RabbitMQ, :309-313 for Azure Service Bus). EnableDelayedRedelivery and RedeliveryIntervalsSeconds feed a second, broker-scheduled tier that sits above it, so a message that exhausts its immediate attempts is scheduled back onto the queue instead of dead-lettering (:161-195).

    [Rubric §7, Microservices Readiness] assesses whether several services can share infrastructure without colliding. EndpointPrefix exists so multiple services can live on one broker without fighting over queue names: without it, two services with a consumer of the same short type name derive the same kebab-case queue and become competing consumers across a service boundary, where each event reaches only one of them (:51-64).

  • Walkthrough: one static field, fourteen init properties, and two computed properties.

    • SectionName = "MessageBus" (MessageBusSettings.cs:14), the bind key.
    • Provider (:17): defaults to MessageBusProvider.InProcess, so the modular monolith is the zero-configuration case.
    • ConnectionString (:26): nullable, and only the first of three sources. ResolveBrokerConnectionString prefers it, then falls back to ConnectionStrings:rabbitmq and ConnectionStrings:messaging (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:225-234), which is what Aspire injects via WithReference(broker). Without that fallback MassTransit would default to localhost:5672 and miss the Aspire-allocated container port (DependencyInjection.Messaging.cs:218-219).
    • EmulatorAdminEndpoint (:49) with [StringLength(2048)] (:48): the base address of the Azure Service Bus emulator's HTTP management plane. It exists only because MassTransit is pinned to v8, which has no vendor emulator mode; the one v8 path onto the emulator is the custom-clients Host overload that needs a data-plane client AND a management-plane client, and the emulator serves those on two different ports, so the management client cannot be derived from ConnectionString alone (:33-40). It is read only when the connection string carries UseDevelopmentEmulator=true (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:311-314), a token no real namespace emits, so the production path is untouched (:294-296).
    • EndpointPrefix (:67) with [StringLength(64)] (:66): when set, AddMassTransit installs new KebabCaseEndpointNameFormatter(settings.EndpointPrefix, includeNamespace: false) (DependencyInjection.Messaging.cs:79-87). includeNamespace: false is deliberate: the prefix is the only namespacing applied, so a queue name stays readable and survives a consumer type moving between folders (:59-63). Unset does NOT mean "no prefix" (SEC-Common-53): it means the resolved ApplicationNamespace, because two applications sharing one broker used to derive identical queue names from identical consumer type names and become competing consumers of each other's events (:461-467).
    • PreserveDefaultEndpointNames (:480), default false: set true only on an existing deployment to keep MassTransit's bare default endpoint names (no prefix), which is what every release before 1.188.0 produced when EndpointPrefix was unset, so queue and subscription names stay stable across the upgrade to the application-namespace default (:472-479).
    • RetryLimit (:76) with [Range(0, 20)] (:75), default 5; 0 disables retries and a faulted message goes straight to the _error queue.
    • RetryMinIntervalSeconds (:83) with [Range(0, 300)], default 1, and RetryMaxIntervalSeconds (:89) with [Range(0, 3600)], default 30: the floor and the cap of the exponential backoff.
    • EnableInbox (:117) and IsInboxEnabled (:125). AddBrokerMessaging registers EfInboxStore when the resolved value is true and NoOpInboxStore plus a startup InboxDisabledWarningService when it is false, so an opt-out is recorded in the log rather than passing silently (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:108-124). The InboxMessages table is part of the shared relational model created by the standard migrations, so turning it on for a migrated host needs no schema work (:91-97).
    • EnableOutbox (:151) and IsOutboxEnabled (:159). AddInfrastructure reads the resolved value to decide whether to run OutboxProcessor and OutboxCleanupService or the single OutboxDisabledNoticeService (DependencyInjection.cs:200-212). Turning it off under a broker is not honored: EnsureOutboxAvailableForProvider throws at registration, because a broker with no outbox has no delivery channel at all (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:202-209, argued at :162-168). The check runs in both AddInfrastructure (:188) and AddBrokerMessaging (:749) so a service host that wires only the broker still fails loudly. The OutboxMessages table stays mapped either way, so flipping the flag is never a migration.
    • EnableDelayedRedelivery (:179), default false. It gates second-level redelivery on RabbitMQ only, because that transport needs the rabbitmq_delayed_message_exchange plugin the Aspire development container does not ship, and enabling it against a plugin-less broker fails at bus start (:167-172, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:283-290). Azure Service Bus schedules natively, so the flag is deliberately not consulted there and the intervals apply unconditionally (:173-177, DependencyInjection.Messaging.cs:322-330).
    • RedeliveryIntervalsSeconds (:195): defaults to [60, 600, 3600], one minute, ten minutes and one hour, a spread wide enough to ride out a dependency restart, a failover and a short incident without an operator replaying the error queue by hand (:181-187). BuildRedeliveryIntervals drops non-positive entries, because a zero or negative interval schedules an immediate redelivery, which is what UseMessageRetry already does and would turn the second level into a hot loop; when nothing survives, the caller skips the filter entirely (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:381-384).
    • PrefetchCount (:217) and ConcurrentMessageLimit (:227): both nullable int, both unset by default, and both range-guarded in code rather than by [Range], because AddBrokerMessaging binds this section with Get<MessageBusSettings>(), which never runs DataAnnotations, so an attribute would document the bound without enforcing it (:209-215, :219-225). ApplyBackpressure applies each one, unset or non-positive, to the transport's own default rather than pushing a stalling value (DependencyInjection.Messaging.cs:360-371). PrefetchCount caps how many messages the transport holds locally ahead of the consumer; a window larger than the work a consumer can finish inside its lock or lease is what turns a slow consumer into redeliveries, while a window of one serializes the endpoint (:210-214). ConcurrentMessageLimit caps how many messages one receive endpoint consumes at once, the backpressure knob for how many database connections and downstream calls a burst of events can open (:220-224).
  • Why it's built this way: resolving both guards from the transport rather than shipping fixed defaults is what lets one settings class serve the monolith and the extracted-service topology at the same time (ADR-008), while keeping the at-least-once contract intact where it matters (ADR-003, ADR-021). Refusing an outbox-less broker at registration rather than honoring it follows the fail-fast configuration contract (ADR-070): the misconfiguration becomes a startup exception instead of silently dropped events.

  • Where it's used: bound with .ValidateDataAnnotations().ValidateOnStart() in AddInfrastructure (DependencyInjection.cs:169-172), then re-read eagerly in the same method to gate the outbox hosted services (:186-198). AddBrokerMessaging reads it again, falling back to new MessageBusSettings() when the section is absent (:738-739), short-circuits on InProcess (:741-744), and otherwise replaces IMessageBus with BrokerMessageBus (:771) and IEventBus with BrokerEventBus (:777), so OutboxProcessor becomes the only delivery channel. InProcessEventBus takes the options as an OPTIONAL parameter and consults the resolved outbox flag to choose between writing rows and dispatching directly (InProcessEventBus.cs:39, :80-84), and OutboxCleanupService reads IsInboxEnabled to decide whether to purge inbox rows (OutboxCleanupService.cs:52). Emulator wiring is delegated to ServiceBusEmulatorSupport. ApplyBackpressure reads PrefetchCount and ConcurrentMessageLimit off the bound instance inside the RabbitMQ and Azure Service Bus branches of ConfigureBrokerTransport (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:297, :314). Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Settings/SettingsTests.cs, which pins both resolution rules unset and explicit (SettingsTests.cs:267-268, :271-273, :286-287, :295-297).

  • Caveats: MassTransit is pinned to major version 8 by workspace policy, and both the retry API surface above and the existence of EmulatorAdminEndpoint are consequences of that pin.


[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

FaultIntegrationEventConsumer<TEvent>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging.Consumers · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/FaultIntegrationEventConsumer.cs:26 · Level 2 · class (public sealed partial, generic)

  • What it is: the consumer of MassTransit's Fault<TEvent> message, published when the real consumer for TEvent exhausts its retry policy. It turns a message that would otherwise appear only as a row in the broker's _error queue into one structured Error log plus a broker.fault.count metric tagged by event type (FaultIntegrationEventConsumer.cs:7-11).
  • Depends on: MassTransit's IConsumer<Fault<TEvent>> and ConsumeContext<T>, BrokerMetrics.FaultCounter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:30-31), IIntegrationEvent as the type constraint (FaultIntegrationEventConsumer.cs:28), and ILogger<T> with the LoggerMessage source generator.
  • Concept introduced, observability for the failure you cannot retry any further. [Rubric §13, Observability and Operability] assesses whether an operator can see a failure without going looking for it, and [Rubric §29, Resilience and Business Continuity] assesses whether the dead-letter path is a designed state rather than an accident. MassTransit's retry ladder ends by moving the message to an error queue and publishing a Fault<T>. Nobody watches an error queue, so the fault is the hook: subscribing to it converts a silent broker-side event into an application log line and a counter an alert can fire on. The poison-message posture is ADR-087, and the metric feeds the telemetry model of ADR-041.
  • Concept, the observer that must never create its own incident. The class doc at FaultIntegrationEventConsumer.cs:17-22 states the rule and the reasoning: a fault consumer that itself faults would publish Fault<Fault<TEvent>> and, on a broker with second-level redelivery enabled, keep re-entering itself. So this consumer never throws, and it never replays the original message. Both properties are visible in the body: the only guard is the ArgumentNullException.ThrowIfNull(context) MassTransit itself will never trip (:33), and after that everything is string formatting and a counter increment.
  • Walkthrough
    • Consume(context) (FaultIntegrationEventConsumer.cs:31-55) reads the fault message (:35).
    • The message id is fault.FaultedMessageId ?? fault.FaultId (FaultIntegrationEventConsumer.cs:39). The comment at :37-38 gives the operational reason for the coalesce: both are what an operator pastes into a queue browser, and one of them is always present.
    • The reasons string joins every exception message in the chain with |, falling back to <no exception detail> on an empty array (FaultIntegrationEventConsumer.cs:44-46). The comment at :41-43 is explicit that this is a deliberate summary and not a truncation bug: stack traces stay in the error queue's message headers, and the message text is what identifies the failure at a glance.
    • LogFault (FaultIntegrationEventConsumer.cs:48, declared at :57-58) logs at Error with the event type, the message id, and the reasons, and the message text itself explains what happened and where the message went.
    • BrokerMetrics.FaultCounter.Add(1, ...) (FaultIntegrationEventConsumer.cs:50-52) increments with an event_type tag, so the counter is sliceable per contract rather than being a single opaque total.
    • It returns Task.CompletedTask (:54), which acks the fault message. Nothing is retried.
  • Why it's built this way: registration is automatic rather than opt-in. RegisterIntegrationEventConsumer<TEvent> adds this consumer alongside the real one unless the caller passes registerFaultConsumer: false (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:38-46), and RegisterUpcastedIntegrationEventConsumer<TEvent> does the same (:78-86). Defaulting to on is the right default for observability: forgetting to register the real consumer is a loud failure, forgetting to register its fault observer is a silent one.
  • Where it's used: paired with every consumer registered through IntegrationEventConsumerExtensions. In MMCA.ADC that is the Identity service's speaker-link events, the Engagement service's set, and Conference's UserRegistered; in MMCA.Store it is the Sales service's ProductVariantChanged. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/Consumers/FaultIntegrationEventConsumerTests.cs, and by the harness suite alongside it (.../Messaging/Consumers/IntegrationEventConsumerHarnessTests.cs).

[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

EventUpcasterStartupValidator

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging.Consumers · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/EventUpcasterStartupValidator.cs:20 · Level 3 · class (internal sealed)

  • What it is: a one-method IHostedService whose entire job is to resolve IEventUpcasterRegistry at host start, so a bad upcaster registration graph fails the host instead of dead-lettering the first retired-contract message hours later (EventUpcasterStartupValidator.cs:7-12).
  • Depends on: IEventUpcasterRegistry as its single primary-constructor parameter (EventUpcasterStartupValidator.cs:20), Microsoft.Extensions.Hosting.IHostedService, and IIntegrationEvent as the type it probes with.
  • Concept introduced, validation by construction, forced by resolution. [Rubric §15, Best Practices and Code Quality] assesses whether invariants are checked at the earliest possible moment, and [Rubric §13, Observability and Operability] assesses whether a misconfiguration is discoverable. The validation itself does not live here: EventUpcasterRegistry validates its whole registration graph in its constructor, rejecting a duplicate source, a type mapped onto itself, and a cycle, with a message naming the offenders. Since DI is lazy, that constructor might not run until the first broker message arrives. This class exists purely to make it run at start. That is the fail-fast configuration posture of ADR-070 applied to a graph that options validation cannot express.
  • Walkthrough
    • StartAsync (EventUpcasterStartupValidator.cs:23-30) discards the result of upcasters.ResolveTerminalType(typeof(IIntegrationEvent)) (:27) and returns a completed task. The comment at :25-26 explains why the call exists at all: the real work happened in the injected registry's constructor, and reading one member is what makes the dependency impossible to elide.
    • StopAsync (EventUpcasterStartupValidator.cs:33) is a completed task. There is nothing to unwind.
  • Why it's built this way: the registration is the other half of the design, and its comment is explicit (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:200-204): TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, EventUpcasterStartupValidator>()), not AddHostedService, because two modules calling AddInfrastructure must not run the same validation twice. TryAddEnumerable de-duplicates on the implementation type, which AddHostedService does not. The class doc adds the cost note (EventUpcasterStartupValidator.cs:13-17): a host with no upcasters resolves an empty registry, and the whole mechanism costs one no-op call at start (ADR-090).
  • Where it's used: registered once inside AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:204), so every host that calls it gets the check. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/Consumers/EventUpcasterStartupValidatorTests.cs.

[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

AzureNotificationHubDeviceRegistrar

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/AzureNotificationHubDeviceRegistrar.cs:15 · Level 4 · class (public sealed)

  • What it is: the real implementation of IPushDeviceRegistrar. It maintains device installations in Azure Notification Hubs using the installation model (client-generated stable ids, full upsert semantics) and stamps each installation with its owner's user:{id} tag so sends can target users rather than raw device tokens (AzureNotificationHubDeviceRegistrar.cs:15-99).

  • Depends on: INotificationHubClient and ILogger<T> as its two primary-constructor parameters (AzureNotificationHubDeviceRegistrar.cs:15-17), NativePushPayloads for the tag, DeviceInstallationRequest as the input shape, and Result / Error.

  • Concept introduced, the hub as the only ownership store, and the existence oracle it must not become. [Rubric §11, Security] assesses whether an endpoint can be used to learn something the caller is not entitled to know, and [Rubric §9, API and Contract Design] assesses whether the result shape is honest about what happened. Installation ids are client-generated, so nothing stops a caller from sending someone else's. The framework stores no device table of its own, so the check has to be a read-then-delete against the hub, comparing the owner tag (AzureNotificationHubDeviceRegistrar.cs:64-74). The subtle part is the response: a mismatch is reported as success, not as not-found. The interface remarks explain why (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Notifications/IPushDeviceRegistrar.cs:28-36): answering differently for "no such installation" and "not yours" would turn the endpoint into an existence oracle for other users' installation ids, and the caller has nothing to do with either answer.

  • Walkthrough

    • UpsertAsync(userId, request, ct) (AzureNotificationHubDeviceRegistrar.cs:20-57) starts with the platform switch: FCMV1 and APNS, compared after ToUpperInvariant, map to NotificationPlatform.FcmV1 and .Apns, and anything else yields null (:22-27).
    • An unmapped platform returns a validation failure with code PushDevice.UnsupportedPlatform, and the message quotes the two accepted constants from DeviceInstallationRequest (AzureNotificationHubDeviceRegistrar.cs:28-34). Naming the valid values in the error is what makes it actionable from a client.
    • The Installation is assembled with the client-supplied id, the resolved platform, the push channel, and exactly one tag, the owner stamp (AzureNotificationHubDeviceRegistrar.cs:36-42). CreateOrUpdateInstallationAsync is a full upsert, so re-registering the same device id replaces the record rather than accumulating duplicates (:46).
    • The upsert's catch is narrow (MessagingException) and translates to PushDevice.UpsertFailed after logging the real exception (AzureNotificationHubDeviceRegistrar.cs:49-56).
    • DeleteAsync(userId, installationId, ct) (AzureNotificationHubDeviceRegistrar.cs:60-89) reads the installation, checks ownership, and deletes only on a match (:68-77). The comment at :64-67 also settles the concurrency question up front: a concurrent re-registration of the same id between the read and the delete is the owner's own doing, so no lock is warranted.
    • Two distinct catches follow. MessagingEntityNotFoundException returns success, because an unknown installation is already the desired state (AzureNotificationHubDeviceRegistrar.cs:79-83); a general MessagingException logs and returns PushDevice.DeleteFailed (:84-88).
    • OwnedBy (AzureNotificationHubDeviceRegistrar.cs:91-92) is the ownership predicate: the installation's tags contain the owner tag, compared with StringComparer.Ordinal. DeleteFailed (:94-98) is factored out so the failure shape is written once.
  • Why it's built this way: ADR-044 puts the device registry in the hub rather than in the application database, which is what removes push tokens (a credential-shaped secret) from the application's storage entirely. The cost of that choice is exactly the read-then-delete ownership check here, and the interface remarks accept it explicitly.

  • Where it's used: registered by AddNativePushNotifications alongside the sender (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:99). Called from the framework's own DevicesController (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:44, :68), which is what lets a client register a device without any application-specific endpoint. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Notifications/Push/AzureNotificationHubDeviceRegistrarTests.cs.

  • Caveats / not-in-source: an installation registered before ownership tagging existed carries no user: tag, and the delete path treats it exactly like someone else's (success, no delete), which is called out in the comment at :71-72. Nothing in this code cleans up such records.


NullPushDeviceRegistrar

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Notifications.Push · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Notifications/Push/NullPushDeviceRegistrar.cs:12 · Level 4 · class (public sealed)

  • What it is: the inert default for IPushDeviceRegistrar. It accepts and discards registrations so a client can call the Devices endpoints unconditionally; nothing is stored until a host enables native push (NullPushDeviceRegistrar.cs:12-21).

  • Depends on: IPushDeviceRegistrar, DeviceInstallationRequest, and Result.

  • Concept: the null-object default introduced at NullNativePushSender. Here both operations return success (NullPushDeviceRegistrar.cs:15-16, :19-20), which is the right choice for this contract specifically: a mobile client registers its device at startup as a matter of course, and failing that call in a host without a hub would surface as an error in an app whose user did nothing wrong. [Rubric §9, API and Contract Design] applies: the Devices endpoints keep the same response shape whether or not the channel is enabled, so no client needs a capability check.

  • Walkthrough: UpsertAsync (NullPushDeviceRegistrar.cs:15-16) and DeleteAsync (:19-20) are both expression-bodied Task.FromResult(Result.Success()). There is no validation of the platform string here, unlike AzureNotificationHubDeviceRegistrar, so a request that the real registrar would reject as an unsupported platform is accepted by this one.

  • Why it's built this way: ADR-044 ships the pipeline inert, and both halves of the channel (the sender and the registrar) have to be inert together, which is why they are registered as a pair (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:331-332) and replaced as a pair (:678-679).

  • Where it's used: registered by AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:332) and resolved by DevicesController (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:27) in any host that has not called AddNativePushNotifications with an enabled section.

  • Caveats / not-in-source: the divergence in validation strictness between this and the real registrar means a test running against the default registration cannot catch an invalid Platform value. That behavior is only exercised through AzureNotificationHubDeviceRegistrar.


ConsumerOriginRestore

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging.Consumers · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/ConsumerOriginRestore.cs:24 · Level 7 · class (internal static)

  • What it is: the adapter between a MassTransit consume context's headers and AmbientOrigin.Restore. It reads the four origin headers off a consumed message and restores them onto the per-message scope, so a message carrying no headers (an older publisher, or an event raised with no user) simply leaves the scope's defaults untouched (ConsumerOriginRestore.cs:24-58).
  • Depends on: AmbientOrigin for the actual restore, MassTransit's Headers and IServiceProvider, MessageHeaders for the four header names, and the UserIdentifierType alias with its TryParse.
  • Concept introduced, parsing rather than trusting a typed header. [Rubric §10, Messaging & Integration Architecture] assesses whether the pipeline handles transport differences correctly rather than assuming one broker's behavior. The user id is transported as text and parsed back rather than read as a typed header, because a broker is free to widen an integer header; Azure Service Bus hands one back as a long even when it was published as an int. Parsing the canonical string form is the one reading that behaves the same on every transport. A malformed value is treated as a publisher bug, not a reason to fail the consume, so it simply yields no identity rather than throwing.
  • Walkthrough
    • Apply(headers, services, authenticationType) (ConsumerOriginRestore.cs:24-58) returns an IDisposable (:35): the handle that withdraws the published origin, which the caller holds for the whole consume (:34). It parses MessageHeaders.UserId with UserIdentifierType.TryParse under the invariant culture, defaulting to null on failure (:42-48).
    • It then returns AmbientOrigin.Restore with that id and the remaining three headers, UserRoles, TenantId, and CorrelationId, read straight through as strings (ConsumerOriginRestore.cs:50-56), so the handle is AmbientOrigin's own restore handle passed through unchanged.
  • Why it's built this way: keeping the header-reading and the id-parsing in this one adapter, separate from AmbientOrigin itself, means the restore logic stays transport-agnostic while this class owns the one transport-specific decision, how a MassTransit header is read back as a string.
  • Where it's used: called by IntegrationEventConsumer<TEvent> (2 references) and UpcastingIntegrationEventConsumer<TEvent> (1 reference), both before touching the inbox, so a retired contract is restored under the same identity and tenant its plain-consumer sibling would have used.
  • Caveats / not-in-source: none beyond what is stated above; the class has no test file of its own under the Consumers test folder, so its behavior is exercised through the two consumers that call it.

UpcastingIntegrationEventConsumer<TEvent>

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Messaging.Consumers · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/UpcastingIntegrationEventConsumer.cs:32 · Level 9 · class (public sealed partial, generic)

  • What it is: the draining consumer for a retired integration-event contract. It binds a broker queue to the old type, upcasts each message to its terminal contract, and invokes the handlers registered for that terminal contract, so handlers are written once against the newest event type while producers still publishing the old one keep being delivered (UpcastingIntegrationEventConsumer.cs:13-23).
  • Depends on: IEventUpcasterRegistry, IServiceProvider, IInboxStore, and ILogger<T> as its four primary-constructor parameters (UpcastingIntegrationEventConsumer.cs:32-36); MassTransit's IConsumer<TEvent>; EventNameResolver for the inbox key; ConsumerOriginRestore to restore the publisher's identity, tenant, and correlation id before the inbox is touched; System.Linq.Expressions and System.Collections.Concurrent for the dispatch cache.
  • Concept introduced, contract retirement without a coordinated deploy. [Rubric §6, CQRS and Event-Driven] assesses whether the event pipeline can evolve its contracts, and [Rubric §7, Microservices Readiness] assesses whether two services can be deployed independently. Renaming or reshaping an integration event across a distributed system normally forces a lockstep deploy, because in-flight messages of the old shape have no handler on the other side. ADR-090 resolves that with a registered IEventUpcaster chain plus this consumer: the old queue stays bound and drains, each message walks the upcaster chain to the terminal type, and the handler code only ever knows the newest contract. The pairing rule is a real trap and is stated in the doc (UpcastingIntegrationEventConsumer.cs:20-22): do not register both this and the plain IntegrationEventConsumer<TEvent> for the same type, because they would compete for one queue and run the handlers twice.
  • Concept, dedup keyed on the original message id. [Rubric §12, Performance & Scalability] assesses whether idempotency is applied uniformly rather than per handler. Idempotent consumption (ADR-021) keys on the message id carried in the envelope. The doc (UpcastingIntegrationEventConsumer.cs:24-28) records that the registry preserves the envelope across every upcast hop, so the id this consumer records is the same id a plain consumer would have recorded, and a redelivery is recognised whatever contract the handlers ultimately see.
  • Concept, restoring the publisher's origin before the inbox is touched. [Rubric §11, Security] and [Rubric §8, Data Architecture] both apply here: under database-per-tenant, the inbox store's own routing depends on the tenant this restore sets. PrincipalAuthenticationType (UpcastingIntegrationEventConsumer.cs:49-54) is declared as the same constant IntegrationEventConsumer<TEvent> uses, because an upcast changes the contract, never the hop the identity came back from. Consume calls ConsumerOriginRestore.Apply (:64-68) before the dedup check for exactly the reason the sibling consumer does it in the same order: a retired contract is delivered with the same headers as a current one, so a consumer that skipped this would run its handlers anonymous while its sibling did not. The returned handle is bound with using var origin and so held for the whole consume (:67-68), which lets a handler's own scope inherit the origin too.
  • Walkthrough
    • Consume(context) (UpcastingIntegrationEventConsumer.cs:57-146) restores the publisher's origin first via ConsumerOriginRestore.Apply (:68), then takes integrationEvent.MessageId before any upcasting (:72), which is the point of the earlier concept paragraph on dedup.
    • The inbox key is EventNameResolver.GetInboxName(typeof(TEvent)) (UpcastingIntegrationEventConsumer.cs:76), the retired contract's [EventName] identity when it declares one and its short type name otherwise, which is what every row written so far holds.
    • inbox.TryBeginAsync(...) returning false means already processed: log at Debug and return (UpcastingIntegrationEventConsumer.cs:80-84). TryBegin also stages the inbox row in the scope's unit of work; it is written by CompleteAsync once every handler has succeeded (:78-79), as in IntegrationEventConsumer.
    • With no upcaster registered for the type, the consumer logs at Information and continues (UpcastingIntegrationEventConsumer.cs:86-91). This is the documented degrade path: the registry returns the instance untouched, and handlers for the original type run as usual.
    • UpcastToTerminal produces the terminal instance, and a type change is logged at Debug with both contract names (UpcastingIntegrationEventConsumer.cs:93-99).
    • Handler dispatch is non-generic because the terminal type is only known at runtime. DispatchCache (UpcastingIntegrationEventConsumer.cs:45-47) memoizes, per terminal type, the closed IIntegrationEventHandler<> interface and a compiled invoker. BuildInvoker (:156-176) builds an expression tree that casts the two object parameters to their concrete types and calls the strongly-typed HandleAsync directly, so reflection is paid once per contract instead of once per message. This mirrors what DomainEventDispatcher does for the in-process path.
    • The dispatch loop (UpcastingIntegrationEventConsumer.cs:109-134) counts handlers and awaits each invoker. Its catch (:122-133) is the failure contract: inbox.Abandon(messageId) discards the staged row so the redelivery is reprocessed rather than skipped as a duplicate (:124-126), the handler failure is logged with the handler's full type name, and the exception is rethrown so MassTransit applies the configured UseMessageRetry policy before dead-lettering (:128-132). OperationCanceledException is excluded from the catch filter, so a shutdown is not treated as a handler failure.
    • Zero handlers is an Information log, not an error (UpcastingIntegrationEventConsumer.cs:136-141): returning normally lets MassTransit ack the message, and the comment states plainly that the broker will not retry.
    • inbox.CompleteAsync(...) (UpcastingIntegrationEventConsumer.cs:145) persists the staged row unless a handler's own save already committed it. The message is recorded only on a successful consume, because the failure path above rethrows.
  • Why it's built this way: the alternative to a per-retired-type consumer is version-tolerant handlers, each branching on a schema version, which spreads the migration across every handler and never gets cleaned up. Concentrating it in one registered consumer per retired contract means the retirement is visible in one place in a host's Program.cs and can be deleted when the old queue is drained (ADR-010 sets the versioning policy, ADR-090 ships the mechanism, ADR-016 pins the MassTransit version this all runs on).
  • Where it's used: registered per retired type through RegisterUpcastedIntegrationEventConsumer<TEvent> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:78-86), which also adds the matching FaultIntegrationEventConsumer<TEvent>. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/Consumers/UpcastingIntegrationEventConsumerTests.cs.
  • Caveats / not-in-source: no first-party host in MMCA.ADC or MMCA.Store currently calls RegisterUpcastedIntegrationEventConsumer; the extension point is documented on BaseIntegrationEvent and on OutputCacheEvictionRequested as the path to take when a contract is retired. Its behavior is therefore established by the test suite rather than by production traffic.

[Rubric §10, Messaging & Integration Architecture] applies: this type sits on the path a message takes once it leaves the process (outbox, bus, consumer, or broker plumbing), which is what section 10 scores.

FileStorageSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/FileStorageSettings.cs:10 · Level 0 · class (sealed)

  • What it is: the FileStorage configuration section for Azure Blob Storage: an endpoint (production, managed identity) or a connection string (local Azurite), plus the container every blob lives in.

  • Depends on: System.Uri (BCL) only. Its consumers are the Azure SDK types (BlobServiceClient, DefaultAzureCredential) wired in DependencyInjection.

  • Concept introduced, the incomplete-section no-op. [Rubric §11, Security] assesses how credentials are handled: the production path sets ServiceUri and authenticates with DefaultAzureCredential, so no storage key exists to leak, while ConnectionString is documented as the local-development alternative (FileStorageSettings.cs:15-19). [Rubric §33, Developer Experience]: AddAzureBlobFileStorage is written so that an incomplete section is a no-op rather than a startup crash (DependencyInjection.Notifications.cs:120-123), which lets a host call it unconditionally and lets an environment opt in with configuration alone. [Rubric §15, Best Practices]: note the deliberate absolute-URI check at DependencyInjection.Notifications.cs:125-126, an empty-string ServiceUri binds to a relative Uri, so a truthiness test would have accepted a useless value; only { IsAbsoluteUri: true } counts, and the comment in the code says exactly that.

  • Walkthrough:

    • SectionName = "FileStorage" (FileStorageSettings.cs:13), the same static section-name convention every settings class in this namespace follows.
    • ServiceUri (:16), nullable Uri, the blob service endpoint.
    • ConnectionString (:19), nullable, the Azurite alternative.
    • ContainerName (:22), documented as required; the registration bails out when it is blank (DependencyInjection.Notifications.cs:120-122) and again when neither an absolute ServiceUri nor a connection string is present (:127-130).
  • Walkthrough of its one consumer: AddAzureBlobFileStorage binds the options (DependencyInjection.Notifications.cs:116-117), re-reads the section eagerly with .Get<FileStorageSettings>() (:119) because the decision to register at all has to be made at composition time, then registers a singleton BlobContainerClient built from either the URI plus DefaultAzureCredential or the connection string (:132-138) and swaps IFileStorageService to AzureBlobFileStorageService (:139), replacing the NullFileStorageService default.

  • Why it's built this way: ADR-045 chose a configuration-gated storage provider so that a host registers it once and each environment decides whether it is live. Binding the options even in the no-op path (DependencyInjection.Notifications.cs:116-117) means IOptions<FileStorageSettings> always resolves, so nothing downstream has to null-check the section.

  • Where it's used: AddAzureBlobFileStorage (DependencyInjection.Notifications.cs:114-142) only.


JobClaim

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:455 · Level 0 · record (private nested, sealed)

  • What it is: the result of one attempt to claim a due job row: which row was due, the occurrence it was due at, the cron expression to compute the next occurrence from, and the claim token, which is null when a different replica won the row.

  • Depends on: nothing first-party. BCL only (string, DateTime, Guid?). It exists solely as the return shape of ScheduledJobRunner.TryClaimNextDueAsync.

  • Concept introduced, three answers in one return value. A claim attempt has three outcomes, not two: nothing was due, something was due and this replica took it, or something was due and another replica took it first. [Rubric §15, Best Practices & Code Quality] assesses whether the code makes illegal states hard to express: the method returns JobClaim?, so null is "nothing due" and a non-null instance with a null LockToken is "lost the race" (ScheduledJobRunner.cs:406-409). Collapsing the latter two into one null would have cost the caller the ability to mark that name as attempted for this cycle and move on, which is exactly what stops the loop spinning on a row this replica will never own (ScheduledJobRunner.cs:390-399). A positional record gets that shape in one line with value equality and no mutable state.

  • Walkthrough: four positional members, each documented (ScheduledJobRunner.cs:450-455): JobName, DueOn (the occurrence the row was due at, used for the lag metric), CronExpression (what the next occurrence is computed from, read from the row rather than recomputed from settings so an in-flight schedule change cannot retarget a run in progress), and Guid? LockToken. It is constructed once, at ScheduledJobRunner.cs:447, where the token is passed as claimed == 0 ? null : lockToken: the count of rows the claiming ExecuteUpdateAsync actually matched IS the race result.

  • Why it's built this way: private sealed record keeps a purely internal carrier invisible to the package's public surface, so it costs nothing in API compatibility terms while still giving the claim path a named, immutable shape (ADR-074).

  • Where it's used: returned by TryClaimNextDueAsync (ScheduledJobRunner.cs:410-448) and consumed by RunDueJobsAsync (ScheduledJobRunner.cs:373-401).


ScheduledJobEntry

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobEntry.cs:20 · Level 0 · class (sealed)

  • What it is: the persisted schedule and last-run record for one registered IScheduledJob: one row per job name, upserted by ScheduledJobRunner on every cycle. It carries both the schedule (expression, next occurrence) and the claim lease that makes a multi-replica host safe.

  • Depends on: nothing first-party and nothing beyond string, DateTime and Guid from the BCL, which is why it is Level 0.

  • Concept introduced, the framework bookkeeping entity. [Rubric §4, DDD] assesses whether the domain model stays a model of the business: this class is deliberately not an IAuditableEntity and not an aggregate root, exactly like OutboxMessage, because it is framework bookkeeping rather than domain state (ScheduledJobEntry.cs:8-14). The consequences are concrete: no soft-delete flag, so no global query filter applies to it; no audit stamps, so the save interceptors have nothing to write (which is why the runner can call a plain SaveChangesAsync with no user id, ScheduledJobRunner.cs:322-327); and no concurrency token, because its concurrency control is the explicit claim lease instead. [Rubric §8, Data Architecture]: the table is host-scoped and lives in the Default data source only, unlike the outbox, which exists once per physical source (ScheduledJobEntry.cs:15-18). Jobs belong to a host, not to a database. [Rubric §11, Security] by omission: it also carries no user data, which is what keeps it in the audit trail's framework-entity exclusion set alongside the outbox and inbox rows (Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:113-119, this type at :118).

  • Walkthrough, in the order the runner touches them:

    • JobName (ScheduledJobEntry.cs:26), required and init-only: the primary key, matching IScheduledJob.Name, so a job has exactly one schedule row per host.
    • CronExpression (ScheduledJobEntry.cs:34), required with a setter: the five-field UTC expression currently in force, which is either the job's code default or the Scheduler:Jobs:{Name}:Cron override. The runner compares it against the resolved expression every cycle and recomputes the next occurrence only when it changed.
    • NextRunOn (ScheduledJobEntry.cs:40): the UTC instant the job next becomes due. A row whose expression cannot be parsed is parked at DateTime.MaxValue so it is never claimed, which is how one bad cron string in configuration fails just that job.
    • LastRunOn (:43), LastOutcome (:50, one of Succeeded, Failed, Skipped), LastError (:57, truncated to the column width and cleared on success so the column always describes the LAST outcome rather than the last failure ever seen), and LastDurationMs (:60): the operator-facing run record.
    • LockedUntil (:67) and LockToken (:74): the claim lease. Other replicas skip a row with an unexpired lease, so an occurrence runs once even with several replicas polling; the claiming replica stamps its outcome only on a row still carrying its own token, so a replica whose lease expired mid-execution cannot overwrite the record of the replica that took over.
  • Why it's built this way: ADR-074 chose to apply the outbox's proven claim-lease idiom to cron rather than adopt Hangfire or Quartz.NET, so the row shape is deliberately the minimum that idiom needs: a due time, a lease, and a token.

  • Where it's used: mapped by ApplicationDbContext.ConfigureScheduler (Persistence/DbContexts/ApplicationDbContext.cs:594-619), which is gated: the entity is mapped only when the host set Scheduler:Enabled and this context targets the Default source (ApplicationDbContext.cs:586-599, the guard itself at :596-599), so a host that never opted in keeps exactly the model, and the migrations, it had before the scheduler shipped, and Cosmos DB never reaches the method at all (ApplicationDbContext.cs:591-592). The mapping sets ToTable("ScheduledJobs", "dbo") (:603), the JobName key (:604), column widths matching the entity's documented truncation (:605-608), and one index on NextRunOn with the two lease columns as included columns (:615-617), deliberately NOT filtered to unlocked rows, because the poll must also find rows whose lease has expired (that is how a dead replica's work is reclaimed, ApplicationDbContext.cs:610-614). Every read and write of the row lives in ScheduledJobRunner.


ScheduledJobOverrideSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerSettings.cs:66 · Level 0 · class (sealed)

  • What it is: one entry under Scheduler:Jobs:{Name}, the per-job override that lets a deployment retime a recurring job without touching code. Today it carries exactly one member, the cron expression.

  • Depends on: nothing. It is the value type of SchedulerSettings.Jobs (SchedulerSettings.cs:60) and is read by ScheduledJobRunner.

  • Concept introduced, code default plus configuration override. An IScheduledJob ships with a CronExpression compiled into it, which is the right default because the job's author knows what cadence the work needs. An operator who has to change that cadence for one environment should not need a release. [Rubric §17, DevOps] assesses whether operational behavior can be changed without a code change; this pair is the minimal answer: an absent entry leaves the compiled-in schedule in force, and a present, non-blank Cron replaces it (SchedulerSettings.cs:54-59, :68-73). [Rubric §15, Best Practices & Code Quality]: the override is keyed by IScheduledJob.Name, the job's own stable identity, so configuration and code agree on one name rather than on a class name that refactoring would break.

  • Walkthrough: a single string? Cron { get; init; } (SchedulerSettings.cs:74). The whole mechanism lives in the reader, not in this type: ScheduledJobRunner.ResolveCronExpression looks the job up by name and takes the override only when it is present AND non-blank, otherwise the job's own expression (ScheduledJobRunner.cs:170-174). Blank-means-absent matters, because a JSON key set to "" is a common way to try to clear a value and would otherwise produce an unparseable schedule.

  • Why it's built this way: the runner treats a changed expression as a schedule rewrite. On each cycle it reads the stored expressions (ScheduledJobRunner.cs:275-281), leaves a row untouched when the resolved expression matches (recomputing every cycle would push the next occurrence forward forever and nothing would ever fire, :279-285), and otherwise recomputes the next occurrence from the current instant and either updates the row with a schedule-changed log (:293-298) or inserts a new one (:299-310). So an operator edit is picked up on the next cycle, with no restart, which is what makes the override useful in the first place (ADR-074).

  • Where it's used: ScheduledJobRunner only, through SchedulerSettings.Jobs.

  • Caveats: an override that does not parse is not rejected at startup, unlike the range-checked scalar settings in this namespace. The runner logs the parse failure (ScheduledJobRunner.cs:295-299, message at :569-570) and records the job with the skipped outcome and a DateTime.MaxValue next run (:301-308), so a bad cron string is an observable non-run rather than a failed boot.


SchedulerMetrics

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerMetrics.cs:16 · Level 0 · class (internal, static)

  • What it is: the OpenTelemetry instrument set for the recurring job scheduler: one meter carrying a run counter, an execution-duration histogram, and a schedule-lag histogram, all emitted by ScheduledJobRunner.

  • Depends on: System.Diagnostics.Metrics (BCL) only (SchedulerMetrics.cs:1). Nothing first-party.

  • Concept introduced, the one-meter-per-feature instrument holder. [Rubric §13, Observability & Operability] assesses whether a running system can be understood from outside: a background loop is invisible by construction (no request, no response code), so the only evidence that a schedule is healthy is telemetry. The three instruments here are chosen to answer the three operator questions: is it running, how long does it take, and is it on time. [Rubric §31, Cost/FinOps] shows up in the same design: the per-occurrence start and finish lines are logged at Debug rather than Information precisely because a busy schedule would otherwise double the runner's steady-state log volume (ScheduledJobRunner.cs:583-590), and the numbers an operator alerts on come from these metrics instead. A host exports them by registering the MeterName meter; the Aspire service defaults (ConfigureOpenTelemetry) already do (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.Telemetry.cs:311), and the doc records that the name is duplicated as a literal there because that package has no reference to Infrastructure (SchedulerMetrics.cs:5-14).

  • Walkthrough:

    • MeterName = "MMCA.Common.Scheduler" (SchedulerMetrics.cs:19) and the single static Meter built from it (:21). The class doc is explicit that one meter serves every scheduler instrument and that a second Meter with this name must never be created (SchedulerMetrics.cs:11-14), since duplicate meters are a classic source of double-counted telemetry. BrokerMetrics repeats the same shape for the broker transport.
    • RunCounter (SchedulerMetrics.cs:28-31), scheduler.job.runs, unit runs, tagged by job and outcome: the failure rate of a schedule is this counter split by outcome.
    • DurationHistogram (:39-42), scheduler.job.duration in seconds, tagged by job, measured around the job's own ExecuteAsync only so it excludes claim and bookkeeping cost. Its stated purpose is the lease relationship: a job whose duration approaches Scheduler:LeaseSeconds is about to lose its claim mid-run (SchedulerMetrics.cs:33-38).
    • LagHistogram (:50-53), scheduler.job.lag in seconds, the interval between an occurrence becoming due and actually starting. It is floored at zero, and a value near the polling interval is normal for an occurrence that landed just after a cycle (SchedulerMetrics.cs:44-49).
  • Why it's built this way: internal static readonly instruments created once at type initialization means the runner records without resolving anything from DI, and keeping the meter name a constant on the same type is what lets a test assert against it. SchedulerMetricsTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/SchedulerMetricsTests.cs:13) pins the instrument surface.

  • Where it's used: ScheduledJobRunner.ExecuteClaimedJobAsync records the lag before invoking the job (ScheduledJobRunner.cs:472-475) and the duration plus the outcome-tagged count after it returns (ScheduledJobRunner.cs:479-482).


GetUserPreferencesQuery

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.GetPreferences · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5 · Level 1 · record (sealed)

  • What it is: the one-line query that asks for a single account's stored UI preferences. It is a positional record with exactly one member, UserId, and it implements IUserScopedRequest (GetUserPreferencesQuery.cs:5).

  • Depends on: IUserScopedRequest (IUserScopedRequest.cs:8), and the UserIdentifierType alias, which resolves to int through the solution-wide global using (MMCA.Common/Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1; see primer §4 for the alias convention). No externals.

  • Concept introduced: the one request record in this family that could be shared. Almost everything else in the shared Users use cases keeps its command record app-side, because ADC and Store disagree on the pipeline markers those records carry: both DeleteUserCommand records, for instance, implement ICacheInvalidating with a CachePrefix built from their own User type (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14, :17; MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:17, :20), which is a value no shared record could produce. This query carries no markers at all: it is not ICacheInvalidating, not IQueryCacheable, not ICommandWithRequest<out TRequest>. That absence is precisely what made it hoistable, and it is the rule worth taking away: a type moves into the framework when it has no app-specific policy attached to it.

    [Rubric §9: API & Contract Design] assesses whether the contract between layers is explicit and minimal. The query is the entire input contract for the read: one identifier, supplied by the controller from the authenticated principal rather than by the caller, so there is no way to ask for another account's preferences through this shape (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:146-151).

    [Rubric §6: CQRS & Event-Driven] assesses the separation of reads from writes. This is the read half of the culture/theme pair; its write counterpart is the app-side ChangePreferencesCommand handled by ChangePreferencesHandlerBase<TUser, TCommand>. Because it is a query, it flows through the shorter query pipeline (FeatureGate, Logging, Caching, handler) with no Validating and no Transactional decorator, per ADR-014.

  • Walkthrough: one positional parameter.

    • UserId (GetUserPreferencesQuery.cs:5): the account to read. Implementing IUserScopedRequest is satisfied by the record's generated property, so the shared handler base can read the target through the interface without knowing the record type (IUserScopedRequest.cs:11).
  • Why it's built this way: the query and its UserPreferencesResponse reply were byte-identical in both app Identity modules, so the handler base could be made generic in the User aggregate alone rather than also in the query type (GetUserPreferencesHandlerBase.cs:10-14). Preferences themselves are the persistence side of ADR-027 (culture) and ADR-028 (theme).

  • Where it's used: constructed by UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> in its GET preferences action (UserAccountAuthControllerBase.cs:151) and handled by GetUserPreferencesHandlerBase<TUser> (GetUserPreferencesHandlerBase.cs:33-35). Both apps' AuthController take the closed handler interface as a constructor dependency (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:36, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:32), and both architecture suites use it as the query specimen when asserting decorator ordering (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:30, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:30).


SchedulerSettings

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerSettings.cs:16 · Level 1 · class (sealed)

  • What it is: the Scheduler section for the recurring-job runner: the on switch, the fallback poll interval, the row-claim lease, which engine holds the ScheduledJobs table, and a per-job cron override map. Every property has a default, so a host that opts in needs only Scheduler:Enabled (SchedulerSettings.cs:6-9).

  • Depends on: DataSource, the engine enum, imported from MMCA.Common.Application.Interfaces.Infrastructure (SchedulerSettings.cs:2, :52), and ScheduledJobOverrideSettings, the one-property class declared at the bottom of the same file (:66-75). Externals: System.ComponentModel.DataAnnotations for [Range] (:1).

  • Concept introduced: one setting that gates both the behavior and the schema. Most feature flags gate behavior. Enabled also decides whether the ScheduledJobs table is mapped into the EF model at all: ApplicationDbContext reads IOptions<SchedulerSettings> with GetService (not GetRequiredService) so an absent registration reads as disabled rather than failing every context construction, then additionally requires the physical source to be the default one (ApplicationDbContext.cs:283-288). The consequence is the one worth learning: a host that leaves the flag false has exactly the model it had before the scheduler shipped, so its migrations never see the table (SchedulerSettings.cs:10-15).

    [Rubric §15, Best Practices & Code Quality] assesses whether adopting a framework feature is additive. Because registration and activation are separate (AddScheduledJobs binds and registers the runner, but the runner returns immediately unless Enabled is true, ScheduledJobRunner.cs:85), a host can ship the registration and turn the scheduler on per environment (DependencyInjection.cs:421-425).

    [Rubric §29, Resilience and Business Continuity] assesses safe behavior under replication. LeaseSeconds is what makes multiple replicas safe: a replica claims a job row for that many seconds and other replicas skip claimed rows, so no occurrence runs twice; if the claiming replica dies mid-execution, the row becomes claimable again once the lease expires (SchedulerSettings.cs:36-43, applied at ScheduledJobRunner.cs:432).

    [Rubric §31, Cost and FinOps] assesses idle-cost defaults. PollingIntervalSeconds is a BOUND on the smart wait, not a hot loop: the runner normally sleeps until the earliest due job and uses this value only to cap that sleep and to notice a configuration-driven schedule change (SchedulerSettings.cs:28-34, ScheduledJobRunner.cs:121-124).

  • Walkthrough: one static field, four init properties and one get-only dictionary.

    • SectionName = "Scheduler" (SchedulerSettings.cs:19).
    • Enabled (:26): default false, documented as the deliberate posture that adopting the framework must never add a table or a background loop to a host that did not ask for one.
    • PollingIntervalSeconds (:34): [Range(1, 3600)] (:33), default 30.
    • LeaseSeconds (:43): [Range(10, 3600)] (:42), default 300, documented as needing to sit comfortably above the longest expected job duration.
    • DataSource (:52): default DataSource.SQLServer. Jobs are host-scoped, so there is exactly one ScheduledJobs table per host rather than one per source, and this only names the relational engine that table lives on; Cosmos DB is not a valid value (:45-51). The runner resolves it against DataSourceKey.DefaultName (ScheduledJobRunner.cs:224).
    • Jobs (:60): a get-only Dictionary<string, ScheduledJobOverrideSettings> keyed by IScheduledJob.Name and bound from Scheduler:Jobs:{Name}. ResolveCronExpression takes the override only when the entry exists AND its Cron is non-blank, otherwise the job's compiled-in expression stands (ScheduledJobRunner.cs:170-174). A changed expression is picked up on the next cycle: the runner compares the resolved expression against the stored one, leaves an unchanged row alone (recomputing it every cycle would push every schedule forward and nothing would ever fire), and otherwise rewrites the stored value and recomputes the next occurrence from the current instant (ScheduledJobRunner.cs:284-306).
  • Why it's built this way: a persistent, database-backed cron scheduler with per-row leases is the decision recorded in ADR-074; everything in this class is the operator-facing surface of it. Defaulting every property makes the section optional, which is the same fail-soft-on-absence, fail-fast-on-bad-value posture ADR-070 describes: the [Range] guards run under ValidateOnStart, so a bad value fails the boot rather than the first cycle.

  • Where it's used: bound with validation in AddScheduledJobs (MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:39-42), which registers the runner through TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, ScheduledJobRunner>()) rather than AddHostedService, precisely so two calls cannot start two runners racing for the same rows (MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:44-48). Read by ScheduledJobRunner (ScheduledJobRunner.cs:42, :46) and by ApplicationDbContext for the table gate (ApplicationDbContext.cs:286-288); the design-time helper supplies a default instance so dotnet ef can build a model without a host (DesignTimeDbContextHelper, DesignTimeDbContextHelper.cs:160-161).


AzureBlobFileStorageService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/AzureBlobFileStorageService.cs:15 · Level 4 · class (public sealed)

  • What it is: the Azure Blob Storage implementation of IFileStorageService. It uploads and deletes blobs inside the one container the host configured, and returns a Result rather than letting a storage exception escape (AzureBlobFileStorageService.cs:10-15).
  • Depends on: Azure.Storage.Blobs.BlobContainerClient and ILogger<T> as its two primary-constructor parameters (AzureBlobFileStorageService.cs:15-17), IFileStorageService as the contract, and Result / Error for its return shapes.
  • Concept introduced, taking the container as a dependency rather than creating it. [Rubric §11, Security] assesses whether an application holds only the permissions it needs, and [Rubric §17, DevOps] assesses whether resource provisioning lives with infrastructure rather than in application startup. The class doc is explicit (AzureBlobFileStorageService.cs:12-13): the container, and crucially its public-access level, is provisioned by infrastructure and not created here. A CreateIfNotExists in application code would need container-management rights at runtime and would make the access level a property of whichever code path ran first. [Rubric §13, Observability and Operability] also applies: RequestFailedException is caught, logged with the exception, and translated into a domain-shaped failure whose message says nothing about Azure (:50-57, :69-76), so the operator sees the cause and the caller sees a stable error code.
  • Walkthrough
    • IsConfigured is a constant true (AzureBlobFileStorageService.cs:20). Its whole purpose is to differ from the false on NullFileStorageService.
    • UploadAsync(blobName, content, contentType, ct) (AzureBlobFileStorageService.cs:23-24) is a thin overload that forwards to UploadAsync(blobName, content, contentType, FileUploadOptions.None, ct), so a caller with no extra headers to set does not have to know the richer overload exists.
    • The real work is UploadAsync(blobName, content, contentType, options, ct) (AzureBlobFileStorageService.cs:28-58). It carries a targeted SuppressMessage for RS0026 (:27): two public overloads each ending in an optional CancellationToken is what that public-API analyzer flags, and the justification records that this overload shipped after the v1.152 baseline while RS0026/RS0027 were off, so changing its signature now would be a breaking change. The body null-guards options (:30), resolves a blob client from the container (:34), and uploads with BlobUploadOptions carrying the content type plus options.ContentDisposition and options.CacheControl as HTTP headers (:35-46), returning the blob's absolute URI on success (:48). Setting ContentType at upload time is what makes the blob serve correctly when a browser fetches the URL directly; the two added headers let a caller control how the browser offers or caches the file (for example, forcing a download versus inline display).
    • Its catch is narrow: only RequestFailedException, the Azure SDK's own failure type (AzureBlobFileStorageService.cs:50). It logs and returns Error.Failure with code FileStorage.UploadFailed and a caller-safe message (:52-56). Anything that is not a storage failure still propagates.
    • DeleteAsync(blobName, ct) (AzureBlobFileStorageService.cs:61-77) uses DeleteBlobIfExistsAsync, so an unknown blob name is success, which is what the interface promises ("unknown names succeed (idempotent)", MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IFileStorageService.cs:39). The same narrow catch yields code FileStorage.DeleteFailed (:69-75).
  • Why it's built this way: ADR-045 records blob storage as the home for binary content the databases should not hold. The registration is where the credential story lives (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:114-142): ServiceUri with DefaultAzureCredential is the managed-identity production path, ConnectionString is the local Azurite path, and ContainerName is required for either. There is one carefully commented trap at :125, an empty-string ServiceUri binds to a relative Uri, so only IsAbsoluteUri counts as configured. An incomplete section returns before registering anything, which leaves the null default in place, so a host can call AddAzureBlobFileStorage unconditionally.
  • Where it's used: registered by AddAzureBlobFileStorage (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:114), called by MMCA.ADC's Identity service host. Consumed by the avatar use cases: SetUserAvatarHandler uploads the normalized jpeg and deletes the previous blob, and RemoveUserAvatarHandler and DeleteUserHandler delete on their own paths.
  • Caveats / not-in-source: there is no dedicated unit test file for this class under MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Storage/. Whether the configured container is public-read or served through a signed URL is a provisioning decision and is not determinable from this source.

ImageSharpImageProcessor

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/ImageSharpImageProcessor.cs:20 · Level 4 · class (public sealed)

  • What it is: the ImageSharp implementation of IImageProcessor. It decodes an uploaded image, orients and crops it to a square, strips all metadata, and re-encodes it as JPEG, returning the bytes as a Result (ImageSharpImageProcessor.cs:15-20).
  • Depends on: SixLabors.ImageSharp (plus its Formats.Jpeg and Processing namespaces), IImageProcessor as the contract, and Result / Error. It takes no constructor dependencies, which is why it can be registered as a singleton.
  • Concept introduced, re-encoding as a sanitization boundary. [Rubric §11, Security] assesses whether untrusted input is neutralized rather than merely inspected, and [Rubric §30, Compliance, Privacy and Data Governance] assesses whether personal data is removed where it enters. The class doc states both payoffs in one sentence (ImageSharpImageProcessor.cs:16-18): decoding plus a full re-encode means only pixels survive, so EXIF metadata (including GPS coordinates, which are PII) and any polyglot payload smuggled into the original file are discarded. This is a stronger property than validation: a file that is simultaneously a valid JPEG and a valid archive or script cannot survive a decode-and-re-encode as anything but an image. The upload-side companion is ImageContentSniffer, which narrows the accepted formats by magic bytes rather than by the client-declared content type.
  • Walkthrough
    • Two public constants bound the decode: MaxDecodedPixels is 50,000,000 (ImageSharpImageProcessor.cs:31), and MaxDecodedDimension is 20,000 on either edge (:38). The remarks on MaxDecodedPixels name the trap directly (SEC-Common-28, :22-26): ADR-045's 2 MB cap bounds the compressed file, which a decompression bomb satisfies, a 2 MB PNG can declare 40000x40000 and cost roughly 6 GB of frame buffer the moment it is decoded, and a few concurrent uploads then take the replica out on an allocation the caller chose. MaxDecodedDimension closes the companion gap: a 1x200000 strip is inside the pixel-count cap yet still pathological for the resampler.
    • NormalizeToSquareJpegAsync(content, size, ct) (ImageSharpImageProcessor.cs:41-110) null-guards content (:43), then reads the format header with Image.IdentifyAsync before allocating any frame (:51-52), passing CreateDecoderOptions(maxFrames: uint.MaxValue) so the header read uses the same restricted format set as the decode. The comment explains the double payoff (:47-50): the header check refuses a declared-oversize image before the allocation it was built to provoke, and doing that check first keeps the failure a 400 (Error.Validation) instead of the OutOfMemoryException that would otherwise escape the catch clause below as a 500.
    • A declared size past either constant returns Error.Validation with code Image.TooLarge and the offending dimensions in the message (ImageSharpImageProcessor.cs:54-60); the stream position is restored when seekable (:62-65) before Image.LoadAsync actually decodes it (:70).
    • That decode passes CreateDecoderOptions(maxFrames: 1) (ImageSharpImageProcessor.cs:70). The comment above it gives the reason (:67-69): the output is a single JPEG built from the first frame, and decoding every frame of an animated file is how a small upload becomes hundreds of megabytes of pixel buffers. MaxFrames is what bounds the multi-frame case; the dimension gates alone bound only one frame at a time.
    • CreateDecoderOptions(uint maxFrames) (ImageSharpImageProcessor.cs:112-138) builds the DecoderOptions per call with a restricted Configuration that registers only the JPEG, PNG, WebP and GIF modules (:129-133). Every other format ImageSharp ships, TIFF and BMP among them, stays unregistered, so such a payload fails as UnknownImageFormatException (a validation failure) before that format's decoder code runs; the ImageSharp 3.x TIFF advisories of 2026-10-07 are accepted as unreachable on that basis (see the NuGetAuditSuppress list in Directory.Build.props). It is built per call rather than cached so it takes the current Configuration.Default memory allocator, which the frame-bound tests swap for a counting one (:122-126, :135).
    • A second gate re-checks the decoded frame's actual dimensions (ImageSharpImageProcessor.cs:74-80), because a format whose header understates its real size would otherwise slip past the header check.
    • The mutation chain is AutoOrient() then Resize with ResizeMode.Crop to a size by size square (ImageSharpImageProcessor.cs:84-90). The ordering comment is the kind of detail that is only learned by shipping the bug: the EXIF orientation flag has to be baked into the pixels before metadata is stripped, or portrait phone photos come out rotated.
    • The three metadata profiles are then nulled explicitly: EXIF, XMP, and IPTC (ImageSharpImageProcessor.cs:92-94). Nulling all three matters, because camera and location data can live in more than one of them.
    • Encoding is JpegEncoder { Quality = 85 } into a MemoryStream, returned as a byte array (ImageSharpImageProcessor.cs:96-101). The stream is disposed through await using with ConfigureAwait(false).
    • The catch is exception-filtered to exactly two ImageSharp types, UnknownImageFormatException and InvalidImageContentException (ImageSharpImageProcessor.cs:103-109), and turns them into Error.Validation with code Image.Undecodable. Undecodable input is the caller's problem, so it is a validation error and not a server failure; anything else still propagates.
    • TooLargeToDecode(width, height) (ImageSharpImageProcessor.cs:147-150) is the one private helper both gates call: either edge past MaxDecodedDimension, or the area (cast to long before multiplying, to avoid an int overflow on a large declared size) past MaxDecodedPixels.
  • Why it's built this way: ADR-045 records normalization as a framework leg rather than an application concern, so every consumer that accepts an image gets the same stripping and the same output shape. Keeping the size a parameter rather than a constant lets the calling handler own the product decision: MMCA.ADC's avatar path passes its own square size from SetUserAvatarHandler. The two decode-size gates close SEC-Common-28, a decompression-bomb denial-of-service that the 2 MB compressed-file cap alone did not prevent, by refusing before the allocation happens rather than catching after.
  • Where it's used: registered as a singleton by AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:337), and the comment there records why it is unconditional (:334): the image processor is dependency-free and always real, unlike the file storage default beside it (:335). Called by SetUserAvatarHandler before the upload. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Storage/ImageSharpImageProcessorTests.cs, with the single-frame bound pinned separately by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Storage/ImageSharpImageProcessorFrameBoundTests.cs.
  • Caveats / not-in-source: the two decode-size gates bound the frame's dimensions and pixel count and MaxFrames = 1 bounds the frame count, but nothing here further bounds working-set memory beyond that; ImageContentSniffer restricts the accepted formats to JPEG, PNG, and WebP by magic bytes, and the compressed-file size guard remains the handler's to write.

NullFileStorageService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Storage · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/NullFileStorageService.cs:11 · Level 4 · class (public sealed)

  • What it is: the unconfigured default for IFileStorageService. Uploads fail with a clear, stable error; deletes succeed, because there is nothing stored to delete (NullFileStorageService.cs:6-11).
  • Depends on: IFileStorageService as the contract and Result / Error for its returns.
  • Concept: the null-object default introduced at NullNativePushSender, with one instructive difference. [Rubric §29, Resilience and Business Continuity] assesses whether a missing capability degrades predictably. This null object is not uniformly silent: the two operations are treated asymmetrically because their meanings differ. A caller that wanted to store something and got nothing must be told (an upload that silently succeeded while storing nothing would leave a dangling URL in the database), whereas a caller that wanted something gone already has what it asked for.
  • Walkthrough
    • IsConfigured returns false (NullFileStorageService.cs:14), the flag the interface offers so a handler can gate a feature (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IFileStorageService.cs:13-14).
    • UploadAsync(blobName, content, contentType, ct) and the FileUploadOptions overload UploadAsync(blobName, content, contentType, options, ct) (NullFileStorageService.cs:17-18, :22-23) both simply return NotConfigured(): the extra options parameter changes nothing about a store that never stores anything. The options overload carries the same grandfathered RS0026 suppression as its Azure sibling (:21), since changing a shipped public signature would be a breaking change.
    • DeleteAsync (NullFileStorageService.cs:26-27) returns success.
    • NotConfigured() (NullFileStorageService.cs:29-33) is the private helper both upload overloads share: a failed Result carrying Error.Failure with code FileStorage.NotConfigured, a message naming the actual condition ("No file storage is configured for this host"), and source set to the type name. Factoring it out is what let the second overload's implementation be a one-line forward when FileUploadOptions was added to the interface.
  • Why it's built this way: the class doc says it directly (NullFileStorageService.cs:7-9), feature endpoints degrade cleanly. Because the failure is a Result and not an exception (ADR-013), an avatar upload in a host with no storage configured returns a clean error response instead of a 500, and the same code path works whether or not the deployment has provisioned a storage account (ADR-045).
  • Where it's used: registered by AddInfrastructure through TryAddTransient (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:336), replaced by AzureBlobFileStorageService when AddAzureBlobFileStorage finds a complete FileStorage section (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:139). It is also the storage the ADC profile E2E suite runs against (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs), and both upload overloads are exercised by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Storage/FileUploadOptionsOverloadTests.cs.
  • Caveats / not-in-source: nothing inside MMCA.Common reads IsConfigured; the two declarations are the ones in this unit (NullFileStorageService.cs:14 and AzureBlobFileStorageService.cs:20). The flag is exercised downstream: MMCA.ADC's UploadSessionAssetHandler checks it before touching storage and returns its own SessionAsset.StorageNotConfigured failure, whose message steers the user to add a link instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/UploadFile/UploadSessionAssetHandler.cs:56-62). The avatar path still discovers the unconfigured state from the failed upload result.

BeginTwoFactorEnrollmentHandlerBase<TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.TwoFactor · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/BeginTwoFactorEnrollmentHandlerBase.cs:35 · Level 4 · class (abstract)

  • What it is: the shared start-two-factor-enrollment workflow: load the account's two-factor state, refuse if already enabled, resolve the account's display name, mint a fresh TOTP secret, and start the enrollment in the store (BeginTwoFactorEnrollmentHandlerBase.cs:35, :48-92).

  • Depends on: ITwoFactorService, ITwoFactorStore and an ILogger as primary-constructor parameters (:35-38); implements ICommandHandler<in TCommand, TResult> closed over Result<TwoFactorSetupResponse> (:38). TCommand is constrained to IUserScopedRequest (:39), the narrowest of this family's command constraints: this workflow reads only the caller's id and carries no request payload. Its one audit line is a private source-generated [LoggerMessage] method, TwoFactorEnrollmentStarted (:106-107), which is why the class is declared partial (:35). Externals: Microsoft.Extensions.Logging.

  • Concept introduced: a fresh secret on every call, never a reuse. An interrupted enrollment must not leave a secret live that somebody who saw the first QR code could still key an authenticator with, so HandleAsync calls twoFactorService.GenerateSecret() unconditionally rather than resuming a prior attempt (:79, comment at :76-78).

    [Rubric §11: Security] assesses secret-material handling. The account is checked for an already-enabled factor before a new secret is even generated (:61-67), and the response carries the secret plus a provisioning URI built by the service, never anything the handler constructs itself (:89-91).

  • Walkthrough: one virtual member, one method and one abstract hook.

    • HandlerName (:45): protected virtual, defaulting to GetType().Name, the same source-stamping pattern used by every other handler base in this family.
    • HandleAsync(TCommand, CancellationToken) (:48-92): null-guards the command (:52); loads the two-factor state (:54), returning Error.NotFound when absent (:55-59); returns a Conflict (Authentication.TwoFactorAlreadyEnabled) when already enabled (:61-67); resolves the account label through the one abstract hook (:69), failing NotFound when it comes back blank (:70-74); generates the secret (:79); starts the enrollment in the store (:81), propagating its failure untouched (:82-85); logs TwoFactorEnrollmentStarted (:87); and returns the secret and provisioning URI (:89-91).
    • ResolveAccountNameAsync(UserIdentifierType, CancellationToken) (:102-104): protected abstract. The one app-specific step, because each app's User exposes its authenticator label (normally the email) differently.
  • Why it's built this way: enrollment, confirmation, disablement and recovery-code regeneration are four small, independently-testable steps rather than one wide handler, so each app subclass only ever forwards the app-specific lookups its use case actually needs. Each step owns its log message as a private [LoggerMessage] method, so the message text lives beside the only code that emits it.

  • Where it's used: covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/TwoFactorHandlerBaseTests.cs through a test subclass.

  • Caveats: the secret is returned to the caller in the response; it is up to the transport (HTTPS) and the client to keep it confidential in transit, nothing in this handler encrypts it further.


ConfirmTwoFactorEnrollmentHandlerBase<TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.TwoFactor · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/ConfirmTwoFactorEnrollmentHandlerBase.cs:29 · Level 4 · class (abstract)

  • What it is: the shared confirm-enrollment workflow: verify the caller's first TOTP code against the pending secret, generate a batch of recovery codes, and persist the completed enrollment (ConfirmTwoFactorEnrollmentHandlerBase.cs:29, :42-73).

  • Depends on: ITwoFactorService, ITwoFactorStore and an ILogger (:29-32); implements ICommandHandler<in TCommand, TResult> over Result<TwoFactorRecoveryCodesResponse> (:32). TCommand is an IUserScopedCommand<out TRequest> carrying a TwoFactorCodeRequest (:33). Also uses TwoFactorErrors, and logs through a private source-generated [LoggerMessage] method, TwoFactorEnabled (:75-76), hence the partial declaration (:29).

  • Concept introduced: the pending secret has to still be there to confirm against. The handler reads the state the Begin step wrote and fails with TwoFactorErrors.TwoFactorEnrollmentMissing when there is no secret, treating an empty or zero-length secret the same as a missing one (:49-53, TwoFactorSecret is not { Length: > 0 } secret), so a confirm call that races an unstarted or already-completed enrollment cannot verify against a blank value.

    [Rubric §1: SOLID] assesses open/closed. Verification (VerifyCode) and persistence (CompleteEnrollmentAsync) are both delegated to collaborators, so the handler itself contains no cryptographic logic.

  • Walkthrough: one virtual member and one method.

    • HandlerName (:39).
    • HandleAsync(TCommand, CancellationToken) (:42-73): null-guard (:46); load state and fail on a missing/empty secret (:48-53); verify the submitted code, failing with TwoFactorErrors.TwoFactorInvalid (:55-58); generate recovery codes (:60); complete the enrollment with their hashes (:62-68); log TwoFactorEnabled (:70); return the plaintext codes to the caller once, in the response (:72).
  • Why it's built this way: recovery codes are only ever returned at this one moment, on the response of the call that completes enrollment, because the store only ever persists their hashes (:62-64, the failure path at :65-68); the plaintext never touches the database.

  • Where it's used: BeginTwoFactorEnrollmentHandlerBase<TCommand> and this type are used together by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/TwoFactorHandlerBaseTests.cs through a shared test subclass.

  • Caveats: a failed VerifyCode does not clear the pending secret, so the caller can retry with a fresh code from the same authenticator entry without restarting enrollment.


DisableTwoFactorHandlerBase<TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.TwoFactor · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/DisableTwoFactorHandlerBase.cs:29 · Level 4 · class (abstract)

  • What it is: the shared turn-off-two-factor workflow: challenge the submitted code against the active factor, and disable it in the store only when the challenge succeeds (DisableTwoFactorHandlerBase.cs:29, :42-71).

  • Depends on: ITwoFactorAuthenticator, ITwoFactorStore and an ILogger (:29-32); implements ICommandHandler<in TCommand, TResult> over Result (:32). TCommand is an IUserScopedCommand<out TRequest> carrying a TwoFactorCodeRequest (:33). Also uses TwoFactorErrors, and logs through a private source-generated [LoggerMessage] method, TwoFactorDisabled (:73-74), hence the partial declaration (:29).

  • Concept introduced: a distinguishable not-enrolled outcome. ChallengeAsync returns a TwoFactorOutcome, and the handler treats NotEnrolled as a distinct failure (TwoFactorErrors.TwoFactorNotEnrolled, :57-60, comment at :54-56) rather than an idempotent success: the caller just presented a code, and the code was never actually checked against anything, so the response says so rather than silently agreeing.

    [Rubric §11: Security] assesses whether the disable path itself is gated. Disabling two-factor cannot happen without a live code challenge, so an attacker who only has the session cookie (and not the authenticator) cannot turn protection off.

  • Walkthrough: one virtual member and one method.

    • HandlerName (:39).
    • HandleAsync(TCommand, CancellationToken) (:42-71): null-guard (:44); challenge the code (:46-52); the NotEnrolled outcome fails distinctly (:57-60); DisableAsync (:62), returning its failure unchanged (:63-66); log TwoFactorDisabled (:68); return success (:70).
  • Why it's built this way: the challenge/store split mirrors the enrollment pair: ITwoFactorAuthenticator owns the live code check, ITwoFactorStore owns the persisted state, and this handler only sequences the two.

  • Where it's used: covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/TwoFactorHandlerBaseTests.cs.

  • Caveats: disabling does not clear previously issued recovery codes from the store on its own; that is ITwoFactorStore.DisableAsync's contract, not visible from this handler.


RegenerateRecoveryCodesHandlerBase<TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.TwoFactor · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/RegenerateRecoveryCodesHandlerBase.cs:30 · Level 4 · class (abstract)

  • What it is: the shared regenerate-recovery-codes workflow: challenge the submitted code, then replace the account's whole recovery-code set (RegenerateRecoveryCodesHandlerBase.cs:30, :44-77).

  • Depends on: ITwoFactorAuthenticator, ITwoFactorService, ITwoFactorStore and an ILogger (:30-34), the widest primary constructor of the four TwoFactor bases; implements ICommandHandler<in TCommand, TResult> over Result<TwoFactorRecoveryCodesResponse> (:34). TCommand is an IUserScopedCommand<out TRequest> carrying a TwoFactorCodeRequest (:35). Logs through a private source-generated [LoggerMessage] method, TwoFactorRecoveryCodesRegenerated (:79-80), hence the partial declaration (:30).

  • Concept introduced: regeneration replaces, it does not append. ReplaceRecoveryCodesAsync takes the freshly generated hash set and is expected to overwrite whatever codes existed before (:66-68), so a code from a batch the caller regenerated away is no longer valid, closing off an old code that may have leaked.

    [Rubric §11: Security] assesses whether the regenerate path is itself gated the same way disable is: a live code challenge, with the same NotEnrolled distinct failure, has to succeed first (:50-62).

  • Walkthrough: one virtual member and one method.

    • HandlerName (:41).
    • HandleAsync(TCommand, CancellationToken) (:44-77): null-guard (:48); challenge (:50-56); the NotEnrolled outcome fails distinctly (:58-62); generate a new code batch (:64); replace them in the store (:66-68), propagating a failure untouched (:69-72); log TwoFactorRecoveryCodesRegenerated (:74); return the new plaintext codes (:76).
  • Why it's built this way: it shares the challenge-then-store shape with DisableTwoFactorHandlerBase<TCommand> and the generate-then-persist shape with ConfirmTwoFactorEnrollmentHandlerBase<TCommand>, because regeneration is genuinely the union of the two operations.

  • Where it's used: covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/TwoFactorHandlerBaseTests.cs.

  • Caveats: as with ConfirmTwoFactorEnrollmentHandlerBase<TCommand>, the plaintext codes are returned exactly once, in this response; the store keeps only their hashes.


GetUserPreferencesHandlerBase<TUser>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.GetPreferences · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21 · Level 8 · class (abstract)

  • What it is: the shared preference-read workflow, and the only query handler among the Users bases. Load the account through the read repository and project its two preference fields into a UserPreferencesResponse (GetUserPreferencesHandlerBase.cs:21, :33-45).

  • Depends on: IUnitOfWork as its single primary-constructor parameter (:21); implements IQueryHandler<in TQuery, TResult> closed over GetUserPreferencesQuery and Result<UserPreferencesResponse> (:22). One constraint: TUser is an AuditableBaseEntity<TIdentifierType> implementing IUserPreferences (:23). Also uses Error. No logger, and no externals beyond the BCL.

  • Concept introduced: one generic parameter is enough when nothing app-specific rides on the request. This is the contrast case for the command bases above. Because GetUserPreferencesQuery carries no pipeline markers, it could be shared outright, so the base is generic in the User aggregate only (:10-14). Note also the weaker entity constraint: AuditableBaseEntity<UserIdentifierType> rather than AuditableAggregateRootEntity<...> (:23), because a read needs no domain events and no aggregate behavior. Constraining to the least the workflow actually uses is the pattern worth copying.

    [Rubric §6: CQRS & Event-Driven] assesses read/write separation. The handler takes the read repository via GetReadRepository (:39) rather than GetRepository, and never calls SaveChangesAsync. The class remarks record that this was a genuine divergence resolved by the hoist: the two pre-existing app copies disagreed, ADC using the read repository and Store the write one, and the read repository is the correct choice for a query handler (:15-19).

    [Rubric §12: Performance & Scalability] assesses avoidable work. The read repository is the no-tracking path, so this query stops materializing an EF change-tracker entry for every preference lookup; the class doc states the consequence plainly, that Store gains a no-tracking read on adoption (:17-18). Note the related trap the workspace has hit elsewhere: a no-tracking source poisons a whole composed query, which is precisely why mutation handlers such as ChangePreferencesHandlerBase<TUser, TCommand> keep using GetRepository instead.

    [Rubric §15: Best Practices & Code Quality] assesses consistency of error shape. The not-found path produces the identical Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name) construction the command bases use (:42-43), so every account use case in both apps reports a missing user the same way.

  • Walkthrough: one protected member and one method.

    • Primary constructor (:21): unitOfWork only.
    • HandlerName (:30): the same GetType().Name default, keeping the error source as GetUserPreferencesHandler for clients that match on it (:25-29).
    • HandleAsync(GetUserPreferencesQuery, CancellationToken) (:33-45): null-guards the query (:37); resolves GetReadRepository<TUser, UserIdentifierType>() (:39); loads by query.UserId (:40); and returns either the stamped Error.NotFound failure (:42-43) or a success wrapping new UserPreferencesResponse(user.PreferredCulture, user.PreferredTheme) (:44). A ternary, not a branch chain: the whole method is a load and a projection.
  • Why it's built this way: the query, the response and the workflow were all identical across the two apps, so this is the cleanest of the Users hoists; the only decision it had to make was which repository is correct for a read, and it resolved that in favor of the no-tracking one (:15-19). Preferences are read at login to reapply a returning user's culture and theme across devices (ADR-027, ADR-028), which is why the read path is worth keeping cheap.

  • Where it's used: subclassed as an empty, name-preserving class in both apps (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13-14, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:12-13). Consumed through the closed IQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>> interface by UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (UserAccountAuthControllerBase.cs:46, :57, :149-150). Pinned by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:14 through a test subclass (:87-88), and by each app's own handler tests.

  • Caveats: the soft-delete global query filter applies to this read like any other, so a soft-deleted account resolves to null and returns NotFound rather than its stored preferences. That behavior comes from the persistence layer, not from anything in this class.


ScheduledJobRunner

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Scheduling · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobRunner.cs:39 · Level 13 · class (sealed, partial, BackgroundService)

  • What it is: the framework's recurring job scheduler. A BackgroundService that runs the host's registered IScheduledJob implementations on their cron schedules, using a persistent job store (ScheduledJobEntry) and the outbox processor's claim-lease idiom so an occurrence executes exactly once across every replica.

  • Depends on: IServiceScopeFactory, ILogger<ScheduledJobRunner>, IOptions<SchedulerSettings> (SchedulerSettings), IDataSourceResolver, and an optional TimeProvider (ScheduledJobRunner.cs:39-44). At run time it resolves IDbContextFactory per cycle (:222) and works against ApplicationDbContext; it emits through SchedulerMetrics and returns its claim results as JobClaim. Externals: EF Core (ExecuteUpdateAsync), Microsoft.Extensions.Hosting, and Cronos, aliased as CronSchedule (ScheduledJobRunner.cs:12).

  • Concept introduced, the claim-lease scheduler. [Rubric §29, Resilience & Business Continuity] assesses whether work survives failure: a schedule that lives in a timer dies with the process, so the schedule lives in a table and the loop only reads it. [Rubric §12, Performance & Scalability] assesses scale-out: an interval-driven hosted service runs on every replica, so scaling a service to three instances silently triples a purge. The fix is the outbox's claim: a single ExecuteUpdateAsync that stamps LockedUntil and LockToken over a Where admitting only unleased-or-expired rows, where the count of matched rows IS the race result (ScheduledJobRunner.cs:435-447). Two replicas both issue that update and exactly one matches. [Rubric §13, Observability & Operability]: every branch that a human would need to explain later has a [LoggerMessage] line, including the ones that do nothing (disabled scheduler, duplicate job name, lease lost). [Rubric §14, Testability]: the injected TimeProvider plus an internal RunCycleAsync mean a test drives one whole cycle deterministically without waiting on wall-clock timers (ScheduledJobRunner.cs:212-213).

  • Walkthrough:

    • State and constants. _settings snapshots IOptions<SchedulerSettings>.Value once (ScheduledJobRunner.cs:46), and _timeProvider falls back to TimeProvider.System (:47). The three outcome strings Succeeded, Failed and Skipped are named constants (:50, :53, :59), MaxErrorLength is 2048 and matches the LastError column width (:62), StartupDelay is 15 seconds so the host finishes module registration and migration before the first cycle touches the table (:69), and MinimumWait is one second, the floor that stops an overdue row hot-looping the runner (:72). StampTimeout is five seconds (:80), the budget for recording an outcome after a job has returned (see ExecuteClaimedJobAsync below).
    • ExecuteAsync (:83-135), the loop. It returns immediately when Scheduler:Enabled is false, after one log line (:85-91), so a disabled scheduler is visible in the logs of a host that expected it without costing a line per cycle. Then the startup delay (:93-100), then forever: run a cycle, and swallow exceptions in two distinct ways. OperationCanceledException during shutdown breaks the loop (:109-113); any other exception is logged and the loop waits out the interval (:114-119), because one bad cycle (an unreachable database, a model mismatch) must not take the scheduler down for the life of the process.
    • The smart wait. ComputeWaitTime (:146-160) is a pure static function: the polling interval when nothing is registered, otherwise the time until the earliest upcoming occurrence, floored at MinimumWait and capped at the configured interval. This is the same "sleep until there is something to do" shape OutboxProcessor uses, and it is what keeps an idle scheduler from polling on a fixed tick.
    • RunCycleAsync (:213-236). One DI scope per cycle. It resolves the registered jobs (:216), returns early when there are none (:217-220), takes the context for the Default logical source on the configured engine through IDataSourceResolver (:222-224), reconciles registrations, runs due jobs, and finally reads back the earliest NextRunOn across the registered names (:229-235) which becomes the next wait.
    • ResolveRegisteredJobs (:243-258) groups GetServices<IScheduledJob>() by Name (ordinal) and keeps the first of each group, logging a collision rather than throwing (:251-254). Two jobs sharing a name would share one schedule row, and the design choice is that one mis-registered module must not stop every other job.
    • SyncRegistrationsAsync (:267-328) reconciles the table against the registered jobs. It reads the stored expressions once (:275-279), and for each job compares the resolved expression against the stored one: unchanged means leave the row alone (:287-293), with the comment naming the bug that would otherwise appear, recomputing NextRunOn every cycle would push every schedule forward forever and nothing would ever fire. A changed expression goes through the set-based UpdateScheduleAsync (:334-366) so a row another replica is currently executing is not disturbed by the change tracker; a new job is inserted (:307-319). An unparsable expression parks the row at DateTime.MaxValue and records Skipped instead of throwing (:295-316, and on the update path at :356-365).
    • ResolveCronExpression (:170-174) is the configuration override point: Scheduler:Jobs:{Name}:Cron when present and non-blank, otherwise the job's compiled-in default. TryGetNextOccurrence (:184-205) wraps Cronos and catches exactly CronFormatException and ArgumentException (:197), converting a malformed expression into a parked row plus an error message rather than a crashed runner.
    • RunDueJobsAsync (:373-401) claims and runs one row at a time, tracking an attempted set so each name is tried at most once per cycle (:378-390). A JobClaim with a null LockToken means another replica won that row, so it is skipped rather than retried (:392-399).
    • TryClaimNextDueAsync (:410-448) reads the earliest due, unleased row (:417-424), mints a Guid token and a lease of Scheduler:LeaseSeconds from now (:431-432), then issues the conditional claim update described above (:437-445).
    • ExecuteClaimedJobAsync (:462-521) records the lag (floored at zero so a clock adjustment cannot publish a negative duration, :472-475), invokes the job, records duration and the outcome-tagged counter (:477-482), then computes the next occurrence from the instant execution finished, not from the occurrence that just ran (:484-488). That is the missed-run policy: a host down for a day runs each job once on startup and returns to cadence instead of replaying a backlog. The final stamp is guarded by the claim token (:497-512): a replica whose lease expired mid-execution matches nothing, logs LogLeaseLost and drops its stale outcome (:514-518) rather than overwriting the current holder's record. That stamp runs under its own CancellationTokenSource(StampTimeout, _timeProvider) (:500, passed at :511) rather than the host stopping token (the source calls this "the outbox M8 shape"): the doc on StampTimeout (:74-79) explains that a completed occurrence must always advance its schedule and release its lease, or a graceful stop landing between the job and the stamp would leave the row leased and re-run work that already finished once the lease expires. Building the source from the injected TimeProvider keeps that budget testable on a fake clock.
    • InvokeJobAsync (:528-560) resolves the job in a fresh DI scope (:532-534), exactly like a request, so a job body gets scoped services (a unit of work, repositories, handlers) and the long-lived runner never captures a scoped dependency. A missing job returns Skipped with a message (:536-540); a cancellation during host shutdown is rethrown so the row stays leased and the occurrence is retried when the lease expires rather than being recorded as a failure it never was (:548-554); any other exception is logged and recorded as Failed (:555-559), so the schedule still advances and a permanently failing job cannot hot-loop. The two shutdown paths are deliberately different: a job interrupted mid-run is retried, while a job that ran to completion gets its stamp under StampTimeout regardless of the stopping token.
    • Log levels are a cost decision. The per-occurrence start and completion lines are Debug (:586-590) with the reason stated inline (a busy schedule would otherwise double this runner's steady-state log volume, [Rubric §31, Cost/FinOps], :583-585), while every failure line stays Error or Warning (:562-596).
  • Why it's built this way: ADR-074 records the decision to extend the durable polling loop already in production instead of adopting Hangfire or Quartz.NET, on the grounds that the missing piece was a cron expression, not a product, and that every extracted service host (ADR-008) would otherwise have to reason about a new dependency. Cron parsing is the one thing bought rather than built (Cronos, MIT, zero-dependency).

  • Where it's used: registered by AddScheduledJobs through TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, ScheduledJobRunner>()) (MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:47-48), deliberately not AddHostedService, since the latter appends a descriptor per call and two modules calling it would run two runners racing for the same rows (DependencyInjection.Jobs.cs:44-46). The framework's own scheduled job is AuditTrailCleanupJob, registered by AddAuditTrail (DependencyInjection.Jobs.cs:124). ScheduledJobRunnerTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/ScheduledJobRunnerTests.cs:21) drives whole cycles through the internal entry point via a shared SchedulerTestHarness (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/SchedulerTestHarness.cs), and SchedulerModelGateTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/SchedulerModelGateTests.cs:25) pins the model gate.

  • Caveats / not-in-source: which hosts actually call AddScheduledJobs and set Scheduler:Enabled lives in the downstream apps, not in this repository, so the set of deployments running a schedule today is Not determinable from source here.


ScopedUserOverride

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ScopedUserOverride.cs:27 · Level 5 · class (internal sealed)

  • What it is: the scoped slot that lets a background scope act as a captured user. Principal is null by default, which means "read through to HTTP"; a caller sets it before any handler is resolved, and clears it afterward (ScopedUserOverride.cs:27-51).
  • Depends on: System.Security.Claims.ClaimsPrincipal (BCL), and AmbientOrigin for the seed value its Principal initializer reads (ScopedUserOverride.cs:30). It is a two-member holder with no other collaborators.
  • Concept introduced, the override slot behind a decorator. [Rubric §11, Security] assesses whether identity flows correctly across an async or process boundary rather than defaulting to anonymous or leaking across scopes. HTTP requests already have an ambient identity from the authentication middleware, but background work (the internal command processor, the outbox processor, a broker consumer) runs on a scope with no HTTP context at all. This class is the seam DI resolves per scope: when nothing has called Set, Principal stays null and ImpersonatingCurrentUserService reads through to whatever the inner ICurrentUserService would have returned anyway.
  • Walkthrough
    • Principal (ScopedUserOverride.cs:30) has a private setter, so Set and Clear are the only ways to change it after construction. Its initializer is AmbientOrigin.Current?.BuildPrincipal(): the scope reads the enclosing hop's published origin once, at construction, and owns its copy from then on (ScopedUserOverride.cs:20-26, :30). A scope a handler opens for itself from the root factory inside a delivery therefore starts as the original interaction, while an HTTP request (which never publishes an origin) starts null. Nothing is shared or mutated across scopes, which is the leak a live ambient override would have caused.
    • Set(principal) (ScopedUserOverride.cs:36-40) null-guards the argument and assigns it.
    • Clear() (ScopedUserOverride.cs:50) sets Principal back to null. Its doc comment names the scenario that makes it necessary rather than optional (:39-46): the outbox processor restores one captured identity per row on a scope it keeps for the whole batch, so without an explicit clear between rows, a row raised by a user would answer for every later row raised by the system.
  • Why it's built this way: ADR-114 needs internal command handlers to see the identity that originally scheduled the work, through the same ICurrentUserService interface every other handler already depends on, rather than a parallel API only background code would use.
  • Where it's used: written by AmbientOrigin.Restore (set or cleared on every restore) and read by ImpersonatingCurrentUserService. Registered per scope in DependencyInjection.cs (2 references). Exercised directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/Consumers/IntegrationEventConsumerContextTests.cs (4), MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Outbox/Processing/OutboxProcessorContextRestoreTests.cs (4), and MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/InternalCommands/InternalCommandTestHarness.cs (2).
  • Caveats / not-in-source: like TenantContext, it is not thread-safe, but as a scoped service on a single logical unit of work (one HTTP request, one dequeued message, one internal command) that is not a property it needs.

AmbientOrigin

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:19 · Level 5 · class (internal static)

  • What it is: the shared helper that flattens a capturing principal's roles for storage, rebuilds a ClaimsPrincipal from what was stored, restores a captured user, tenant, and correlation id onto a fresh scope, and publishes that restored origin on the async flow so scopes opened inside the hop inherit it (AmbientOrigin.cs:19-249). It is the one place this logic exists; everything that later replays a captured origin, an internal command row, an outbox row, a broker message, calls into it.
  • Depends on: ScopedUserOverride and ITenantContext (from TenantContext) as the scope services it restores onto; OriginSnapshot as the published value and RestoreHandle as the disposable it returns; ICorrelationContext for the correlation id; the UserIdentifierType alias; System.Security.Claims for Claim/ClaimsIdentity/ClaimsPrincipal; AuthClaimTypes for the subject claim type.
  • Concept introduced, one flatten/rebuild pair reused across every async boundary. [Rubric §11, Security] assesses whether identity propagation is centralized rather than reimplemented per call site, and [Rubric §8, Data Architecture] assesses whether the tenancy invariant holds even off the request path. MaxRolesLength (AmbientOrigin.cs:37-40, value 512) is the column width shared by InternalCommands.UserRoles and OutboxMessages.UserRoles; FlattenRoles truncates to fit rather than throw, and is explicitly null-tolerant even though ICurrentUserService.Roles is declared non-nullable, because Roles is a default interface member that runs against hand-written doubles and mocks that stub only the properties a caller touches (:35-40). A null there must read as "no roles", never as a capture that throws inside a save.
  • Concept, the tenant is the one value Restore will not overwrite. Restore (AmbientOrigin.cs:156-188) documents an explicit asymmetry (:112-127): the principal and correlation id are set or cleared on every call, because a scope reused across several messages cannot let one message's user answer for the next, but ITenantContext is single-valued for the life of a scope by contract, since a scope whose tenant changed mid-flight has already read rows under the previous one. Restore therefore sets the tenant only when the scope has not already resolved a different one (:196-202); a host running database-per-tenant is unaffected, because its work is already one scope per tenant.
  • Concept, the restored origin is also published on the async flow. Integration event handlers are singletons that open their own scope from the root factory, a scope that never sees the Restore call. Restore therefore also writes an OriginSnapshot into a private AsyncLocal (AmbientOrigin.cs:24, :180-185), exposed read-only as Current (:32). TenantContext, ScopedUserOverride and CorrelationContext read Current once, at construction, and seed themselves from it (TenantContext.cs:19, ScopedUserOverride.cs:30, CorrelationContext.cs:13), so a handler's own scope runs as the original interaction (:116-144, the "Scopes opened inside the hop inherit it" remark). The snapshot carries the tenant and correlation id the restored scope ended up with, not the raw captured ones, so a database-per-tenant scope whose tenant was already fixed hands that tenant down (:182-183).
  • Walkthrough
    • FlattenRoles(roles) (AmbientOrigin.cs:54-70) joins the roles with a comma and truncates to MaxRolesLength, returning null for a null or empty source.
    • BuildPrincipal(userId, userRoles, authenticationType) (AmbientOrigin.cs:86-109) returns null when userId is null, meaning no user was captured, and the execution stays anonymous rather than inventing an identity. Otherwise it builds a sub claim from the id and one role claim per comma-separated entry, then wraps them in a ClaimsIdentity stamped with authenticationType, which is what makes IsAuthenticated true.
    • Restore(services, userId, userRoles, tenantId, correlationId, authenticationType) (AmbientOrigin.cs:156-188) now returns an IDisposable. It first saves the enclosing origin and withdraws it (:164-167), so a carrier constructed during this restore belongs to this hop and does not seed itself from the previous one. It resolves each of the three scope services with GetService rather than a required resolve, so a bare provider (a directly constructed test fixture) simply restores nothing rather than failing the hop (:125-127). It sets the tenant first via RestoreTenant (:171, :194-202), because it routes the scoped context factory to the right database and every repository resolved afterward reads its query filter from it; then sets or clears ScopedUserOverride via RestorePrincipal (:172, :206-220); then sets the correlation id when one was carried (:174-178); then publishes the new OriginSnapshot and returns a RestoreHandle that puts the previous origin back (:180-187). Callers dispose the handle when the message or row is done, so the next one cannot inherit it.
    • Suppress() (AmbientOrigin.cs:231-236) withdraws the published origin until the returned handle is disposed. It exists for the case where an explicit choice must win over an enclosing hop: its doc comment names TenantDataSourceTargets.CreateTenantScope (:225-229).
  • Why it's built this way: centralizing flatten, rebuild, and restore in one internal static class means every caller, the internal command scheduler, the outbox processor, the broker consumers, restores context identically; a divergence in any one of them would have been a silent identity or tenant leak rather than a compile error.
  • Where it's used: called by ConsumerOriginRestore (2 references) and directly by InternalCommandScheduler (2), InternalCommandProcessor (2), InternalCommandOriginCapture (2), TenantDataSourceTargets (2, the Suppress caller), BrokerMessageBus (1), DbContextFactory (1), and OutboxProcessor (1), all under MMCA.Common/Source/Core/MMCA.Common.Infrastructure. The seeding readers are ScopedUserOverride, TenantContext and CorrelationContext. Exercised by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Context/TenantScopeInsideARestoredHopTests.cs (3).
  • Caveats / not-in-source: not determinable from this source whether a host could reasonably bypass Restore and set the three scope services directly; every first-party caller goes through this helper. The AsyncLocal is the one ambient value in this design, so a caller that drops the returned handle leaves the origin published for the rest of that async flow.

OriginSnapshot

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:252 · Level 5 · record (internal sealed)

  • What it is: the immutable value AmbientOrigin publishes on the async flow while a background hop is running: the user id, roles, tenant, correlation id and authentication type the hop restored (AmbientOrigin.cs:252-262).
  • Depends on: the UserIdentifierType alias and System.Security.Claims.ClaimsPrincipal; its one method calls back into AmbientOrigin.BuildPrincipal (:261).
  • Concept introduced, a snapshot instead of a shared carrier. [Rubric §11, Security] assesses whether identity propagation avoids leaking across scopes. Publishing an immutable record rather than the live ScopedUserOverride or TenantContext means a scope that seeds from it copies values and then owns them; no mutable state is shared between the hop's scope and a scope a handler opens later.
  • Walkthrough
    • Positional members (AmbientOrigin.cs:252-257): UserId (nullable, null for system-raised work), UserRoles (the comma-separated list), TenantId, CorrelationId (both the values the restored scope ended up with) and a non-null AuthenticationType.
    • BuildPrincipal() (AmbientOrigin.cs:261) rebuilds the principal from the stored id and roles, or returns null when no user was captured. ScopedUserOverride calls it from its initializer (ScopedUserOverride.cs:30).
  • Why it's built this way: the roles stay in their flattened string form so the snapshot carries exactly what the stored row or message header carried, and rebuilding stays in the one helper that already owns that logic.
  • Where it's used: only inside AmbientOrigin (Current, Restore) and by the three seeding carriers through Current; no other file names the type.
  • Caveats / not-in-source: none beyond the above.

RestoreHandle

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:240 · Level 5 · class (private sealed, nested in AmbientOrigin)

  • What it is: the IDisposable that Restore and Suppress hand back; disposing it puts the previously published origin back (AmbientOrigin.cs:240-243).
  • Depends on: OriginSnapshot (the saved previous value) and the private CurrentOrigin AsyncLocal of its enclosing AmbientOrigin (:24).
  • Concept introduced, scoped publication through a disposable. [Rubric §1, SOLID] assesses whether a lifetime is expressed by the type system rather than by a convention. The handle makes "publish for the duration of this message or row" a using-shaped contract: Dispose is the single line CurrentOrigin.Value = previous (:242), so nesting restores correctly and the next message in a reused scope cannot inherit the last one's origin.
  • Walkthrough: a primary constructor capturing previous (AmbientOrigin.cs:240), and Dispose (:242) assigning it back. Restore passes the origin it displaced (:187) and Suppress passes the one it withdrew (:236).
  • Why it's built this way: a private nested class keeps the AsyncLocal writable only from AmbientOrigin, while callers see only IDisposable.
  • Where it's used: constructed in Restore and Suppress in the same file; callers hold it as IDisposable.
  • Caveats / not-in-source: not determinable from source whether every caller disposes it on all paths; that is each caller's responsibility.

TenantContext

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/TenantContext.cs:13 · Level 6 · class (public sealed)

  • What it is: the scoped holder of "which tenant is this scope running as". It starts unresolved (unless it is created inside a background hop, below), is set exactly once per scope, and refuses to be changed to a different value afterwards (TenantContext.cs:6-13). A scope created while an outbox row, broker message or internal command is being delivered starts with the tenant that hop restored, so a handler that opens its own scope runs under the original tenant (TenantContext.cs:19).
  • Depends on: ITenantContext as the contract, System.Globalization for the exception message, and AmbientOrigin for the seed read in the constructor (TenantContext.cs:19). Otherwise it is a two-property object with one guarded setter.
  • Concept introduced, the ambient scope value with a write-once discipline. [Rubric §11, Security] assesses whether an isolation boundary can be crossed by accident, and [Rubric §8, Data Architecture] assesses whether the tenancy model is enforced by the infrastructure rather than by convention. Multi-tenancy here (ADR-073) is a shared-schema query filter plus database-per-tenant routing, which means the tenant value silently participates in every read (the named Tenant query filter), every write (the save interceptor), every tenant-scoped cache key, and the choice of physical database. Reassigning it mid-scope would therefore be catastrophic in a very quiet way: rows already tracked by the context were fetched under the old tenant, and the next save would attribute them to the new one. The exception message spells out exactly that (TenantContext.cs:45-51), and it ends with the remedy rather than just the complaint: start a new scope.
  • Walkthrough
    • The public parameterless constructor (TenantContext.cs:19) assigns TenantId = AmbientOrigin.Current?.TenantId, so the value is read once at construction and null outside a hop (every HTTP request).
    • TenantId has a private setter (TenantContext.cs:22), so the only way to write it after construction is through SetTenant.
    • IsResolved is TenantId is not null (TenantContext.cs:25). Note that "unresolved" is the normal, expected state for every background service, seeder, and design-time tool (:7-9): consumers of the value treat unresolved as "no tenancy" rather than as an error.
    • SetTenant(tenantId) (TenantContext.cs:28-52) has three branches. It null- and whitespace-guards the argument (:22); assigns and returns when nothing is set yet (:24-28); returns silently when the same value is re-asserted (:32-35); and throws InvalidOperationException on a genuine change (:37-43).
    • The idempotent middle branch is the load-bearing one, and the comment at :30-31 names the scenario: the resolution middleware and a background worker that re-asserts the tenant on the same scope must not fight each other. Same value is a no-op, different value is a hard failure.
  • Why it's built this way: the registration comment in AddInfrastructure explains why the class is always registered rather than opted into (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:301-305): everything that reads it treats an unresolved tenant as "no tenancy", so an always-on registration costs one object per scope and removes a whole class of "works until someone forgets the opt-in" bug. Only AddMultiTenancy(configuration) turns the mechanism itself on.
  • Where it's used: written by TenantResolutionMiddleware on the request path (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:70), and by the per-tenant background paths that create their own scope: database initialization (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:212), OutboxProcessor, OutboxCleanupService, OutboxAdministration, and AuditTrailCleanupJob. It is read by DbContextFactory for routing, by TenantSaveChangesInterceptor, and by the caching decorators through TenantCacheKey. Covered by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Context/TenantContextTests.cs, and exercised for routing by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Tenancy/DbContextFactoryTenantTests.cs.
  • Caveats / not-in-source: the class is not thread-safe, and does not need to be as a scoped service on a request path, but two threads sharing one scope and racing on SetTenant with different values is not guarded against.

ChangePasswordHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ChangePassword · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:42 · Level 8 · class (abstract)

  • What it is: the shared password-rotation workflow for an authenticated user. Load the account, reject a credential-less account, check the per-account change-password lockout, verify the current password against the stored hash (counting a miss against the lockout), hash the new one, let the aggregate apply its own invariants, and persist only if the aggregate accepted the change (ChangePasswordHandlerBase.cs:42, :65-120).

  • Depends on: IUnitOfWork, IPasswordHasher and an ILogger as primary-constructor parameters, plus a required IRefreshSessionStore, a required ILoginProtectionService and an optional TimeProvider (:42-48); it implements ICommandHandler<in TCommand, TResult> closed over Result (:48). Its two constraints are the interesting part: TUser must be an AuditableAggregateRootEntity<TIdentifierType> keyed by UserIdentifierType and implement IPasswordChangeableUser (:49), and TCommand must be an IUserScopedCommand<out TRequest> carrying a ChangePasswordRequest (:50). It also uses Error, and its audit line is a private source-generated [LoggerMessage] method, PasswordChanged (:131-132), which is why the class is declared partial (:42). Externals: System.Globalization and Microsoft.Extensions.Logging (:1-2).

  • Concept introduced: the generic template-method handler, and the two axes it is generic over. The two app Identity modules carried line-identical copies of this handler, differing only in log text (ChangePasswordHandlerBase.cs:14-18). Hoisting them needed two variation points, and each is a separate generic parameter for a separate reason. TUser varies because each app owns its own User aggregate and the framework must never reference either; the capability it needs is named by an interface constraint instead, so the base can call ChangePassword without knowing the type (IPasswordChangeableUser.cs:19). TCommand varies because the command record carries app-specific pipeline policy: ADC's ChangePasswordCommand is ICacheInvalidating (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:16, :18) and Store's is not (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:13), so a single shared record would have had to pick one behavior. The base reads the command only through IUserScopedCommand<out TRequest>, which is deliberately not ICommandWithRequest<out TRequest>: that marker also opts the command into automatic CommandRequestValidator<TCommand, TRequest> registration, which is a per-app decision (IUserScopedCommand.cs:6-11), and the class remarks record the same split (ChangePasswordHandlerBase.cs:19-24).

    [Rubric §1: SOLID] assesses open/closed and dependency inversion. The workflow is closed for modification and open for extension along exactly two declared axes plus the HandlerName hook, and every collaborator is an abstraction: unit of work, hasher, lockout service, refresh-session store, logger, and the aggregate's own capability interface.

    Concept introduced: a credential-less account has no current password to prove, so it has no change-password path either. An external-OAuth account carries an empty PasswordHash and PasswordSalt (ADR-036), and the handler rejects that case explicitly, before the lockout check and the verify, rather than letting a zero-length hash compare against anything (:78-86).

    Concept introduced: a wrong current password counts against a lockout, just as a failed sign-in does. Without it, anyone holding a live session could use this endpoint as an unthrottled password oracle. The handler therefore runs the current-password check through the same failed-attempt counter and exponential lockout the sign-in path uses (ADR-029): CheckLockoutAsync before the verify, returning its failure as-is when the key is locked (:88-93); IncrementFailedAttemptsAsync on a mismatch (:97); and ResetFailedAttemptsAsync once the password verifies (:102). The counter key is password-change:{userId}, built by the private ProtectionKey helper with the id rendered under CultureInfo.InvariantCulture (:128-129). The constructor documentation gives the two reasons it is not keyed by email: IAuthUser exposes no address, and a separate key keeps a change-password lockout from locking the owner out of sign-in (:34-40, :122-127).

    [Rubric §11: Security] assesses credential handling. Four properties are visible in the code. Repeated guesses are throttled by the lockout above before the hasher is even called (:88-93). The current password is verified before anything is written (:95), and the failure is an Unauthorized error with a stable code rather than a message that distinguishes "no such user" from "wrong password" at this layer (:82-86, :95-100). Nothing in the handler ever logs the plaintext, the hash or the salt: the success log carries only the user id (:131-132). Hashing itself is delegated to IPasswordHasher, whose contract returns a hash and a fresh salt as a tuple (IPasswordHasher.cs:11), the shape ADR-032 fixes. A successful change also revokes every live refresh session on the account through the required IRefreshSessionStore, via the shared RefreshSessionRevocation helper (:112-114, comment at :110-111), so a stolen refresh chain cannot outlive the rotation that was meant to remediate it (ADR-097). Only the timeProvider parameter defaults to null; the base caches it in a private _timeProvider field, falling back to TimeProvider.System (:52), and a caller must supply the refresh-session store and the lockout service explicitly.

    [Rubric §4: DDD] assesses whether business rules live in the domain. The handler never mutates the user's fields: it calls user.ChangePassword(newHash, newSalt) (:105) and returns whatever Result the aggregate produced, so an aggregate invariant (for example refusing rotation on a deleted account) short-circuits the save without the handler knowing the rule exists.

  • Walkthrough: two protected members, the handler method, and two private helpers.

    • Primary constructor (:42-48): unitOfWork, passwordHasher, logger, the required refreshSessions and loginProtection, and the optional timeProvider. The logger is typed as the non-generic ILogger so a subclass can pass its own ILogger<TAppHandler> and keep the log category app-specific while the message text stays in the base's own [LoggerMessage] declaration (ADC passes ILogger<ChangePasswordHandler> at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:27).
    • UnitOfWork (:55): a protected pass-through over the captured parameter, exposed so an app subclass can enlist further aggregates in the same unit of work.
    • HandlerName (:62): protected virtual, defaulting to GetType().Name. This is the detail that made the hoist behavior-preserving: because each app keeps a subclass literally named ChangePasswordHandler, the source field on every returned error is byte-identical to what it was before the workflow moved (:57-61).
    • HandleAsync(TCommand, CancellationToken) (:65-120): null-guards the command (:69); takes the write repository via GetRepository (:71) because this path saves; loads by command.UserId (:72) and returns Error.NotFound stamped with the handler name and the aggregate type name when the account is missing (:75); rejects a credential-less account (:82-86); builds the counter key and checks the lockout (:88-93); verifies the current password, incrementing the counter and returning Error.Unauthorized("Auth.InvalidCurrentPassword", ...) on mismatch (:95-100); resets the counter (:102); hashes the new password into a (newHash, newSalt) tuple (:104); calls the aggregate (:105); and only on success saves (:108), revokes refresh sessions (:112-114) and logs PasswordChanged (:116). The aggregate's result is returned either way (:119), so a domain failure propagates unchanged.
    • ProtectionKey(UserIdentifierType) (:128-129): private static, the password-change: counter key described above.
    • PasswordChanged(ILogger, UserIdentifierType) (:131-132): the source-generated Information-level audit line, "User {UserId} password changed".
  • Why it's built this way: the two apps had drifted into identical code, and identical code in two places is where behavior silently diverges. Hoisting it once, with app variation expressed as generic parameters and one virtual hook, is the standing preference for reusable infrastructure in this workspace. Keeping the command record app-side is not a compromise but the correct boundary: the record is where CQRS pipeline policy is declared, and that policy is genuinely per app (ChangePasswordHandlerBase.cs:19-24).

  • Where it's used: subclassed once per app, each subclass empty apart from the constructor forwarding, and both forward the lockout service (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:24, :30; MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:28, :34). Both subclasses are picked up as scoped command handlers by ScanModuleApplicationServices<TAssemblyMarker>() (see DependencyInjection) and are then wrapped by the decorator pipeline. The workflow is pinned directly by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePasswordHandlerBaseTests.cs:18, which drives it through a test double subclass that also takes an ILoginProtectionService (:254-260).

  • Caveats: new-password strength is not checked here. Both apps' commands additionally implement ICommandWithRequest<ChangePasswordRequest> (ChangePasswordCommand.cs:15 in ADC, :13 in Store), which routes the payload through the Validating decorator before the handler runs, so the base can assume a syntactically valid request. The lockout counter is reset as soon as the current password verifies (:102), before the aggregate is asked to accept the new one (:105), so an aggregate rejection after a correct password still clears the count. Neither app's command implements ITransactional, so the single SaveChangesAsync at :108 is the whole atomic unit for the aggregate write; the lockout counter is written through ILoginProtectionService, outside it.


ChangePreferencesHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ChangePreferences · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:23 · Level 8 · class (abstract)

  • What it is: the shared preference-write workflow. Load the account, merge the partial request over the stored values, let the aggregate apply its invariants, and persist on success (ChangePreferencesHandlerBase.cs:23, :40-63).

  • Depends on: IUnitOfWork and an ILogger (:23-25); implements ICommandHandler<in TCommand, TResult> over Result (:25). Constraints: TUser is an AuditableAggregateRootEntity<TIdentifierType> implementing IUserPreferences (:26), and TCommand is an IUserScopedCommand<out TRequest> carrying a ChangePreferencesRequest (:27). Also uses Error, and logs through a private source-generated [LoggerMessage] method, PreferencesChanged (:65-66), hence the partial declaration (:23). Externals: Microsoft.Extensions.Logging (:1).

  • Concept introduced: null means "leave alone", and where that rule is enforced. The request record is nullable in both fields on purpose: the app-bar culture switcher sends only a culture and the theme toggle sends only a theme (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ChangePreferencesRequest.cs:3-10). A naive handler that passed both fields straight to the aggregate would clear whichever one the caller omitted. The merge therefore happens in exactly one place, at the call into the aggregate: command.Request.Culture ?? user.PreferredCulture and the matching line for the theme (:53-55). The domain interface documents the same contract from its side, so an aggregate author knows that UpdatePreferences always receives both values fully resolved (IUserPreferences.cs:18-25).

    [Rubric §15: Best Practices & Code Quality] assesses whether a rule has one home. Before the hoist this merge existed twice; a change to it (say, adding a third preference) had to be made in two repositories in lockstep or the apps would drift. It now has one home and one test suite.

    [Rubric §24: Forms, Validation & UX Safety] assesses whether a partial update can destroy data the user did not touch. The null-coalescing merge is the guarantee that it cannot: switching the theme never wipes a stored culture, which is exactly the failure the UI's two independent controls would otherwise produce (ADR-027 culture, ADR-028 theme).

  • Walkthrough: same shape as the password base, with fewer collaborators.

    • Primary constructor (:23-25): unitOfWork and logger; no hasher, lockout or session store, since nothing here is credential material.
    • UnitOfWork (:30) and HandlerName (:37): the same two protected members, with the same rationale (an app subclass named ChangePreferencesHandler keeps the pre-hoist error source, :32-36).
    • HandleAsync(TCommand, CancellationToken) (:40-63): null-guard (:44); write repository via GetRepository (:46); load by command.UserId (:47); Error.NotFound stamped with handler and aggregate names when missing (:50); the merged call to user.UpdatePreferences(...) (:53-55); and, only when the aggregate succeeded, SaveChangesAsync plus the PreferencesChanged log (:56-60). The aggregate's result is returned unchanged (:62).
  • Why it's built this way: identical to the rationale for ChangePasswordHandlerBase<TUser, TCommand>, and with the same asymmetry on the command record: ADC's ChangePreferencesCommand is ICacheInvalidating (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15, :18) while Store's is not (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:11-12), so the record stays app-side and only the payload record (ChangePreferencesRequest) is shared (ChangePreferencesHandlerBase.cs:16-20).

  • Where it's used: subclassed by MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:17, :20 and MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:16, :19, both empty subclasses that exist only to fix the generic arguments and preserve the class name. Invoked from UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which builds the app's command through a factory hook and returns 204 No Content on success (UserAccountAuthControllerBase.cs:126-132). Covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePreferencesHandlerBaseTests.cs:16 through a test subclass (:108-109).


ConfirmEmailHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.EmailConfirmation · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/EmailConfirmation/ConfirmEmailHandlerBase.cs:33 · Level 8 · class (abstract)

  • What it is: the shared complete-an-email-confirmation workflow: redeem the single-use token, resolve the account it was issued to, treat an already-confirmed address as a success rather than a re-confirmation, and otherwise confirm and persist (ConfirmEmailHandlerBase.cs:33, :50-93).

  • Depends on: IUnitOfWork, IEmailConfirmationTokenService and an ILogger (:33-36); implements ICommandHandler<in TCommand, TResult> over Result (:36). Constraints: TUser is an AuditableAggregateRootEntity<TIdentifierType> implementing IEmailConfirmableUser (:37), and TCommand is an ICommandWithRequest<out TRequest> carrying a ConfirmEmailRequest (:38). Also uses EmailConfirmationErrors, and logs through two private source-generated [LoggerMessage] methods, EmailConfirmed (:95-96) and EmailConfirmationRejected (:100-101), hence the partial declaration (:33).

  • Concept introduced: already-confirmed is a success that writes nothing. A confirmation link opened twice, whether by a mail-client link prefetch or a user's double tap, is the most ordinary duplicate this feature sees, so the handler checks user.IsEmailConfirmed after resolving the account and returns success with no save when it is already true (:77-81, comment at :74-76).

    [Rubric §11: Security] assesses whether a rejected token leaks account state. Both rejection paths, an invalid or expired token and an account that no longer resolves, return the identical EmailConfirmationErrors.InvalidToken(HandlerName), differing only in a log reason string (:59-63, :68-72), so the response cannot be used to distinguish "bad token" from "account gone" the way ResetPasswordHandlerBase<TUser, TCommand> closes the same gap for password resets. The rejection log itself carries the reason and nothing else, no address and no account id, as the comment on its declaration records (:98-101).

  • Walkthrough: two protected members and one method.

    • UnitOfWork (:41): exposed for app-level extensions, same shape as the other Users bases.
    • HandlerName (:47): protected virtual, defaulting to GetType().Name.
    • HandleAsync(TCommand, CancellationToken) (:50-93): null-guard (:52); redeem the token via ValidateAndConsumeAsync(request.Email, request.Token, ...) (:56-58), failing generically on rejection (:59-63); load the resolved account through the write repository (:65-67), failing generically again if it is gone (:68-72); short-circuit on an already-confirmed address (:77-81); call user.ConfirmEmail() (:83), returning its failure untouched (:84-87); save (:89); log EmailConfirmed and return the aggregate's success result (:91-92).
  • Why it's built this way: it is the confirmation half of the same email-verification vertical whose send side is SendEmailConfirmationHandlerBase<TUser, TCommand>; the two are separate handlers, not one, because sending is anonymous and always returns success while confirming is the one call in the vertical that actually mutates the aggregate.

  • Where it's used: subclassed by ADC (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ConfirmEmail/ConfirmEmailHandler.cs:1). Covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/EmailConfirmationHandlerBaseTests.cs.

  • Caveats: the request validator, not this handler, is the only place a malformed token shape can produce a 400; everything this handler itself rejects collapses into the same generic invalid-token error.


DeleteUserHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.DeleteUser · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:62 · Level 8 · class (abstract)

  • What it is: the shared account-erasure workflow: authorize owner-or-privileged-role, soft-delete the account, run the app's tail hook, irreversibly anonymize the personal data in place, save, write the shared soft-deleted marker that revokes the account's already-issued access tokens, then drain a post-save queue (DeleteUserHandlerBase.cs:62, :80-163). It exposes one abstract member and one virtual hook.

  • Depends on: IUnitOfWork, an ICacheService and an ILogger (:62-65); implements ICommandHandler<in TCommand, TResult> over Result (:65). Constraints: TUser is an AuditableAggregateRootEntity<TIdentifierType> implementing IErasableUser (:66), and TCommand is an IUserOwnedRequest (:67), not an IUserScopedCommand<out TRequest>: this workflow needs the caller as well as the target, and carries no request payload. It also uses UserOwnershipRule, Error and SoftDeletedUserCache, and logs through two private source-generated [LoggerMessage] methods, UserErased at Information (:196-197) and SoftDeletedMarkerFailed at Warning (:199-200), hence the partial declaration (:62). Externals: Microsoft.Extensions.Logging (:1).

  • Concept introduced: anonymize-in-place erasure, and why the row survives. Soft-delete (IsDeleted = true) hides a row but keeps its personal data, so it does not by itself satisfy a GDPR/CCPA erasure request; hard-deleting the row would break cross-context scalar references and destroy the audit trail. The framework's answer is the pair IAnonymizable plus IErasableUser: keep the row, overwrite the personal fields with non-identifying placeholders, and require Anonymize() to be idempotent (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:24-30). This handler is where that policy becomes a sequence (ADR-005).

    Concept introduced: interface dispatch as a correctness device. The comment at :113-117 is one of the most instructive in the framework. An app's User may hide the base entity's Delete() with public new Result Delete() to add account-specific behavior. A hidden method is not an override, and member lookup on a generic type parameter prefers the members of its class constraint, so a bare user.Delete() inside this base would bind to AuditableBaseEntity<TIdentifierType>.Delete() and silently skip the app's version. The workflow therefore assigns the user to an IErasableUser local first and calls through the interface (:118-119), because the interface map resolves to the most derived Delete() the app type declares. The interface's own remarks record the same reasoning from the contract side (IErasableUser.cs:13-23), including the deliberate choice not to implement it on the base entity so a forgetful consumer fails the generic constraint at compile time rather than losing behavior at run time (IErasableUser.cs:26-27).

    Concept introduced: the post-save queue. OnAfterSoftDeleteAsync receives an ICollection<Func<CancellationToken, Task>> afterCommit (:192). Work that must not happen unless the erasure is actually saved (deleting a blob, notifying another system) is enqueued rather than run inline, and the base drains the queue in order after SaveChangesAsync (:155-158). The subtle benefit named in the docs is that the override can hand values it captured before anonymization to a post-save closure without parking them in mutable handler state (:49-51), which matters because handlers are scoped and a field would be a shared mutable across the whole request. The class summary is precise about what "after" means: under an ITransactional command the save is not the commit, so the marker and every afterCommit action run before the transaction commits, and only idempotent, retry-safe work belongs there; anything that must survive with the erasure should be scheduled as an internal command inside the unit instead (:18-21, repeated on the parameter at :179-186).

    Concept introduced: token revocation as part of the shared workflow, best effort and first. Between the save and the queued actions the base writes the shared soft-deleted marker through SoftDeletedUserCache.MarkDeletedAsync (:144-153), which is what stops an erased account's already-issued access token from working before the next database lookup would notice (ADR-047). Two decisions are documented on the class and are worth reading as a pair (:24-39). It is best effort: the erasure is already saved, so a cache fault must not turn a successful, irreversible deletion into a failure the caller would retry, and the write is wrapped in a try/catch (excluding OperationCanceledException) that logs SoftDeletedMarkerFailed and continues (:150-153). The warning text states the consequence precisely: the Identity host still rejects the deleted user's access token, but a host without the account-status query accepts it until it expires (:199-200). And it runs before the queued actions, because that tail is unbounded app work (deleting a blob, calling storage) and every second it takes is a second the deleted account's token still works. Owning the marker here rather than in each app's tail is what gives both apps the identical revocation window; the hook's own docs tell an override not to re-stamp it (:185-186).

    [Rubric §30: Compliance, Privacy & Data Governance] assesses whether a data-subject erasure request is actually satisfiable. The sequence here is the mechanism behind both apps' published erasure promise: soft-delete, then irreversible anonymization, persisted in one save (:12-21).

    [Rubric §11: Security] assesses authorization placement. The very first thing the method does, before it touches the repository, is the ownership check (:86-96), so an unauthorized caller cannot even confirm that an account id exists. The privileged-role test is passed in already evaluated because each app owns its own role vocabulary (UserOwnershipRule.cs:15-19).

    [Rubric §1: SOLID] assesses the template-method shape. The invariant order (authorize, load, delete, tail, anonymize, save, revoke, queued actions) is fixed by the base; only the two hooks vary.

  • Walkthrough: two protected members, the handler method, two hooks and two log methods.

    • Primary constructor (:62-65): unitOfWork, cacheService, logger. UnitOfWork (:70) is protected specifically so a tail hook can reach further aggregates; the cache is not exposed, because the only write the base makes through it is the marker.
    • HandlerName (:77): the same GetType().Name default that preserves the pre-hoist error source (:72-76).
    • HandleAsync(TCommand, CancellationToken) (:80-163):
      • Authorization first (:86-92) through UserOwnershipRule.CheckOwnership (UserOwnershipRule.cs:38), with the code "User.DeleteForbidden" and a message the caller sees; a non-null return is the failure (:93-96).
      • Load through the write repository (:98-99); Error.NotFound when absent (:102).
      • IErasableUser erasable = user; erasable.Delete() (:118-119) with the dispatch rationale above; a failure returns immediately (:120-123). The comment there also records why outstanding refresh sessions are not revoked at this point: the refresh flow re-fetches the user through the same soft-delete query filter, so an erased account's sessions stop working the moment this commits, and an app that also wants the rows tidied revokes them from its tail via IRefreshSessionStore in the same unit of work (:107-111).
      • Allocate the afterCommit list and call OnAfterSoftDeleteAsync (:125-126); a failed tail aborts before anything is persisted (:127-130).
      • erasable.Anonymize() (:133), also short-circuiting on failure (:134-137).
      • SaveChangesAsync (:139), then the best-effort marker write (:144-153), then the queued actions drained in order (:155-158), then the UserErased log (:160) and Result.Success() (:162).
    • HasDeletePrivilege(string? currentUserRole) (:171): protected abstract. Deliberately abstract rather than virtual-defaulting-to-false, so adopting the base forces an explicit answer about which role bypasses ownership; its XML doc names UserRole.IsAdmin(...) only as an example of "whatever the app's privileged role is" (:165-170).
    • OnAfterSoftDeleteAsync(user, command, afterCommit, cancellationToken) (:189-194): protected virtual, defaulting to Task.FromResult(Result.Success()); returning a failure aborts the erasure before anything is persisted (:173-175). Its position is load-bearing and documented: it runs after Delete() and before Anonymize() (:45-48), which is the only point where an override can both read personal data that anonymization is about to erase and enlist further aggregates in the same unit of work. Running it after Delete() rather than before means an already-deleted account fails with its own AlreadyDeleted error rather than one raised by a cascaded aggregate (:54-58).
  • Why it's built this way: the hook contract was derived from what the two apps actually needed, and both uses are visible in their overrides. ADC's override (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:43-74) captures the avatar blob name before anonymization clears the URL (DeleteUserHandler.cs:51), raises the cross-service UserDeleted event on the aggregate so its outbox row is written by the very save that commits the erasure (:59), and schedules the avatar blob deletion as a durable internal command through IInternalCommandScheduler inside the erasure transaction rather than queueing it on afterCommit, so an erasure that persists can never lose the instruction to delete the photo (:61-71, :76-86); a scheduling failure fails the erasure (:82-85). Store instead cascades in the same unit of work, erasing the linked Customer that holds its name/email/address PII and returning the Customer's own failure untouched so nothing is persisted (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:41-65). One hook covers both because it can do work inline and schedule work for after the save.

  • Where it's used: subclassed once per app, each supplying its own privileged role to HasDeletePrivilege (MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:29, :35, with HasDeletePrivilege returning UserRole.IsOrganizer(...) at :39-40; MMCA.Store/.../DeleteUser/DeleteUserHandler.cs:25, :29, with UserRole.IsAdmin(...) at :32-33). Both subclasses take the ICacheService purely to hand it to this base. Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:17, whose fixture user type is TestHidingDeleteUser (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:103) precisely so the hidden-Delete() dispatch rule above is a regression test rather than a comment.

  • Caveats: queued afterCommit actions run after the erasure has been saved, so each one owns its own failure handling; the base does not wrap them (:155-158, and see the documented expectation at :179-186). Under an ITransactional command they, and the marker, run before the commit (:18-21). The one exception to "not wrapped" is the marker write the base performs itself, which it does wrap (:144-153). Both apps' DeleteUserCommand records are ICacheInvalidating, so the cache prefix they carry is invalidated by the decorator after the handler returns success, outside this class.


ForgotPasswordHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ForgotPassword · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:36 · Level 8 · class (abstract)

  • What it is: the shared start-a-password-reset workflow: parse the submitted address, resolve the account behind it, mint a single-use token, and email it. Every outcome returns Result.Success() (ForgotPasswordHandlerBase.cs:36, :52-101).

  • Depends on: IUnitOfWork, IPasswordResetTokenService, IEmailSender, IOptions<PasswordResetSettings> and an ILogger (:36-41); implements ICommandHandler<in TCommand, TResult> over Result (:41). Constraints: TUser is only an AuditableAggregateRootEntity<TIdentifierType> keyed by UserIdentifierType (:42) with no capability interface at all, because the workflow reads nothing off the aggregate except Id (:73), and TCommand is an ICommandWithRequest<out TRequest> carrying a ForgotPasswordRequest (:43). It also uses the Email value object (:58), and logs through three private source-generated [LoggerMessage] methods (:157-166), hence the partial declaration (:36). Externals: System.Globalization, System.Net.WebUtility, Microsoft.Extensions.Logging and Microsoft.Extensions.Options (:1-4).

  • Concept introduced: the success-always handler, and anti-enumeration as a return-type decision. Every other command base in this family reports its failures. This one cannot. A response that differs between "we sent you a reset link" and "no such account" is an account-enumeration oracle: anyone can walk an address list and learn which addresses are registered. So the four ways this workflow can fail to send anything all return Result.Success() and differ only in a log line: a malformed address (:59-63), an address with no account (:67-71), a request the token service throttled (:74-78), and an email send that threw (:91-97). The class remarks state the rule outright and name the one exception: only the request validator can produce a 400, and it inspects the shape of the address alone (:21-26, MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11-16).

    [Rubric §11: Security] assesses whether a public endpoint leaks facts about who holds an account. The leak surface is wider than the HTTP response, and the code closes it in three places. The result is uniform (:62, :70, :77, :96). The controller turns every one of them into the same 202 Accepted (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:82-93). And the rejection log, PasswordResetRejected, deliberately carries a reason string but no address and no account id, so the log does not become the oracle the response is not (:163-166, the comment at :163-164 says so). Only the paths that already proved an account exists log a user id: PasswordResetRequested (:157-158) and the Warning-level PasswordResetEmailFailed (:160-161).

    [Rubric §29: Resilience & Business Continuity] assesses what happens when a dependency fails mid-workflow. A send failure is caught, logged with the exception, and swallowed (:91-97); the token has already been issued and is still valid, so the user can retry or use the link from a later request. The catch filter excludes OperationCanceledException (:91) so a cancelled request is not misreported as a delivered reset.

    [Rubric §3: Clean Architecture] assesses dependency direction. The workflow lives in the Application layer and reaches the SMTP relay, the token cache and the database only through interfaces; the one thing it genuinely cannot express in the framework, an address-to-account lookup over an app-owned User aggregate, is the single abstract member (:110).

  • Walkthrough: two protected properties, the handler method, four hooks and three log methods.

    • Primary constructor (:36-41): unitOfWork, tokenService, emailSender, settings, logger.
    • UnitOfWork (:46): exposed so the lookup override can reach a read repository. Both apps use it for exactly that.
    • Settings (:49): settings.Value, unwrapped once so the body reads Settings.TokenLifetimeMinutes rather than settings.Value....
    • HandleAsync(TCommand, CancellationToken) (:52-101): null-guard (:56); Email.Create on the raw string, so a malformed address never reaches the lookup (:58-63); FindUntrackedByEmailAsync (:66); tokenService.IssueAsync(email.Value, user.Id, ...) (:73), whose failure means the per-email throttle fired; then the send, composed from the three Compose* hooks and sent as HTML (:84-89); and finally the PasswordResetRequested log and success (:99-100).
    • FindUntrackedByEmailAsync(Email, CancellationToken) (:110): protected abstract. The only app-specific step, because each app's User stores the address differently.
    • ComposeSubject() (:114): protected virtual, "Reset your password". Override to localize or rebrand.
    • ComposeBody(string? resetLink, string token) (:124-135): protected virtual. It carries the link and the raw token, because clients without deep linking (the MAUI head) need the token typed into the reset page by hand (:116-120). Both the link and the token go through WebUtility.HtmlEncode before interpolation into the HTML (:129, :133), and the expiry is rendered with CultureInfo.InvariantCulture (:126).
    • ComposeResetLink(string email, string token) (:152-155): protected virtual. Returns null when PasswordResetSettings.ResetUrl is blank, so an unconfigured host degrades to a token-only email rather than emailing a broken link; otherwise it appends #email=...&token=... with both values Uri.EscapeDataString-encoded, as a URL fragment rather than a query string.
    • PasswordResetRequested, PasswordResetEmailFailed and PasswordResetRejected (:157-166): private static partial, the three source-generated audit lines described under Security.

    Concept introduced: the live token rides in the URL fragment, not the query string. A fragment is never sent to a server, so putting the address and the single-use token there keeps them out of ingress access logs, out of request telemetry (url.query is exported unredacted), and out of any Referer header the reset page emits; the page itself reads the values from location.hash and scrubs them from the address bar (:145-155, remarks on ComposeResetLink).

  • Why it's built this way: the reset token is deliberately not a database row. It lives in the distributed cache, hashed at rest, with the per-email request throttle and the per-token attempt cap enforced by the token service rather than by this handler (ADR-091; MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:58, :71, :140). That split is why the handler's only reaction to a throttled request is a log line: it never learns which limit fired. The command record stays app-side for the same reason it does in the ChangePassword hoist, and the base reads it only through ICommandWithRequest<ForgotPasswordRequest> (:27-32).

  • Where it's used: subclassed once per app, each override implementing the address lookup as an untracked GetAllAsync filtered on the Email value object (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:21, :26, :29-37; MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:21, :27, :34-46). Reached over HTTP through PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, whose POST forgot-password action is [AllowAnonymous], rate-limited by the auth-IP policy and [Idempotent] (PasswordResetAuthControllerBase.cs:75-93). Pinned by six tests in MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:21 through a test subclass (:190-195), one per rejection path plus the unconfigured-ResetUrl degradation (:27, :42, :57, :79, :92, :108).

  • Caveats: the anonymous command carries no user identifier, which is why it implements ICommandWithRequest<out TRequest> rather than IUserScopedCommand<out TRequest> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12-13). Nothing in this workflow writes to the database, so it never calls SaveChangesAsync; the unit of work is present only to hand the subclass a read repository.


ResetPasswordHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ResetPassword · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:41 · Level 8 · class (abstract)

  • What it is: the shared complete-a-password-reset workflow: redeem the single-use token, hash the new password, let the aggregate apply its invariants, persist, then clear the account's lockout so the user can sign in immediately with the new credential (ResetPasswordHandlerBase.cs:41, :65-114).

  • Depends on: IUnitOfWork, IPasswordHasher, IPasswordResetTokenService, ILoginProtectionService and an ILogger, plus a required IRefreshSessionStore and an optional TimeProvider (:41-48); implements ICommandHandler<in TCommand, TResult> over Result (:48). Constraints: TUser is an AuditableAggregateRootEntity<TIdentifierType> implementing IPasswordChangeableUser (:49), the same capability ChangePasswordHandlerBase<TUser, TCommand> requires, and TCommand is an ICommandWithRequest<out TRequest> carrying a ResetPasswordRequest (:50). Also uses Error, and logs through two private source-generated [LoggerMessage] methods, PasswordResetCompleted (:122-123) and PasswordResetRejected (:127-128), hence the partial declaration (:41). Externals: Microsoft.Extensions.Logging.

  • Concept introduced: burn the token before the write, not after. The token is consumed at the top of the method, before anything is saved (:76-78), and the comment explains the trade: leaving it live until the write succeeds opens a replay window in which the same token redeems twice, while burning it early costs a user whose aggregate then rejects the change one extra reset request (:73-75). Choosing the second cost is the security-over-convenience call, and it is pinned by its own test, HandleAsync_ConsumesTheTokenBeforeSaving (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:113).

    Concept introduced: one error for every rejection. Unlike the authenticated change-password path, which can afford a specific Auth.InvalidCurrentPassword, this anonymous endpoint collapses an unknown token, an expired token, a mismatched token, an attempt-capped token and a vanished account into a single Auth.InvalidResetToken (:116-120, produced at :82 and :91). The private InvalidToken() factory exists so there is exactly one construction site and no way for a future edit to make two branches distinguishable by accident.

    [Rubric §11: Security] assesses whether an anonymous endpoint leaks account state. Two mechanisms do the work here: the uniform error above, and a rejection log that names only a reason string, never an address or an account id (:81, :90, and the declaration with its comment at :125-128). The matching controller action turns every failure into the same 401 (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:99-118).

    [Rubric §29: Resilience & Business Continuity] assesses whether a user can recover unaided. The final ResetFailedAttemptsAsync call (:110) is the part that makes a reset actually usable: a user who reset the password because the brute-force lockout locked them out would otherwise still be locked out with a brand-new credential (ADR-029; ILoginProtectionService.cs:33).

    Concept introduced: a reset is the remediation for a suspected intruder, so the intruder's session has to die with the old password. After saving, the handler revokes every live refresh session on the account through the required IRefreshSessionStore, using the shared RefreshSessionRevocation helper (:105-107, comment at :103-104), the same mechanism ChangePasswordHandlerBase<TUser, TCommand> uses (ADR-097). Only the timeProvider parameter defaults to null; the base caches it in a private _timeProvider field, falling back to TimeProvider.System (:52), and a caller must supply the refresh-session store explicitly.

    [Rubric §4: DDD] assesses whether the rules live in the domain. As with the change-password base, the handler hashes and then calls user.ChangePassword(newHash, newSalt) (:95), returning the aggregate's own result on failure without saving or clearing the lockout (:96-99).

  • Walkthrough: two protected members, the handler method, one private helper and two log methods.

    • Primary constructor (:41-48): unitOfWork, passwordHasher, tokenService, loginProtection, logger, the required refreshSessions, and the optional timeProvider. Seven collaborators, one more than ChangePasswordHandlerBase<TUser, TCommand>, because a reset touches the token store, the hasher, the database, the refresh session store and the lockout store in one pass.
    • UnitOfWork (:55) and HandlerName (:62): the same two protected members as the other bases, with the same rationale (an app subclass named ResetPasswordHandler reports that name as the error source, :57-61).
    • HandleAsync(TCommand, CancellationToken) (:65-114): null-guard (:69); redeem the token via ValidateAndConsumeAsync(request.Email, request.Token, ...) (:76-78) and fail generically on rejection (:79-83); take the account id the token resolved to (:85) and load it through the write repository (:86-87), failing with the same generic error if it is gone (:88-92); hash the new password (:94) and call the aggregate (:95); SaveChangesAsync (:101); revoke refresh sessions (:105-107); clear the lockout (:110); log PasswordResetCompleted and return the aggregate's success result (:112-113).
    • InvalidToken() (:116-120): the single Error.Unauthorized("Auth.InvalidResetToken", ...) construction, stamped with HandlerName.
    • PasswordResetCompleted and PasswordResetRejected (:122-128): private static partial, the source-generated audit lines.
  • Why it's built this way: the reset half of the recovery vertical had to share the change-password hoist's shape (generic in the aggregate, generic in the command, one virtual HandlerName) so that both credential-write paths report errors identically and neither app has to restate the workflow (ADR-091). The one ordering decision it owns, consuming before saving, is documented in the code rather than left to be rediscovered (:73-75). New-password strength is not re-checked here because ResetPasswordRequestValidator includes the same StrongPasswordRules<T> set the registration and change-password requests use, so a reset cannot be a way around the complexity policy (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12-23).

  • Where it's used: subclassed once per app as an empty, name-preserving class (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:24, :24-29; MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:20, :26-31). Reached over HTTP through the POST reset-password action on PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (PasswordResetAuthControllerBase.cs:99-118), which answers 204 No Content on success. Pinned by five tests in MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:20 through a test subclass (:176-181), covering both generic-error paths, the happy path, the aggregate rejection and the consume-before-save ordering (:28, :48, :64, :85, :111).

  • Caveats: the two apps differ on cache policy exactly as they do for change-password: ADC's ResetPasswordCommand is ICacheInvalidating with a prefix built from its own User type (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:16, :18) and Store's is not, which is the reason the command record stays app-side. The lockout clear at :110 runs after the save and refresh-session revocation, and is not part of the transaction: if it throws, the password has already changed. It clears the counter keyed by the request's email address (request.Email, :110), which is the sign-in counter; the separate password-change:{userId} counter that ChangePasswordHandlerBase<TUser, TCommand> keeps is not touched here. Not determinable from source: whether any deployment configures a ResetFailedAttemptsAsync implementation that can fail in a way the caller would notice, since the contract returns a bare Task with no result (ILoginProtectionService.cs:33).


SendEmailConfirmationHandlerBase<TUser, TCommand>

MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.EmailConfirmation · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/EmailConfirmation/SendEmailConfirmationHandlerBase.cs:40 · Level 8 · class (abstract)

  • What it is: the shared start-email-confirmation workflow: parse the submitted address, resolve the account behind it, skip an already-confirmed address, mint a single-use token, and email it. Every outcome returns Result.Success() (SendEmailConfirmationHandlerBase.cs:40, :56-111), the same success-always shape as ForgotPasswordHandlerBase<TUser, TCommand>.

  • Depends on: IUnitOfWork, IEmailConfirmationTokenService, IEmailSender, IOptions<EmailConfirmationSettings> and an ILogger (:40-45); implements ICommandHandler<in TCommand, TResult> over Result (:45). TUser is only an AuditableAggregateRootEntity<TIdentifierType> keyed by UserIdentifierType (:46), with no capability interface required on the constraint itself, and TCommand is an ICommandWithRequest<out TRequest> carrying a SendEmailConfirmationRequest (:47). Also uses the Email value object, and logs through three private source-generated [LoggerMessage] methods (:176-185), hence the partial declaration (:40).

  • Concept introduced: an optional capability check with a safe default. Unlike ConfirmEmailHandlerBase<TUser, TCommand>, which constrains TUser to IEmailConfirmableUser, this base does not: its IsAlreadyConfirmed hook pattern-matches the aggregate against the interface at runtime and answers false when it is not implemented (user is Domain.Auth.IEmailConfirmableUser { IsEmailConfirmed: true }, :129-130), so an app that has not adopted the confirmation contract still gets a working send workflow rather than a generic constraint it cannot satisfy.

    [Rubric §11: Security] assesses account-enumeration resistance, the same concern ForgotPasswordHandlerBase<TUser, TCommand> closes for password resets. A malformed address (:60-65), an address with no account (:67-73), an already-confirmed address (:75-81), a throttled token request (:83-88) and a send that threw (:92-107) all return the identical Result.Success() and differ only in a log reason string. The rejection log, EmailConfirmationRejected, carries that reason and never an address or account id (:182-185); only EmailConfirmationRequested (:176-177) and the Warning-level EmailConfirmationEmailFailed (:179-180), both reached after an account resolved, log a user id.

  • Walkthrough: two protected properties, the handler method, and five hooks (mirroring ForgotPasswordHandlerBase<TUser, TCommand> member for member, plus the already-confirmed check).

    • UnitOfWork (:50) and Settings (:53).
    • HandleAsync(TCommand, CancellationToken) (:56-111): null-guard (:58); Email.Create on the raw string (:60-65); FindUntrackedByEmailAsync (:68); the already-confirmed short-circuit (:75-81); tokenService.IssueAsync (:83-88); the send, composed from the three Compose* hooks (:92-100); and the EmailConfirmationRequested log and success (:109-110).
    • FindUntrackedByEmailAsync(Email, CancellationToken) (:120): protected abstract, the one app-specific lookup.
    • IsAlreadyConfirmed(TUser) (:129-130): protected virtual, the pattern-match default described above.
    • ComposeSubject() (:134): protected virtual, "Confirm your email address".
    • ComposeBody(string?, string) (:144-155) and ComposeConfirmationLink(string, string) (:171-174): protected virtual, carrying both the link and the raw token for the same no-deep-linking (MAUI) reason as the password-reset base, and, like ForgotPasswordHandlerBase<TUser, TCommand>.ComposeResetLink, riding the address and token in the URL fragment rather than the query string for the same ingress-log and Referer reasons (:165-170).
  • Why it's built this way: it is the send half of the same email-verification vertical whose confirm side is ConfirmEmailHandlerBase<TUser, TCommand>, and it repeats the success-always shape of ForgotPasswordHandlerBase<TUser, TCommand> because both are anonymous-reachable endpoints where a differing response would be an enumeration oracle.

  • Where it's used: subclassed by ADC (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SendEmailConfirmation/SendEmailConfirmationHandler.cs). Covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/EmailConfirmationHandlerBaseTests.cs.

  • Caveats: nothing in this workflow writes to the database, so it never calls SaveChangesAsync; the unit of work is present only to hand the subclass a read repository, the same caveat ForgotPasswordHandlerBase<TUser, TCommand> carries.


ImpersonatingCurrentUserService

MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Context · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/ImpersonatingCurrentUserService.cs:20 · Level 9 · class (internal sealed)

  • What it is: the ICurrentUserService decorator that lets background work answer identity questions as the user who originally triggered it. Every member checks ScopedUserOverride.Principal first and falls through to the inner ICurrentUserService only when nothing was captured (ImpersonatingCurrentUserService.cs:20-52).
  • Depends on: ICurrentUserService inner and ScopedUserOverride as its two primary-constructor parameters (ImpersonatingCurrentUserService.cs:20-22), and implements ICurrentUserService itself, so it is a drop-in replacement wherever the interface is consumed.
  • Concept introduced, a decorator that changes the answer, not the pipeline stage. [Rubric §11, Security] assesses whether a component's identity claims are consistent with where the code is actually running. Unlike the CQRS pipeline decorators, which wrap a handler call, this decorator wraps the identity service itself: any code that resolves ICurrentUserService, whether a handler, a query-cache key builder, or an audit-field stamper, gets the captured identity automatically once AmbientOrigin.Restore has set the override, with no code path needing to know it is running outside an HTTP request.
  • Walkthrough
    • User (ImpersonatingCurrentUserService.cs:24-25) returns userOverride.Principal ?? inner.User.
    • UserId (ImpersonatingCurrentUserService.cs:27-29) reads GetUserId() off the override principal when set, else delegates to inner.UserId.
    • Role (ImpersonatingCurrentUserService.cs:31-35) reads the first ClaimTypes.Role claim off the override principal when set, else delegates to inner.Role.
    • GetClaimValue<T>(claimType) (ImpersonatingCurrentUserService.cs:38-52) delegates outright to inner.GetClaimValue<T> when there is no override principal (:41-44); otherwise it reads the claim and parses it itself. The comment at :46-48 records a real trap: claims are machine-written in the invariant culture exactly as the inner CurrentUserService reads them, so parsing under the ambient culture would misread decimal and DateTime separators.
  • Why it's built this way: ADR-114 needs internal command handlers, and anything else running on a restored scope, to see the originally scheduling user through the exact same ICurrentUserService contract handlers already depend on. A decorator over the interface, rather than a branch inside every consumer of ICurrentUserService, keeps that substitution invisible to callers.
  • Where it's used: registered in DependencyInjection.cs (1 reference) as the decorator over the host's real ICurrentUserService. Depends on ScopedUserOverride (1 reference, the file that declares it also references this type in return). Exercised by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/InternalCommands/InternalCommandTestHarness.cs.
  • Caveats / not-in-source: not determinable from this source whether every host registers this decorator or only those that process internal commands or outbox messages; the registration site itself is the authority on when it is wired in.

CreateMigrationProofTable

MMCA.Common.Infrastructure.Tests.MigrationsFixture · MMCA.Common.Infrastructure.Tests.MigrationsFixture · MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests.MigrationsFixture/CreateMigrationProofTable.cs:24 · Level 13 · class (sealed)

  • What it is: a real, committed EF Core migration against the framework's single SQLite context. It creates one table, MigrationProof, and exists so the framework's migration-apply path can be proved against the kind of artifact a consumer would actually commit rather than against a mock (CreateMigrationProofTable.cs:24, :39-47).

  • Depends on: SqliteDbContext, named in the [DbContext] attribute (:22). Externals: EF Core's Migration base class, MigrationBuilder, and the DbContextAttribute / MigrationAttribute pair from Microsoft.EntityFrameworkCore.Migrations and .Infrastructure (:1-2). Its project takes a single ProjectReference on MMCA.Common.Infrastructure and is marked IsPackable=false (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests.MigrationsFixture/MMCA.Common.Infrastructure.Tests.MigrationsFixture.csproj:3, :6).

  • Concept introduced: a test fixture that has to live in its own assembly. EF Core does not discover migrations by convention over the whole app: it scans one nominated migrations assembly and matches the migrations in it to a context by the [DbContext] attribute each one carries (:22). The framework declares exactly one SQLite context class (ADR-006), so any migration compiled into MMCA.Common.Infrastructure.Tests would immediately be "pending" for every other test in that assembly that names it as the migrations assembly. One of those tests, DbContextFactoryMigrationTargetTests, rests its whole argument on the test assembly declaring none. The two facts cannot coexist in one assembly, so the migration was given its own tiny library and only the tests that opt in by naming it ever see it (:12-18). The class remarks state that reasoning in the code itself.

    There is a second, smaller lesson in what the file does not contain. No .Designer.cs and no model snapshot accompany it: neither is needed to apply a migration at run time, and both exist only so dotnet ef migrations add can diff the next one (:19-21). The direct consequence shows up in Up, where the column types are written out by hand because an empty target model supplies nothing (:37-38).

    [Rubric §14: Testability] assesses whether the hard parts of the system can be exercised for real rather than asserted about. Migration application is exactly such a part: it is startup behavior against a live provider, and mocking it proves nothing. This fixture makes it testable by supplying the one input the production path needs (a committed migration in a nominated assembly) while isolating it from every other test through an assembly boundary.

    [Rubric §8: Data Architecture] assesses schema-change discipline. The two public constants are the contract this fixture offers its tests: the migration id recorded in __EFMigrationsHistory (:27) and the table name that proves the schema, not just the history row, was touched (:30). Asserting on both is what separates "EF wrote a history row" from "the schema actually changed".

    [Rubric §17: DevOps] assesses whether deployment behavior is verified rather than assumed. The "None" initialization strategy, which is what a deployed host runs, has to refuse to start when the schema is behind the code and has to name the pending migration in the failure so an operator knows what to apply. That guard can only be exercised against a genuinely pending, genuinely named migration, which is what this type provides (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:145, :168).

  • Walkthrough: two attributes, two constants, and the migration pair.

    • [DbContext(typeof(SqliteDbContext))] (:22): the binding EF uses to decide which context this migration belongs to.
    • [Migration(MigrationId)] (:23): the id EF records and reports. Passing the constant rather than a literal is what lets the tests assert on CreateMigrationProofTable.MigrationId instead of repeating the string.
    • MigrationId (:27): "20260831000001_CreateMigrationProofTable", the timestamp-prefixed form EF expects and the value written into __EFMigrationsHistory.
    • TableName (:30): "MigrationProof", the table Up creates and the evidence a test queries for.
    • Up(MigrationBuilder) (:33-48): null-guards the builder (:35), then a single CreateTable with an autoincrementing long Id carrying the Sqlite:Autoincrement annotation (:43-44), a non-null string Name (:45), and a primary key named PK_MigrationProof built from the same constant (:47). Every column type is stated explicitly for the empty-target-model reason above.
    • Down(MigrationBuilder) (:51-56): drops the table, so the migration is reversible and a test that applies it leaves nothing conceptually stranded.
  • Why it's built this way: the framework's migration story is a production concern, not a unit test. MigrateAsync() on DbContextFactory is the same call a host makes for the "Migrate" strategy (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/MigrationApplyProofTests.cs:22-26), and a proof of it needs a real migration, a real SQLite file, and a real history table. Nominating the assembly is configuration, not code: a data source points at it through DataSourceEntrySettings.SqliteMigrationsAssembly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/DataSourceEntrySettings.cs:55), which is exactly how the tests wire this fixture in (MigrationApplyProofTests.cs:41-42, :57-58).

  • Where it's used: by two test classes, in two different assemblies, for two different halves of the same behavior. MigrationApplyProofTests applies it through DbContextFactory and asserts the history row, the created table, and that nothing stays pending (MigrationApplyProofTests.cs:94-106); that asking what is pending applies nothing (:110-120); and that a second MigrateAsync over an up-to-date database is a no-op rather than a re-apply whose CREATE TABLE would collide (:124-133). DatabaseInitializationExtensionsTests uses it for the production guard in DatabaseInitializationExtensions: the "None" strategy must throw naming the pending migration and must apply nothing on the way out (DatabaseInitializationExtensionsTests.cs:154-155, :167-174). Its deliberate non-consumer is DbContextFactoryMigrationTargetTests, which needs a migrations assembly that declares nothing.

  • Caveats: the assembly is a test fixture, never shipped: IsPackable=false (MMCA.Common.Infrastructure.Tests.MigrationsFixture.csproj:3) and it is listed in MMCA.Common.slnx under Tests/ (MMCA.Common/MMCA.Common.slnx:37), so nothing in the published packages carries this migration. Because there is no model snapshot, dotnet ef migrations add cannot meaningfully extend this assembly: a second migration here would have to be hand-written the same way.


⬅ gRPC & Inter-Service Contracts • Index • Common UI Framework (MudBlazor components, theme, base pages) ➡