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 anAssemblyto a scanner without coupling to an incidental concrete class.[Rubric §1, SOLID](DIP): registration code depends on a deliberate, meaningless token rather than ontypeof(SomeRandomHandler).Assembly, so renaming or moving any real type never breaks the scan. Repeating the identicalAssemblyReferenceplusClassReferencepair in every package keeps each assembly self-describing with no cross-layer reference at all.Walkthrough: two
public static readonlyfields resolved once at type initialization.Assemblyistypeof(AssemblyReference).Assembly(MMCA.Common/Source/Core/MMCA.Common.Application/AssemblyReference.cs:7);AssemblyNameisAssembly.GetName().Namewith a?? string.Emptynull-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
AssemblyReferencefor this layer.CommonArchitectureMappins 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
IAuditTrailReaderhands 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 counterpartAuditTrailEntrylives 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 carryingPiiAttributereads as the redaction placeholder on both sides (AuditTrailEntryDTO.cs:9-11), because the interceptor that writes the row substitutesPiiRedactor.RedactedTokenforOldValueandNewValuebefore 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
initproperties on asealed record, so the type gets structural equality and immutability from the compiler. Five arerequired, 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 ofAdded/Modified/Deleted(:36), andChangedOn, the UTC instant (:45). The nullable ones each encode a real case:PropertyNameis null on the summary row of a create or delete (:23-27),OldValueandNewValueare null when there is no value on that side (:30,:33),ChangedByis null when the save carried no identity such as a background service or a seeder (:38-42), andCorrelationIdis 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
recordrather than a class means a test can compare two projections by value, and therequiredmarkers 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 implementationAuditTrailReader, which projects it directly inside the EFSelectso 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>()declareswhere TAssemblyMarker : class(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:169-170) and so doesMmcaApplicationPipelineBuilder.ScanModule<TAssemblyMarker>()(MmcaApplicationPipelineBuilder.cs:27-28).ClassReferencefills that slot without weakeningAssemblyReference'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 throughtypeof(TAssemblyMarker).Assembly(DependencyInjection.cs:171) before it hands thatAssemblyto the assembly-typed overload. The Domain copy documents the same role, "anchor type used for assembly resolution whenAssemblyReferencecannot 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
AssemblyReferencestatic (and therefore impossible to instantiate accidentally). Every module's Application assembly defines its ownClassReference, so each module scans itself by passing its local copy.Where it's used: as the
TAssemblyMarkerargument inScanModuleApplicationServices<TAssemblyMarker>(). All three ADC module composition roots callservices.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 rootAddApplication()passes the Application-layer copy toAddValidatorsFromAssemblyContaining<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
DependencyInjectionregisters into the service collection as a singleton instance the momentAddApplicationDecorators()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'sTryDecoratecan only wrap registrations that already exist (seeDependencyInjectionfor 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 markerprivatemeans no consumer can register, resolve, or fake it, so the signal cannot be forged from outside.[Rubric §14, Testability]: the same marker is whatVerifyDecoratorPipeline()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 theextension(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-lineservices.TryAddSingleton(new DecoratorPipelineSeal()). Registering an instance rather than a type means nothing is ever constructed lazily, andTryAddmeans a second call is a no-op at the descriptor level. It is called at the very end ofAddApplicationDecorators(), after all thirteenTryDecoratecalls (:145).IsPipelineSealed(:701-710): a linear scan of the collection comparingdescriptor.ServiceTypetotypeof(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 anInvalidOperationExceptionnaming the offending call and spelling out both remedies, move the call ahead ofAddApplicationDecorators()or compose the whole sequence withAddMmcaApplicationPipeline(...)(:719-723). Every entry point that can contribute a handler opens with it:AddApplicationDecoratorsitself (:117, which is what makes a second call throw),ScanModuleApplicationServices(:182),AddEntityCrud(:335),AddEntityUpdateVerb(:397),AddEntityUpdate(:448) andAddMmcaApplicationPipeline(:614).- The two deliberate non-guards:
AddCommandRequestValidator<TCommand, TRequest>()(:475) andAddApplicationProfiling()(: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;ApplicationPipelineCompositionTestspins 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 asksIsPipelineSealed, and when the answer is no it throws the distinct "the pipeline was never closed" message rather than listing every handler as undecorated.
- Declaration:
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 isprivaterather thaninternal.Where it's used: only inside
DependencyInjection.cs, by the four helpers above. Its observable effects are the exception messages tested inApplicationPipelineCompositionTests(: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 bareservices.AddScoped<ICommandHandler<...>, ...>(), still slips through; catching that is exactly whatVerifyDecoratorPipeline()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.
ModuleNamedeclares ownership andSeedAsyncreceives anIServiceProviderso the seeder can resolve whatever it needs. Implementations are auto-discovered byModuleLoaderand 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 aDbContext; a seeder that genuinely needs direct EF access implements the Infrastructure-layerIDbSeederinstead. TheIServiceProviderparameter 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 fromIModule, 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 correspondingIModule.Nameso 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
ModuleLoaderinto a case-insensitive dictionary keyed byModuleName(ModuleLoader.cs:102-105) and kept only when the matching module is enabled (ModuleLoader.cs:129-132). The actual invocation happens at host startup:DatabaseInitializationExtensionscallsmoduleLoader.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) andIdentityModuleSeeder(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) andIdentityModuleSeeder(.../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.ListDeadLettersAsynchands 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 nullablestring?andDateTime?). 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:DataSourcenames which relational source the row lives in,CommandTypeandScheduledOn/CreatedOnsay what was supposed to run and when,AttemptsandLastErrorsay why it stopped, andDeadLetteredOnsays when it stopped mattering.[Rubric §3, Clean Architecture]: declaring the record in Application, the same layer asIInternalCommandAdministration, 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 afterMaxAttemptswas 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 byIInternalCommandAdministration.ListDeadLettersAsync(IInternalCommandAdministration.cs:42), implemented byInternalCommandAdministration(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
IInternalCommandimplementation 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.Attributeonly.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.InternalCommandNameAttributebreaks that coupling: the constructor validates and stores an explicitName(:58,Validatedhelper 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) callsArgumentException.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
InternalCommandNameResolverfree 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 byInternalCommandMessage(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandMessage.cs) when persisting a scheduled row.IInternalCommandimplementations are expected to carry it, though the interface itself does not enforce that at compile time.Caveats / not-in-source: nothing in the
IInternalCommandinterface contract requires this attribute; a command with noInternalCommandNameAttributefalls back to whateverInternalCommandNameResolverdoes 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 betweenAddApplication()andAddApplicationDecorators(), which is to say everything that puts a command or query handler into the container: module assembly scans, aModuleLoaderrun, cross-service gRPC clients, broker messaging.Depends on:
DependencyInjection(it callsScanModuleApplicationServiceson the collection it holds). Externals:Microsoft.Extensions.DependencyInjection.IServiceCollectionandSystem.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 isinternal(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 ownIEndpointRouteBuilderstyle 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, constrainedwhere TAssemblyMarker : classat:28), which forwards toServices.ScanModuleApplicationServices<TAssemblyMarker>()and returnsthisfor chaining. The marker is typically the module's ownClassReference.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: aModuleLoader.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.
AddMmcaApplicationPipelinerunsAddApplication(), invokes the callback with a freshly constructed builder, and then returnsAddApplicationDecorators()(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-53andMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ApplicationPipelineCompositionTests.cs:34.Caveats / not-in-source: the MMCA.Helpdesk host uses the builder too:
AddMmcaApplicationPipelineatMMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:121, with module discovery as aRegisterstep (: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 optionalDependencieslist, aRequiresDependenciesflag, aRegistermethod that wires the module's services, and an optionalRegisterDisabledStubsmethod for the cross-module stubs used when the module is switched off.Depends on:
ApplicationSettings(Level 0, passed intoRegister); externallyMicrosoft.Extensions.DependencyInjection(IServiceCollection) andMicrosoft.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 oneRegistercall.[Rubric §7, Microservices Readiness]assesses independent deployability: modules declare dependencies by name (string), soModuleLoadercan compute a safe startup order with no compile-time reference between modules. When a dependency is disabled andRequiresDependenciesis left at itsfalsedefault, the depended-on module registers stubs throughRegisterDisabledStubsso cross-module interfaces stay resolvable, which is precisely what lets the Conference service boot withDisabledBookmarkCountServicestanding in for Engagement'sIBookmarkCountService.Walkthrough: five members, three of them with default interface implementations, so a minimal module supplies only
NameandRegister.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 thatRegistertakes anIConfigurationBuilder, not a builtIConfiguration, 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
IModulethe 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'sCatalogModule/SalesModule/IdentityModule, and Helpdesk's singleTicketsModule(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.API/TicketsModule.cs:13). Discovered, sorted, and invoked byModuleLoader; the name is additionally pinned as a fitness rule byModuleConformanceTestsBase, 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
IModuleandIModuleSeederimplementation, 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, andTopologicalSort(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 eachRegistertook, and which assemblies failed to scan or loaded only partially (those two atError,:349-353).Walkthrough
- State (
ModuleLoader.cs:18-22): three private lists,_enabledModules,_seeders,_disabledModuleNames, the first and third surfaced as the read-onlyEnabledModules(:25) andDisabledModuleNames(:28) properties. A fourth field,_stubRegistrations(:21), is a case-insensitiveDictionary<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 aninit-onlyILogger<ModuleLoader>defaulting toNullLogger<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): flattensmoduleAssembliesthroughGetTypes()inside atrywith two catches, and neither aborts the whole scan. AReflectionTypeLoadException(the shape a missing transitive reference produces) is caught first (:81-88): it joins the non-nullLoaderExceptionsmessages, logsLogAssemblyPartiallyLoadedatError, 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) logsLogAssemblyScanFailed, also atErrorand now with the exception attached, and skips that assembly. The code comment gives the reason forErrorrather thanWarning: 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-interfaceIModuleviaActivator.CreateInstance(:97-100) and everyIModuleSeederinto anOrdinalIgnoreCasedictionary keyed byModuleName(:102-105). - Per-module loop (
:110-133): for a module disabled perModulesSettings.IsModuleEnabled, it logs, snapshotsservices.Count, callsmodule.RegisterDisabledStubs(services), stores the newly appended descriptors under the module's name (:118-120), records the name, and continues. An enabled module runsValidateModuleDependenciesthenRegisterEnabledModule, 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 viaModulesSettings.IsDependencyRemote(:146-148), and throwsInvalidOperationExceptiononly if a genuinely unsatisfied dependency remains andRequiresDependenciesis true; the message spells out the three remediations, enable the module, disable this one, or add the name toModules:{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 callingmodule.Register, it adds the conventional per-module JSON configuration filesmodules.{name}.jsonand, when an environment name was supplied,modules.{name}.{environment}.json, both optional andreloadOnChange: true(:185-189); the name is lower-cased withToLowerInvariantunder a documentedCA1308suppression for the file-naming convention (:182-184). It times theRegistercall with aStopwatchand 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 callsValidateRemoteDependencyStubs(:221-233). That helper (:236) skips open generics, resolves each stub'sServiceType, 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 aRemoteDependenciesentry or a forgottenAddTypedGrpcClientwould otherwise surface as a first-request failure or a silent no-op instead of at startup (:198-211).SeedAllAsync(:266): awaits each collected seeder'sSeedAsyncin registration (that is, topological) order, withConfigureAwait(false)(:268-271).TopologicalSort(:282): buildsmodulesByName,inDegree, and a reverse-adjacencydependentsmap, allOrdinalIgnoreCase(: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 aQueue<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 throwsInvalidOperationExceptionnaming them (:324-329).
- State (
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 aModuleHostContextwhoseRegisterModulesstep is the actualDiscoverAndRegistercall (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 callsDiscoverAndRegisterinside aRegisterstep of itsAddMmcaApplicationPipelinecall, namingtypeof(TicketsModule).Assemblyas the one assembly to scan (:125-133). Seeding runs later, fromDatabaseInitializationExtensions(DatabaseInitializationExtensions.cs:137).Caveats / not-in-source:
ValidateRemoteDependencieshas no production caller today. It is exercised only byModuleLoaderTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Modules/ModuleLoaderTests.cs:139,:151,:166); noProgram.csin this workspace calls it afterbuilder.Build(), so a mis-declaredRemoteDependenciesentry 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
IInternalCommandSchedulerinstead of executing immediately on the caller's own request.Depends on:
ICommand<Result>(group-05), so anIInternalCommandis, by construction, also an ordinary CQRS command that resolves anICommandHandler<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: anIInternalCommandscheduled throughIInternalCommandScheduler.ScheduleAsyncis durable, a row in theInternalCommandstable, 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 narrowsICommand<Result>, the framework does not need a second handler abstraction or a second decorator pipeline for deferred work;InternalCommandProcessorresolves and invokesICommandHandler<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 plainResult(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'scommandparameter (IInternalCommandScheduler) and onInternalCommandDispatcher's resolution ofICommandHandler<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 exampleSendEmailConfirmationCommand(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 ofInternalCommandNameResolver, 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 ofListDeadLettersAsync's result); BCL (Guid,TimeSpan,IReadOnlyCollection<Guid>); the framework'sResult<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 fromIInternalCommandSchedulerrather 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 optionaldataSourcefilter (nullmeans 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 ofInternalCommandDeadLetterrows.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 keepingLastError(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.idsnull 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
dataSourcefilter 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 inMMCA.Common.Infrastructure'sDependencyInjection.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
IInternalCommandwhose only payload is the name of the blob to delete.Depends on:
IInternalCommand(Level 3), so any implementation is also schedulable throughIInternalCommandSchedulerand 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 letsDeleteBlobInternalCommandHandlerBase<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), narrowingIInternalCommandwith onestring 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 : IDeleteBlobInternalCommandwith 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) andDeleteAvatarBlobInternalCommand(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommand.cs), each paired with a handler that derives fromDeleteBlobInternalCommandHandlerBase<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
IFileStorageServiceimplementation 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, theTCommandconstraint),ICommandHandler<TCommand, Result>(the contract it implements),IFileStorageService(constructor dependency,DeleteAsync) andErrorType(NotFound, tested against the failure result). Externally,Microsoft.Extensions.Loggingfor 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) checksresult.Errors.Any(e => e.Type == ErrorType.NotFound)(:53) and returnsResult.Success()in that case (:56) instead of propagating the storage failure.[Rubric §15, Best Practices & Code Quality]: the class isabstract partial, generic overTCommand, 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;DeleteSessionAssetBlobInternalCommandHandlerandDeleteAvatarBlobInternalCommandHandlerboth derive from it with no additional logic of their own.Walkthrough: a primary-constructor class taking
IFileStorageService fileStorageandILogger logger(:49-51), constrainedwhere TCommand : IDeleteBlobInternalCommand(:53).HandleAsync(TCommand command, CancellationToken cancellationToken = default)(:62) null-guards the command (:64), callsfileStorage.DeleteAsync(command.BlobName, cancellationToken)(:66), and branches three ways: success logsLogBlobDeletedand returns the successfulresult(:68-72); aNotFounderror logsLogBlobAlreadyGoneand returnsResult.Success()(:74-78); any other error logsLogBlobDeleteFailedwith the first error's message (or"Unknown error"if the error list is empty,:84) and returns the original failedresult(:80-85). The three[LoggerMessage]partial methods (:88-95) areInformation,Information, andWarningrespectively, each interpolatingBlobKindandBlobNameso 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
BlobKindoverride point is enough for each concrete handler to produce its own readable log lines without overridingHandleAsyncitself.Where it's used:
DeleteSessionAssetBlobInternalCommandHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/SessionAssets/UseCases/DeleteSessionAssetBlob/DeleteSessionAssetBlobInternalCommandHandler.cs) andDeleteAvatarBlobInternalCommandHandler(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommandHandler.cs) both derive fromDeleteBlobInternalCommandHandlerBase<TCommand>closed over their own command type.Caveats / not-in-source: the class does not itself register as an
ICommandHandler; registration happens through the ordinaryScanModuleApplicationServicesscan 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
IInternalCommandto 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-memoryTask.Delaywould be, it is a database write that must be atomic with whatever aggregate change triggered it. The XML remarks onScheduleAsync(: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 anITransactionalcommand, 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 theGuidis the scheduled row's id, the same idIInternalCommandAdministrationlater operates on.ScheduleAsync(IInternalCommand command, DateTimeOffset? runAt = null, CancellationToken cancellationToken = default)(:45-48):runAtis the earliest instant the command may run, converted to UTC;nullor 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 afterdelayhas 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
SuppressMessagefor analyzer ruleRS0026(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-
DateTimeOffsetparameter give the common "run in N minutes" case aTimeSpancall site without the caller doingDateTimeOffset.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 underMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/), and Conference'sDeleteEventHandler,DeleteSessionAssetHandler,UploadSessionAssetHandler,DeleteSessionHandler(underMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/). ADC'sSessionSelectionController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs) also takes it. Implemented byInternalCommandScheduler(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/InternalCommandScheduler.cs) and registered withTryAddScopedinMMCA.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
ITransactionalcommand 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
partialand its twelve public members are split across four files, each with its ownextension(IServiceCollection services)block:DependencyInjection.cs(:27,AddApplication(),AddApplicationDecorators(),AddApplicationProfiling(),AddMmcaApplicationPipeline(configure),VerifyDecoratorPipeline()),DependencyInjection.ModuleScanning.cs(:15,ScanModuleApplicationServices<TAssemblyMarker>()and itsAssemblyoverload),DependencyInjection.Crud.cs(:16,AddEntityCrud<...>(),AddEntityUpdateVerb<...>(),AddEntityUpdate<...>(),AddCommandRequestValidator<TCommand, TRequest>()) andDependencyInjection.Extensibility.cs(:11,AddUserDataExportSection<TSection>(),AddEventUpcaster<TSource, TTarget, TUpcaster>()). The five private helpers behind the pipeline seal,DecoratorPipelineSeal,IsPipelineSealed,SealPipeline,ThrowIfPipelineSealed,FormatUndecorated, stay inDependencyInjection.csbelow the extension block (:289-333).Depends on: the core singletons
IDomainEventDispatcher/DomainEventDispatcher,IEventUpcasterRegistry/EventUpcasterRegistry,INavigationMetadataProvider,IEntityQueryPipeline; the markerClassReference; the permission registryIPermissionRegistry/UnconfiguredPermissionRegistryand the parallel permission catalog fallbackIPermissionCatalog(also defaulted toUnconfiguredPermissionRegistry); the open-generic handler contractsICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>; the seven command decoratorsTransactionalCommandDecorator<TCommand, TResult>,TimeoutCommandDecorator<TCommand, TResult>,ValidatingCommandDecorator<TCommand, TResult>,CachingCommandDecorator<TCommand, TResult>,LoggingCommandDecorator<TCommand, TResult>,AuthorizationCommandDecorator<TCommand, TResult>,FeatureGateCommandDecorator<TCommand, TResult>; the six query decoratorsTimeoutQueryDecorator<TQuery, TResult>,ValidatingQueryDecorator<TQuery, TResult>,CachingQueryDecorator<TQuery, TResult>,LoggingQueryDecorator<TQuery, TResult>,AuthorizationQueryDecorator<TQuery, TResult>,FeatureGateQueryDecorator<TQuery, TResult>; the optionalProfilingCommandDecorator<TCommand, TResult>andProfilingQueryDecorator<TQuery, TResult>; the scanned contract familiesIDomainEventHandler<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 handlersCreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>,UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>,UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>andDeleteEntityHandler<TEntity, TIdentifierType>overUpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>; the request-validator bridgeCommandRequestValidator<TCommand, TRequest>overICommandWithRequest<out TRequest>; the upcaster contractIEventUpcaster; the export contributor contractIUserDataExportSection; the builderMmcaApplicationPipelineBuilder; and the private markerDecoratorPipelineSeal. 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'sTryDecorateapplies 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 aTryAddSingleton<IPermissionRegistry, UnconfiguredPermissionRegistry>()fallback (:123, comment at:118-122), paired with the identicalTryAddSingleton<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 withDecoratorPipelineSeal(:150), every handler-contributing entry point opens withThrowIfPipelineSealed(DependencyInjection.cs:116insideAddApplicationDecorators,DependencyInjection.ModuleScanning.cs:49,DependencyInjection.Crud.cs:80,:142,:193,DependencyInjection.cs:209insideAddMmcaApplicationPipeline), andAddMmcaApplicationPipeline(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 inDependencyInjection.ModuleScanning.cs. The marker overload (:28-30, constrainedwhere TAssemblyMarker : classat:29) just resolvestypeof(TAssemblyMarker).Assemblyand forwards to theAssemblyoverload (: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 byAddValidatorsFromAssembly(:117). After the passes, a reflection loop (:121-135) finds every type in the assembly implementingICommandWithRequest<TRequest>, constructsCommandRequestValidator<TCommand, TRequest>andIValidator<TCommand>withMakeGenericType, andTryAddTransients the pair (:131-134), so a command that embeds its own request DTO gets a bridging validator for free.TryAddis load-bearing here: an explicitIValidator<TCommand>picked up by the earlierAddValidatorsFromAssemblypass 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 inDependencyInjection.Crud.cs.AddEntityCrud<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>()(:74) replaces the three hand-written handler classes a straightforward CRUD aggregate would otherwise need, registeringCreateEntityHandler,UpdateEntityHandlerandDeleteEntityHandlerclosed 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'sTryDecoratewraps concrete service types: an openICommandHandler<,>registration would resolve completely undecorated andVerifyDecoratorPipeline()could not see it (:45-53). Second, everything isTryAdd, 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, andAddEntityUpdate<TCommand, ...>()(:187) does the same for a derived command that carries state beside the request. All three route their validator wiring throughAddCommandRequestValidator<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 inDependencyInjection.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>()thenTryAddEnumerable(ServiceDescriptor.Scoped<IUserDataExportSection, TSection>())(:41-42), and both halves matter.TryAddEnumerablede-duplicates by implementation type, so registering the same section twice adds it once while two different sections both survive, which a plainAddScopedwould 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, withTSourceandTTargetnamed explicitly so the compiler checks the shape at the registration site (ADR-090, rationale at:55-67).Walkthrough
AddApplication()(DependencyInjection.cs:32): fourTryAddSingletoncalls,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) andIEntityQueryPipeline(:43), thenAddValidatorsFromAssemblyContaining<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, thirteenTryDecoratecalls, seal.ScanModuleApplicationServices<TAssemblyMarker>()(DependencyInjection.ModuleScanning.cs:28, constrainedwhere TAssemblyMarker : classat:29) andScanModuleApplicationServices(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, constrainedwhere TSection : class, IUserDataExportSectionat:39) andAddEventUpcaster<TSource, TTarget, TUpcaster>()(:81): the two accumulating registrations. The export handler itself needs no registration here: apps subclassExportUserDataHandlerBase<TUser, TQuery>in their own Application assembly and the scanner picks the subclass up as anIQueryHandlerlike any other (:32-36).AddApplicationProfiling()(DependencyInjection.cs:160): optional,TryDecoratesProfilingCommandDecorator<,>andProfilingQueryDecorator<,>on top (:162-163), for use withApplicationSettings.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 newMmcaApplicationPipelineBuilder, returnAddApplicationDecorators()(:209-215). Theconfigurecallback 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 perICommandHandler<,>/IQueryHandler<,>service type (:253-269, last-registration-wins is the container's own rule), and reports every surviving entry whoseImplementationFactoryis null (:271-285). The doc explains why that test is sound:TryDecoraterewrites 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,partialDependencyInjectionclass at the composition root, so domain and Application types never reference the container. The pervasiveTryAdd*andTryDecoratepattern lets a consuming app override any framework default simply by registering its own implementation first. Each of the four files carries its ownextension(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
AddMmcaApplicationPipelinewith the module-discovery step, the cross-service gRPC clients and the broker wiring inside the callback (for exampleMMCA.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 itsAddMmcaApplicationPipelinecall (:121).ScanModuleApplicationServices<ClassReference>()is called from each module's own composition root (seeClassReferencefor 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'sIdentityModulefor 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
DependencyInjectionexist 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 atMMCA.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 atAssemblyReference.cs:1). Nothing first-party, which is why it sits at Level 0. Note that Domain is the innermost layer aboveMMCA.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 readonlyfields, both resolved once at type initialization.Assemblyistypeof(AssemblyReference).Assembly(AssemblyReference.cs:10), a self-reference so the field can never point at the wrong assembly.AssemblyNameisAssembly.GetName().Namewith a?? string.Emptyfallback (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 readonlyrather than a property means the reflection call happens once per process, and the self-referencingtypeofmakes 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 scanFromAssemblyOf<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<>).Assemblyinstead (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:22, and identically atMMCA.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'sAddValidatorsFromAssemblyContaining<T>()) is handedClassReferenceinstead. The XML doc says exactly that: an anchor for assembly resolution whenAssemblyReferencecannot 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 notsealedand notstatic, 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 nousingdirectives 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 ownICacheInvalidatingchoice 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 arecordthat already has aUserIdpositional parameter.Where it's used: extended by
IUserOwnedRequestandIUserScopedCommand<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-localExportSectionUnavailablesource-generated log message onExportUserDataHandlerBase<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.constrather thanstatic readonlybecause 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.Unavailablewhen the caller passes no reason (IUserDataExportSection.cs:99), and directly inExportUserDataHandlerBase<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) andDatabaseInitStrategy("Migrate"or"None").Depends on: nothing first-party.
System.ComponentModel.DataAnnotationsfor 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: apublic static readonly string SectionNamenaming 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 takeIOptions<ApplicationSettings>from the container or receive the bound instance by value; nothing injectsIConfigurationto 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).UseMiniProfilertakes the implicitfalsedefault (: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 intoValidateDataAnnotationson 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 atMaxPageSizeper page, soMaxExportRowsbounds the whole file and not one page (:19-23).DatabaseInitStrategy = "Migrate"(:43).Why it's built this way:
static SectionNamekeeps registration DRY, andinitimmutability makes one bound instance safe to share across the process as a singletonIOptions<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 callsAddOptions<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 onModuleHostContext.ApplicationSettings(ModuleHostContext.cs:44) and passed by value into everyIModule.Register(services, configuration, applicationSettings)call, so a module reads global settings without resolving anything. Each knob then has a distinct consumer:MaxPageSizeis resolved per request byEntityControllerBase<TEntity, TEntityDTO, TIdentifierType>throughIOptions<ApplicationSettings>with a500fallback when nothing is registered (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:59-65);MaxExportRowsthe same way, withDefaultMaxExportRows = 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);UseMiniProfilergatesMiniProfilerExtensions(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:18) and the profiling repository wrappers inRepositoryFactory(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:34,:58);DatabaseInitStrategydrives the startup switch inDatabaseInitializationExtensions(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}inappsettings.json.Enabled(defaulttrue) controls whether the module's service tree is registered;RemoteDependencieslists 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" ].ModuleLoaderthen treats that dependency as satisfied, lets the disabled module'sRegisterDisabledStubsput 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 isset, notinit, because theIConfigurationbinder needs a settable collection to populate; the resultingCA2227("collection properties should be read only") analyzer error is suppressed with an inline#pragmaplus 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
Enabledflag 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 discoveredIModulebyModuleLoaderduring composition, and enumerated directly byModuleControllerFeatureProviderwhen 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
Cacheconfiguration 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 byCachingQueryDecorator<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 theCachesection (TTL policy, key prefix) is bound in Infrastructure besideCacheSettings, 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 sameCache:PopulateLockTimeoutkey, so they cannot drift (QueryCachePipelineSettings.cs:3-14, and the reciprocal note onCacheSettingsatMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/CacheSettings.cs:18).[Rubric §12, Performance & Scalability]: the sameSectionNameplusinitshape as every other settings class here (ApplicationSettingsintroduces 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), astatic readonlyso 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
CachingQueryDecoratortake it as a constructor dependency at all, and making the fallback astatic readonlyon the settings type is what lets the decorator's optionalIOptions<>parameter degrade cleanly to framework behavior in a unit test or in a host that never calledAddCaching.Where it's used: bound by Infrastructure's
AddCachingpath, with validation when a configuration is supplied (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:48-51) and as a bareAddOptions<T>()when it is not, soIOptions<QueryCachePipelineSettings>always resolves to the framework defaults rather than failing the host (:56, comment at:33-38). Read byCachingQueryDecorator<TQuery, TResult>, which takes it as an optional constructor parameter (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:48), falls back toDefaultPopulateLockTimeoutwhen 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 byCacheSettingsTests(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:
IUserScopedRequestplus the authenticated caller. It addsCurrentUserIdand 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 theUserIdentifierTypealias.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 plusUserOwnershipRule. 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.UserIdis 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-layerDataExportControllerBase<TQuery>(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:64). It is also the parameter type ofUserOwnershipRule.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 withICacheInvalidating(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:
IUserScopedRequestplus 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 declaredout TRequest(IUserScopedCommand.cs:13), soIUserScopedCommand<DerivedRequest>is usable whereIUserScopedCommand<BaseRequest>is expected. The more instructive part is the XML doc's warning (IUserScopedCommand.cs:6-11): this interface is deliberately separate fromICommandWithRequest<out TRequest>, even though both expose aRequestproperty, becauseICommandWithRequestalso opts the command into automaticCommandRequestValidator<TCommand, TRequest>registration (seeScanModuleApplicationServicesinDependencyInjection,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 inheritedUserId.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 addICacheInvalidatingon 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) andChangePreferencesHandlerBase<TUser, TCommand>(.../ChangePreferences/ChangePreferencesHandlerBase.cs:27), and the two constraints onUserAccountAuthControllerBase<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
IUserDataExportSectionhands 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 DTOUserDataExportSectionDTOthat 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 = falseplus 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
initmembers:required string SectionName(IUserDataExportSection.cs:50),required bool Available(:53),object? Data(:60), andstring? UnavailableReason(:67). The tworequiredmembers are the ones a result is meaningless without; the two nullable ones are mutually exclusive in practice. Datais typedobject?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 withArgumentException.ThrowIfNullOrWhiteSpace(sectionName)(:75) and returnsAvailable = true. Note that anullpayload 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 toUserDataExportSectionDefaults.UnavailableReasonwhen the caller supplies none (:99).
- Four
Why it's built this way: a
sealed recordwithinitmembers 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 byExportUserDataHandlerBase<TUser, TQuery>'sRunSectionAsync, which copies its four fields into aUserDataExportSectionDTO(ExportUserDataHandlerBase.cs:177-183). Produced by every concrete section, for example Store'sSalesUserDataExportSectionreturningCompletewith 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)andIsDependencyRemote(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.jsoncan express an arbitrary map of module name to settings without a hand-written model class per module.[Rubric §7, Microservices Readiness]:IsDependencyRemoteis 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 andModuleLoadertreats the dependency as satisfied.Walkthrough:
SectionName = "Modules"(ModulesSettings.cs:10).IsModuleEnabled(:18-19) isTryGetValuefollowed bysettings.Enabled, so a module absent from configuration is treated as disabled, not enabled, which the XML doc states explicitly (:12-15).IsDependencyRemote(:30-32) doesTryGetValuefor the consumer, thensettings.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 leavingTryGetValuechains scattered through composition code.Where it's used: bound and validated alongside
ApplicationSettingsbyModuleHostExtensions.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 onModuleHostContext(ModuleHostContext.cs:47). Consumed byModuleLoaderfor both the enable check and the remote-dependency bypass (ModuleLoader.cs:112,:132,:136,:213), and byModuleControllerFeatureProviderto 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 ofAddAPI(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 theUserIdentifierTypealias. Nothing external beyondTaskandCancellationToken.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 toAvailable = falseand 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 returningUserDataExportSectionResult.Unavailableis preferred where the reason is known (:34-37).- Registration is a separate one-liner:
AddUserDataExportSection<TSection>()doesTryAddScoped<TSection>()plusTryAddEnumerable(ServiceDescriptor.Scoped<IUserDataExportSection, TSection>())(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:38-44).TryAddEnumerableis 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>intoExportUserDataHandlerBase<TUser, TQuery>(ExportUserDataHandlerBase.cs:51). Implemented byEngagementUserDataExportSectionandNotificationUserDataExportSection, registered in that order (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:53-54), and by Store'sSalesUserDataExportSection(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,ThrowingSectionandCancellingSection(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:309and: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
nullwhen allowed and a ready-madeErrorwhen not.Depends on:
IUserOwnedRequest(Level 1, the parameter type), plusErrorandErrorTypefromMMCA.Common.Shared.Abstractions(imported atUserOwnershipRule.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 abool, 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 withArgumentNullException.ThrowIfNull(request)(:45), then evaluates one conditional expression: ifrequest.CurrentUserId == request.UserId || callerHasPrivilegedRole, returnnull(allowed); otherwise returnError.Forbidden(...)with the caller-suppliedcode,message, andsource, and withtargetfixed tonameof(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 asResult.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>passesHasDeletePrivilege(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>passesHasExportPrivilege(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 ownHandlerNameas 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:
IUnitOfWorkand, through it,IReadRepository<TEntity, TIdentifierType>;IUserDataExportSection(injected as anIEnumerable);IUserOwnedRequest(theTQueryconstraint) andUserOwnershipRule;AuditableAggregateRootEntity<TIdentifierType>(theTUserconstraint);IQueryHandler<in TQuery, TResult>(the contract it implements);Result/Error; the Shared-layer DTOsUserDataExportDTOandUserDataExportSectionDTO(MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15and:61); andUserDataExportSectionDefaults. Externals:TimeProviderandILogger(both injected through the primary constructor,ExportUserDataHandlerBase.cs:50-53), plus the[LoggerMessage]source generator fromMicrosoft.Extensions.Loggingfor 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 noIQueryCacheable(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 asTimeProvider, 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-constructorabstract partialclass (partial so the logging source generator can supply the body of its private log method) takingIUnitOfWork,IEnumerable<IUserDataExportSection>,TimeProvider, andILogger, implementingIQueryHandler<TQuery, Result<UserDataExportDTO>>, withTUser : AuditableAggregateRootEntity<UserIdentifierType>andTQuery : 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.UnitOfWorkis exposedprotected(:64) purely so a subject-snapshot override can read further aggregates.HandlerNameisvirtualand defaults toGetType().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 throughUserOwnershipRule.CheckOwnershipwithHasExportPrivilege(query.CurrentUserRole)and the code"User.ExportForbidden"(:81-90), failing fast on rejection.- The read (
:92-98):unitOfWork.GetReadRepository<TUser, UserIdentifierType>()thenGetByIdAsync. 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 returnsError.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name)(:96-97). - The subject snapshot (
:100): oneawaiton the abstractBuildSubjectSnapshotAsync. - The fan-out (
:102-108): a plainforeachover the injected sections, awaited one at a time. The comment states both reasons (:102-103): sections share the scoped unit of work and itsDbContext, 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, thenResult.Success(export)(:121). RunSectionAsync(:166-199) is the degradation boundary. It calls the section, copies the four result fields into aUserDataExportSectionDTO(:177-183), and catches with the filterwhen (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-generatedExportSectionUnavailable(:190, declared at:201-202atLogLevel.Warningwith the template "Data-subject export section {Section} unavailable for user {UserId}; export continues with Available=false") and returns an envelope withAvailable = falseand the genericUserDataExportSectionDefaults.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); andOnExportCompletedAsync(...)(virtual, defaulting toTask.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).
- Shape and constraints (
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 isWarning, notError, 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 whateverILoggerthe 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
IQueryHandlerwith 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 atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:31-36). ADC'sExportUserDataHandlercloses it overUserandExportUserDataQueryand overrides only the two abstract members (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30, privilege =UserRole.IsOrganizerat:38, snapshot at:41); Store's does the same with its ownUser(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:34). Behaviour is pinned byExportUserDataHandlerBaseTests, 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, fromMMCA.Common.Application.Interfaces.Infrastructure.Auth, imported atSoftDeletedUserValidator.cs:1),IUnitOfWork(injected via primary constructor),IRepository<TEntity, TIdentifierType>(obtained from the unit of work), andAuditableAggregateRootEntity<TIdentifierType>as theTUserconstraint (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 atSoftDeletedUserValidator.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 passesignoreQueryFilters: trueto punch through the soft-delete global filter for this one predicate.[Rubric §15, Best Practices & Code Quality]: because the type is generic overTUserrather 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, constrainedwhere TUser : AuditableAggregateRootEntity<UserIdentifierType>(SoftDeletedUserValidator.cs:20-21). The single methodIsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken cancellationToken = default)(:23-25) resolves the repository throughunitOfWork.GetRepository<TUser, UserIdentifierType>()(:27), then returnsrepository.ExistsAsync(u => u.Id == userId && u.IsDeleted, ignoreQueryFilters: true, cancellationToken: cancellationToken)withConfigureAwait(false)(:30-33). Note it isGetRepositoryoff the unit of work rather than a constructor-injectedIRepository<,>, 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 againstTUserkeeps the framework free of any reference to an app'sUseraggregate while still producing a fully translated EF query.Where it's used: registered per app, closed over that app's
Useraggregate:services.TryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>()inMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:38andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:43. The consumer isSoftDeletedUserMiddleware, which resolves the interface lazily per request viacontext.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 thantypeof(SomeRandomHandler).Assemblypointing 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 readonlyfields resolved once at type initialization (AssemblyReference.cs:7-8):Assemblyviatypeof(AssemblyReference).Assembly(AssemblyReference.cs:7), andAssemblyNameviaAssembly.GetName().Namewith a?? string.Emptyfallback (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.AddInfrastructureusesFromAssemblyOf<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
AssemblyReferencein 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'sFromAssemblyOf<T>()) is handedClassReferenceinstead ofAssemblyReference.[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.AddInfrastructurecallsservices.Scan(scan => scan.FromAssemblyOf<ClassReference>()...)(DependencyInjection.cs:134-138, the anchor itself at:112) to discover everyIEntityTypeConfigurationBase<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
InProcessDistributedLockhands 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;Interlockedprovides the once-only latch,IAsyncDisposable/ValueTaskthe 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 aRelease(key)method is the scope-bound-resource shape theIDistributedLockcontract mandates, and it is what letsawait usingrelease 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
helddictionary and thekey(InProcessDistributedLock.cs:79); a singleint _releasedfield (InProcessDistributedLock.cs:81) is the latch.DisposeAsync(InProcessDistributedLock.cs:83-91) runsInterlocked.Exchange(ref _released, 1) == 0and only then callsheld.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 returnsValueTask.CompletedTask(InProcessDistributedLock.cs:90) because removing a key from aConcurrentDictionaryis 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 usingunwind 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'sIAsyncDisposable?; the APIIdempotencyFilteris 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
RedisDistributedLockacquisition. 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) andMicrosoft.Extensions.Logging;Interlockedfor the latch. Same handle shape asInProcessLockHandle, with real asynchronous work in the release.Concept: the owner-token release, the half of the
SET NX PXlock that keeps it honest (the acquire half is taught underRedisDistributedLock).[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 theIDistributedLockcontract, 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-qualifiedRedisKey, this acquisition'sRedisValuetoken, and a logger (RedisDistributedLock.cs:91-95), with the sameint _releasedlatch (RedisDistributedLock.cs:97).DisposeAsync(RedisDistributedLock.cs:99-130) returns immediately when the latch was already set (RedisDistributedLock.cs:101-104); otherwise it evaluatesReleaseScriptwith that key and token inside atry(RedisDistributedLock.cs:111-115). The script isif 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. A0result means the key was already gone or is owned by someone else now, so there is nothing to release andLogLockAlreadyExpiredwarns (RedisDistributedLock.cs:117-122, message text atRedisDistributedLock.cs:84). Any exception from the release (a Redis fault or timeout) is caught by a deliberate broadcatch (Exception)under a justifiedCA1031suppression (RedisDistributedLock.cs:124-129) and logged throughLogLockReleaseFailed(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 notesRedisTimeoutExceptionis not aRedisException, hence the broad catch.Why it's built this way: a plain
DELwould 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 mockedIDatabase, 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
UseDataSourceAttributeis 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 theDataSourcesconfiguration entries modelled byDataSourceEntrySettings/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 beforeDomain; (3) the literal"Default", the top-levelConnectionStringssection. A logical name with noDataSourcesentry (or whose connection string equals the top-level one) collapses onto theDefaultphysical 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 = trueis 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 = falseforbids 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). Nameget-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 = trueturns 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
EntityDataSourceRegistryso 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:
IConfigurationandIHostEnvironment(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 noIHostEnvironmentis at hand.Resolve(configuration, environment)(:51-66): prefers the explicitConfigurationKey(sanitized); otherwise falls back toenvironment.ApplicationName, then to the rawapplicationNameconfiguration key; otherwise returnsFallback. Never returns empty.Sanitize(name)(:75-81): strips every character theUnsafeCharactersregex 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 insideAddBrokerMessagingwhen the setting is unset (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:81-83), and referenced fromDependencyInjection.Messaging.csfor the broker endpoint formatter. Covered byMMCA.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
Smtpsection: 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.DataAnnotationsfor[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)]onPort(SmtpSettings.cs:21) means a configuration typo such as0or70000fails 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 = 25is apublic static readonlyfield (:15) used as the property's own default (:22), so the value is discoverable and testable rather than inlined.[Rubric §11, Security]:Passwordis a plainstring(:28), so it is only ever as safe as the configuration provider that supplies it (user-secrets or Key Vault, never a committedappsettings.json).Walkthrough: two static fields then eight
{ get; init; }members.SectionName = "Smtp"(SmtpSettings.cs:12) andDefaultSmtpPort = 25(:15).Host(:18),Port(:22),Username(:25),Password(:28),EnableSsl(:31, defaulting tofalse),From(:34),To(:37),TimeoutSeconds(:53,[Range(1, 600)], default30). Every string defaults tostring.Empty, so an absent section binds cleanly; onlyPortandTimeoutSecondsare range-checked.Tois documented as the default recipient used by the no-argumentSendAsyncoverload (:36-37).EnableSslno 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 plainfalsedefault because it only records what the host configured, not the resolved posture (:16-24).TimeoutSecondsexists 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.Valueinto a readonly field (SmtpEmailSender.cs:12-14), then builds a freshSmtpClientper send fromHost/Portplus aNetworkCredentialand the resolved TLS decision (:25-29), and aMailMessagefromFrom(:32-35).
Why it's built this way: annotations plus
ValidateOnStartcost 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()inAddInfrastructure(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
BackgroundServiceloop 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 injectedTimeProviderso a test can drive it with a fake clock (PeriodicBackgroundService.cs:20-102).Depends on:
Microsoft.Extensions.Hosting.BackgroundServiceas the base,TimeProviderandILoggeras its two primary-constructor parameters (PeriodicBackgroundService.cs:20-22), and theLoggerMessagesource generator for its two log methods (:97-101), which is what makes the classpartial.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 throughInterval,IsEnabled,StartupDelay,LogCycleFailure, andExecuteCycleAsync.[Rubric §29, Resilience and Business Continuity]assesses whether a background component survives its own failures: thecatchat:136-139is the whole answer, because an unhandled exception insideExecuteAsyncsilently 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:LogCycleFailureis 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 aFakeTimeProvideradvances the loop instantly, which is exactly what the tests do (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/PeriodicBackgroundServiceTests.cs:23,:66).Walkthrough
Intervalis abstract (PeriodicBackgroundService.cs:80): every subclass must state its own cadence, and nothing defaults it.StartupDelayis 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).IsEnabledis virtual and defaults totrue(PeriodicBackgroundService.cs:93). It is read once, at the top ofExecuteAsync, and afalselogs 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 generatedLogCycleError. 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 owntrywhosecatch (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 throughLogCycleFailure(: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:LogDisabledat Information,LogCycleErrorat Error with the exception attached and a message that states the recovery ("it will retry on the next interval"). Both stampGetType().Name, so the log line names the concrete sweep, not the base class;LogCycleErroris now called throughLogCycleFailurerather 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
BackgroundServicedirectly 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 aCountingSweeptest 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
IDistributedLockfor 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, fromMMCA.Common.Application.Interfaces, imported atInProcessDistributedLock.cs:4),ILogger<InProcessDistributedLock>injected through the primary constructor (InProcessDistributedLock.cs:31), and its own nestedInProcessLockHandle. 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, "noIConnectionMultiplexeris registered ... Register a Redis client (AddRedisClient) to make it exclusive across replicas").Walkthrough:
- State.
PollIntervalis 25 ms (InProcessDistributedLock.cs:34), the gap between acquisition attempts while waiting for a holder._heldis aConcurrentDictionary<string, byte>withStringComparer.Ordinal(InProcessDistributedLock.cs:36), used as a set: the value byte is a placeholder and only key presence matters._degradationWarnedis the warn-once flag (InProcessDistributedLock.cs:39). - Guards and the warning.
TryAcquireAsync(InProcessDistributedLock.cs:42-73) rejects a blank key, a non-positivettl, and a negativewait(InProcessDistributedLock.cs:48-50), then flips the flag withInterlocked.Exchange(ref _degradationWarned, 1) == 0so 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 anInProcessLockHandle(InProcessDistributedLock.cs:61-63). Otherwise, ifStopwatch.GetElapsedTime(startedAt) >= waitthe method returnsnull(InProcessDistributedLock.cs:66-69), which is what makeswait: TimeSpan.Zeroa single non-blocking attempt exactly as the contract promises. Otherwise it awaitsTask.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. ttlis 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.
- State.
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
AddCachingin the Infrastructure composition root when noIConnectionMultiplexeris resolvable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:99-101, inside theIDistributedLockfactory at:84-98; seeDependencyInjection), which covers MMCA.Helpdesk, local single-process runs, and tests. Behavior is pinned byInProcessDistributedLockTests(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-BCLUseDatabaseAttribute, it references the first-partyDataSourceenum.Depends on: the
DataSourceenum (fromMMCA.Common.Application.Interfaces.Infrastructure.Persistence, imported atUseDataSourceAttribute.cs:1) andSystem.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, whileUseDatabaseAttributeselects which database on that engine.Walkthrough:
[AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)](UseDataSourceAttribute.cs:12), same inheritance and single-use semantics asUseDatabaseAttributeso a provider base class propagates the engine to derived configurations.- Primary-constructor parameter
dataSource(UseDataSourceAttribute.cs:13) of typeDataSource. DataSourceget-only property (UseDataSourceAttribute.cs:16) exposing the chosen engine. The XML doc (UseDataSourceAttribute.cs:5-10) records that it is read byDataSourceServiceat model-building time to populate the entity-to-data-source cache thatUnitOfWorkuses to route each entity to the correctApplicationDbContext.
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 byDataSourceService/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 unsetSmtp:EnableSslkey 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]: leavingSmtp:EnableSslunset used to mean cleartext, becauseSmtpSettings.EnableSslis a plainbooldefaulting tofalse. 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
Resolveand one generated log method.Resolve(configuration, environment)(:37-49): readsSmtp:EnableSsl(EnableSslConfigKey = "Smtp:EnableSsl",:29); if it parses as a bool, that value wins; otherwise returnsenvironment?.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 isfalse, the host is not Development,settings.Hostis set, and a logger was supplied, emits oneLogCleartextSmtpwarning 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
boolis 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 theSmtpClient's TLS posture, and fromSensitiveDataLoggingGatefor the analogous decision on a different setting. Covered byMMCA.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 standardSET key token NX PX ttlRedis lock against a single Redis instance.Depends on:
IConnectionMultiplexerand the rest of StackExchange.Redis (IDatabase,RedisKey,RedisValue,RedisResult),ILogger<RedisDistributedLock>, and an optionalCacheKeyNamespace(RedisDistributedLock.cs:24-27), which is what puts this type at Level 2 rather than Level 1. It returns its nestedRedisLockHandle.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 (seeInProcessDistributedLockfor the degraded alternative).[Rubric §29, Resilience & Business Continuity]assesses failure behavior: the expiry carried on theSETis 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, seeRedisLockHandle) rather than failing the already-committed guarded work.Walkthrough:
- State.
KeyPrefixis"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 underRedisLockHandle.PollIntervalis 50 ms (RedisDistributedLock.cs:40); unlike the in-process poll, each retry here is a network round trip._keysfalls back toCacheKeyNamespace.Nonewhen no namespace was injected (RedisDistributedLock.cs:42), so theCache:KeyPrefixoption (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,ttlgreater than zero, non-negativewait(RedisDistributedLock.cs:51-53). - Key and token. The physical key is
_keys.Qualify(string.Concat(KeyPrefix, key))(RedisDistributedLock.cs:55). The token is a freshGuid.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 aRedisLockHandleclosing over the database, key, token, and logger (RedisDistributedLock.cs:70-73); onceStopwatch.GetElapsedTime(startedAt) >= waitit returnsnull(RedisDistributedLock.cs:75-78); otherwise it delays onePollIntervaland retries (RedisDistributedLock.cs:80).
- State.
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 PXlock 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
AddCachingwhenever anIConnectionMultiplexeris resolvable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Caching.cs:88-102, seeDependencyInjection), passing the sameCacheKeyNamespacethe distributed cache gets (DependencyInjection.Caching.cs:67,:91). The one in-framework caller is the APIIdempotencyFilter;RedisDistributedLockTestscovers the acquire and release commands against a mockedIDatabase.Caveats / not-in-source: whether a given deployed environment actually supplies the
redisconnection 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 registeredIConnectionMultiplexerdecides 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 classsplit across six files by concern (DependencyInjection.cs,.Auth.cs,.Caching.cs,.Jobs.cs,.Messaging.cs,.Notifications.cs), each carrying its own C# previewextension(IServiceCollection services)block, so the registration surface reads as one type while the methods live next to the settings and services they wire.DependencyInjection.csitself carriesAddInfrastructure(IConfiguration)(:56);.Caching.cscarriesAddCaching(IConfiguration?)(DependencyInjection.Caching.cs:26) andAddCommonHybridCache(Action<HybridCacheOptions>?)(:137);.Jobs.cscarriesAddScheduledJobs(IConfiguration)(DependencyInjection.Jobs.cs:37),AddScheduledJob<TJob>()(:72) andAddAuditTrail(IConfiguration)(:108);.Notifications.cscarriesAddNotificationInfrastructure()(DependencyInjection.Notifications.cs:26),AddPushNotifications(IConfiguration)(:41),AddNativePushNotifications(IConfiguration)(:82) andAddAzureBlobFileStorage(IConfiguration)(:114);.Messaging.cscarriesAddBrokerMessaging(IConfiguration, Action?)(DependencyInjection.Messaging.cs:43) andAddTypedServiceClient<TInterface, TImplementation>(string)(:141), plus five private static helpers below its extension block (:179,:202,:241,:337,:358);.Auth.cscarries the four opt-in identity completions (DependencyInjection.Auth.cs:17-186); andDependencyInjection.csitself also carriesAddMultiTenancy(IConfiguration)(:266),AddServices()(:284),AddEntityConfigurationAssembly(Assembly)(:338) andAddStronglyTypedIds(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:ICacheServicewithDistributedCacheService/MemoryCacheService/HybridCacheService,IDistributedLockwithRedisDistributedLock/InProcessDistributedLock,IJwksProvider/RsaJwksProvider,TokenService,LoginProtectionService,PasswordResetTokenService,EFRefreshSessionStorewithRefreshSessionCleanupService,EventUpcasterStartupValidator,ScheduledJobRunner,IAuditTrailReader/AuditTrailReader,TenantContext,CorrelationContext,JwtForwardingDelegatingHandler. Settings:ConnectionStringSettingswithConnectionStringSettingsValidator,DataSourcesSettings/DataSourceEntrySettings,MessageBusSettings,OutboxSettings,PersistenceSettings,SmtpSettings,JwksSettings,CacheSettings,QueryCachePipelineSettings,LoginProtectionSettings,PasswordResetSettings,RefreshSessionSettings,SchedulerSettings,AuditTrailSettings,TenancySettingswithTenancySettingsValidator,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 isTryAdd*, 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:95and: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 theirEnabledflag 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 aConditionalWeakTablekeyed by context (:61-69), includingTenantSaveChangesInterceptor, which is registered unconditionally so a host can never leave the write-side tenancy guard half-wired (:66-69). It also registersIRawSqlQueryExecutor/EFRawSqlQueryExecutorscoped (:110), the sibling ofIQueryableExecutorfor the reads LINQ cannot express, scoped rather than singleton because it reaches the scope's own context throughIDbContextFactoryso a raw statement shares the caller's connection and any open transaction; andIConcurrencyConflictDetector/EfCoreConcurrencyConflictDetectorTryAddSingleton(:120), the sibling classifier to the unique-constraint detector for a save rejected by a moved concurrency token. Near the end it calls the privateAddInternalCommandshelper (:222, ADR-114), thenAddServices()(:224), then decoratesICurrentUserServicewithImpersonatingCurrentUserServiceviaTryDecorate(:226-233), guarded on whetherScopedUserOverrideis already registered so a host whose modules each callAddInfrastructuredoes not stack one decorator per call.- Settings binding, and the one rule annotations cannot express.
ConnectionStringSettingsbinds throughAddOptions(...).ValidateDataAnnotations().ValidateOnStart()(:71-74), thenConnectionStringSettingsValidatoris added throughTryAddEnumerable(:81-82) with the reason inline (:76-80): "a host must reach some database" spans theConnectionStringssection AND theDataSourcesone, so a SQLite-only host declaring its databases as named sources is legitimate while a host declaring none anywhere is not. The sameTryAddEnumerableidiom recurs for every validator and startup check in the file, so two modules callingAddInfrastructurenever run one validation twice. - The named-data-sources note (
:84-88) is load-bearing:DataSourcesSettingsis built directly fromconfiguration.GetSection(...).Get<Dictionary<string, DataSourceEntrySettings>>()rather than throughAddOptions, 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):DbContextFactoryis scoped (one per request) andPhysicalDbContextFactoryis 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 latterTryAdded so a host on another engine can register its own first and keep it. - The Scrutor scan (
:128-132) discovers everyIEntityTypeConfigurationBase<TEntity, TIdentifierType>in the Infrastructure assembly viaFromAssemblyOf<ClassReference>()(:129) and registers each as its implemented interfaces, scoped to match the DbContext lifetime, closing the loop back toClassReference.AddCaching(configuration)is called immediately after (:134). - The auth block (
:148-175) bindsLoginProtectionSettings,PasswordResetSettingsandRefreshSessionSettingswith their scoped services, and gates one hosted service:RefreshSessionCleanupServiceis registered only whenRefreshSessions:Enabledis 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):EventUpcasterStartupValidatoris anIHostedServiceadded throughTryAddEnumerable, 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).OutboxSignalis always registered (:195). Then the message-bus section is read eagerly (:203-204), passed to the privateEnsureOutboxAvailableForProviderguard (:205), andMessageBusSettings.IsOutboxEnableddecides between the two hosted outbox services (OutboxProcessorplusOutboxCleanupService,:207-211) and a singleOutboxDisabledNoticeService(: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 theOutboxMessagestable stays mapped either way so flipping the flag is never a migration (ADR-100). The operator surfaceOutboxAdministrationis scoped, because it creates one child scope per data source it visits (:217-220). The method then calls the privateAddInternalCommandshelper (:222) andAddServices()(:224). AddCaching(DependencyInjection.Caching.cs:26-105) does two probes in one method, and itsIConfigurationparameter is optional. With configuration it binds the key-prefix options plusCacheSettingsand the Application layer'sQueryCachePipelineSettings(:41-51); without it, both are registered bare soIOptions<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 anIDistributedCacheis registered and is not the no-opMemoryDistributedCache(:62), it buildsDistributedCacheServiceover it plus anyIConnectionMultiplexer, the optionalCacheKeyNamespaceand the TTL settings (:64-73); otherwiseMemoryCacheService(:77), where the keyspace is private to the process so no prefix is needed. Both factories now resolve the namespace withCacheKeyNamespace.From(sp)(:67,:91) rather than pullingIOptions<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:RedisDistributedLockwhen a multiplexer resolves (:87-93),InProcessDistributedLockotherwise (: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 callsAddHybridCache(:139), then configuresHybridCacheOptionsthrough the options pipeline rather than theAddHybridCachecallback (:145-159), because the TTL policy now comes from the boundCachesection 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 doesRemoveAll<ICacheService>()beforeAddSingleton(:161-177) so the call wins whether it runs before or afterAddInfrastructure. The remarks are honest about the cost of that choice (:125-130):RemoveAlldoes not distinguish the framework's registration from a host's own customICacheService, so calling this is a statement that the two-level cache IS the cache.AddScheduledJobs(DependencyInjection.Jobs.cs:37-51) andAddScheduledJob<TJob>(:72-78) wire the recurring-job feature (ADR-074). The first bindsSchedulerSettingswith validation (:39-42) and registersScheduledJobRunnerviaTryAddEnumerablerather thanAddHostedService(: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 accumulatingIEnumerable<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) bindsAuditTrailSettings(: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 inAddScheduledJobskeeps 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, soAuditTrail:RetentionDaysis inert.AddMultiTenancy(DependencyInjection.cs:277-289) bindsTenancySettingsand addsTenancySettingsValidatorthroughTryAddEnumerable(:273-275). What it switches on is resolution, not isolation: the filter, the interceptor andITenantContextare always present and inert until a tenant resolves (:244-251), and aTenancy: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) bindsTwoFactorSettingsand registersITwoFactorService/TotpTwoFactorServicesingleton (pure over its arguments) andITwoFactorAuthenticator/TwoFactorAuthenticatorscoped (shares the request's unit of work); it registers noITwoFactorStoredeliberately, because the account's secret and recovery hashes belong to the consumer's ownUseraggregate.AddEmailConfirmation(configuration)(DependencyInjection.Auth.cs:68) bindsEmailConfirmationSettingsand registersIEmailConfirmationTokenService/EmailConfirmationTokenServicescoped, plusTimeProvider.SystemthroughTryAddSingleton(:79), because the token service stamps expiries from the injected clock:AddServicesregisters the same clock (DependencyInjection.cs:324), and theTryAddkeeps this call self-sufficient without displacing a host's ownTimeProvider; calling it changes nothing about sign-in until the host'sUseralso implementsIEmailConfirmableUserand setsRequireConfirmedEmail.AddLegalAcceptance(configuration)(DependencyInjection.Auth.cs:104) bindsLegalAcceptanceOptionsfrom theLegalsection (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceOptions.cs:15) and nothing else: it registers noILegalAcceptanceService, because the accepted version and instant belong to the app's ownUseraggregate, so the consumer supplies that implementation plus a controller deriving fromLegalAcceptanceControllerBase. It deliberately omitsValidateOnStart, unlike its siblings, since the singleLegal:CurrentTermsVersionsetting has no invalid value (any version string turns the feature on, null or whitespace turns it off). The options reach registration throughAuthenticationValidatorsas an optional constructor argument, so with the setting unset the call changes nothing.AddStoredPermissionGrants(configuration)(DependencyInjection.Auth.cs:139) bindsPermissionGrantSettings, registers thePermissionGrantModelGatesingleton (its presence is what tellsApplicationDbContextto map thePermissionGranttable for the one data source named byAuthentication:PermissionGrants:DataSourceName), the EF grant store, aPermissionGrantCachesingleton that answers as the read cache, the invalidator and the refreshIHostedServiceall from one instance (so an edit and the reads that follow it cannot see two different snapshots),IRoleAdministrationService/StoredPermissionRoleAdministrationService, and decorates whateverIPermissionRegistryis already registered withLayeredPermissionRegistryviaTryDecorate; it must be called AFTERAddAuthorizationPolicies()for that decoration to have anything to wrap, and falls back to registering an empty compiled registry first whenTryDecoratefinds nothing, so the stored layer still works standalone. AddServices(DependencyInjection.cs:295-340) registers the small services and encodes a subtle lifetime lesson:TokenServiceis 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 staticCryptoProviderCachestill held the cached signature provider wrapping it, throwingObjectDisposedExceptionon the next RS256 sign.[Rubric §11, Security](correct signing-key lifecycle). It also sets the defaultIEventBustoInProcessEventBus(:305) and the defaultIMessageBustoInProcessMessageBus(:311), registersITenantContextunconditionally 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 aredisconnection string exists (:51-62), and replaces the null senders (:65-66).AddNativePushNotifications(:82-102) andAddAzureBlobFileStorage(: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-126is worth copying: an empty-stringServiceUribinds to a relativeUri, so only{ IsAbsoluteUri: true }counts. AddBrokerMessaging(DependencyInjection.Messaging.cs:43-127) is the extraction pivot. It readsMessageBusSettings, falling back tonew MessageBusSettings()when the section is absent (:47-48); onInProcessit 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), callsAddMassTransit(:62-89) and thenReplaces the scopedIMessageBuswithBrokerMessageBus(:93) andIEventBuswithBrokerEventBus(:99), the deliberate exception to theTryAddrule, because the in-process bus must not run alongside the broker. Inside the MassTransit callback, unlessMessageBusSettings.PreserveDefaultEndpointNamesis set, it installs aKebabCaseEndpointNameFormatterwithincludeNamespace: false(:77-85) carrying either the configuredEndpointPrefixor, when that is unset, the resolvedApplicationNamespace(SEC-Common-53), because every service on a shared broker would otherwise derive the same queue name from the same consumer type and collide;PreserveDefaultEndpointNamesexists 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 fromsettings.IsInboxEnabled, the resolved posture rather than the raw flag (unset means ON for a broker,Messaging/MessageBusSettings.cs:141):EfInboxStorescoped when on (DependencyInjection.Messaging.cs:110), and when off the singletonNoOpInboxStoreplus anInboxDisabledWarningServicehosted 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, thenConnectionStrings:rabbitmq(what Aspire injects), thenConnectionStrings:messaging; without that fallback MassTransit would default tolocalhost:5672and never reach the Aspire-allocated container port.ConfigureBrokerTransport(:241-324) does the per-transport wiring, callingApplyBackpressure(:274,:314) right beforecfg.ConfigureEndpoints(context)on both the RabbitMQ and the Azure Service Bus branches.ApplyBackpressure(:337-348) is the new fifth helper: it appliesPrefetchCountandConcurrentMessageLimitto the bus factory configurator, range-guarding each in code (a positive check) rather than by[Range], becauseAddBrokerMessagingbinds the section withGet<MessageBusSettings>(), which never runs DataAnnotations.BuildRedeliveryIntervals(:358-361) maps the configured seconds toTimeSpans, 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 justifiedIDE0051suppression 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-backoffUseMessageRetrypolicy driven byMessageBusSettings(DependencyInjection.Messaging.cs:292-296for RabbitMQ,:309-313for Azure Service Bus). Above it sits second-levelUseDelayedRedelivery, which reschedules a message through the broker overRedeliveryIntervalsSeconds(one minute, ten minutes, one hour by default) so an outage measured in hours does not dead-letter the event; it is registered beforeUseMessageRetryso 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 therabbitmq_delayed_message_exchangeplugin that the Aspire development container does not ship, so it is gated behindEnableDelayedRedelivery, default false (:260). Right after eachUseMessageRetrycall,ApplyBackpressureappliesPrefetchCountandConcurrentMessageLimitto the same configurator beforecfg.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.IsEmulatorConnectionStringdecides betweenConfigureEmulatorHost(cfg, connectionString, settings.EmulatorAdminEndpoint)(:290-291) and the productioncfg.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 typedHttpClientto Aspire service discovery (http://{serviceName},:151-152), attachesJwtForwardingDelegatingHandler(:148,:153) so the inbound bearer token flows downstream, and adds the standard Polly resilience handler (:156); theS5332suppression at:150documents 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 inMMCA.Common/CLAUDE.md: hosts callAddApplicationDecorators()last so Scrutor can decorate handlers already registered, but the relative position ofAddInfrastructureis not otherwise ordering-sensitive, andAddCommonHybridCacheis 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 extractedMMCA.ADC.*service hosts) afterAddApplication(); 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.csfiles 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.Jsonfor serialization,System.Globalization.CultureInfofor the tag rendering, and the solution-wideUserIdentifierTypealias. 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 amessageenvelope with anotificationblock; APNs wants a reservedapsblock with analertinside 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
MaxTagsPerExpressionis the constant20(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) buildsnotificationfrom the title and body, attachesdataonly when the metadata dictionary is non-empty (:22-25), and serializes the whole thing under amessageroot (:27).BuildApnsPayload(title, body, metadata)(NativePushPayloads.cs:31-53) builds theaps.alertblock, then lifts each metadata pair to a top-level custom key, which is where APNs expects application data. The guard at:44-48is the interesting line: a metadata key literally namedapsis 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,ChunkbyMaxTagsPerExpression, and join each chunk with||. One expression per chunk, each an OR over at most 20 user tags.UserTag(userId)(NativePushPayloads.cs:66-67) rendersuser:{id}withCultureInfo.InvariantCulturethroughstring.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 callsUserTagboth when stamping an installation (AzureNotificationHubDeviceRegistrar.cs:41) and when verifying ownership before a delete (:92). Covered byMMCA.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
NativePushsection 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 withEnabledfalse 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); nullableConnectionString, documented as a Listen+Send+Manage rule (:18); nullableHubName(:21).AddNativePushNotificationsbinds 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 asSchedulerMetrics, 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.countis 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 staticMeterbuilt 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, unitmessages, tagged byevent_type.CircuitOpenCounter(:42-45),broker.circuit.open.count, same unit and tag.
Why it's built this way:
internal static readonlyinstruments 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 toFaultCounterright after logging the fault (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/FaultIntegrationEventConsumer.cs:50-52), andOutboxProcessoradds toCircuitOpenCounteron theBrokenCircuitExceptionbranch 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) andOutboxProcessorTests(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
ConfigureBrokerTransportstays a single unconditionalcfg.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'sIServiceBusBusFactoryConfigurator(ServiceBusEmulatorSupport.cs:1-4). BCL:Uri,Interlocked,CultureInfo. Its one caller isDependencyInjection's privateConfigureBrokerTransport, and its input comes fromMessageBusSettings(ConnectionStringplusEmulatorAdminEndpoint).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 carriesUseDevelopmentEmulator=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-clientsHostoverload, where the caller builds both the data-plane and the management-plane client itself (ServiceBusEmulatorSupport.cs:19-30).Walkthrough:
- The four pieces of state.
EmulatorMarkeris the literal"UseDevelopmentEmulator=true"(ServiceBusEmulatorSupport.cs:37).EmulatorEntityQuotais one hour, the ceiling the emulator enforces on entity time-to-live and auto-delete-on-idle (:42).EmulatorHostAddressissb://localhost/(:48) and the doc is careful about what it is: it names the bus for theHostoverload, not a network location, because both clients are already bound to the emulator's actual ports (:44-47)._entityQuotasAppliedis the once-per-process latch (:54). IsEmulatorConnectionString(:62-64) is the whole detection: a null-guard and aContainswithStringComparison.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 newServiceBusClientfor the data plane and a newServiceBusAdministrationClientfor the management plane (:97-100). TheCA2000suppression 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) flipsInterlocked.Exchange(ref _entityQuotasApplied, 1) != 0and returns early on the second call (:110-113), then writes three MassTransit statics down to the one-hour quota:DefaultMessageTimeToLive,BasicMessageTimeToLiveandAutoDeleteOnIdle(: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:5300is an absolute URI, withlocalhostread as the scheme and no host at all, so the check also requires the scheme to behttporhttps(:136-139). A missing or malformed endpoint throws anInvalidOperationExceptionwhose 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 theEndpoint=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).
- The four pieces of state.
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
ConfigureBrokerTransportstay 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:
ConfigureBrokerTransportcallsIsEmulatorConnectionStringand, on a match,ConfigureEmulatorHost(cfg, connectionString, settings.EmulatorAdminEndpoint)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:313-314, the productioncfg.Host(connectionString)in theelseat:295). The admin endpoint is bound fromMessageBus: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, alongsideMessageBus__Provider=AzureServiceBusand 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.AddBrokerMessagingre-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 isInProcess(:50-53).- The transport configuration then switches on the same value to pick
UsingRabbitMq(DependencyInjection.Messaging.cs:271-300) orUsingAzureServiceBus(:279-317), withInProcessas 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 whenEnableDelayedRedeliveryis set on RabbitMQ, which needs the delayed-message-exchange plugin (:188-193).
Why it's built this way: a zero-valued
InProcessmeans an absentMessageBussection 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 atMessageBusSettings.cs:17.Where it's used: MessageBusSettings.Provider (
MessageBusSettings.cs:17) and theAddBrokerMessagingbranches 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 insideIntegrationEventConsumerExtensions. It hands the fault consumer the same endpoint settings the integration-event consumer received, except that an explicitNamegets the-faultsuffix, so the two consumers never share a queue (IntegrationEventConsumerExtensions.cs:180-226). - Depends on: MassTransit's
IEndpointRegistrationConfiguratorand the privateFaultEndpointSuffixconstant,"-fault"(IntegrationEventConsumerExtensions.cs:16). The wrappedinnerconfigurator 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 exampleName,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
Namesetter writesvalue + FaultEndpointSuffixtoinner(IntegrationEventConsumerExtensions.cs:191-194).Temporary,PrefetchCount,ConcurrentMessageLimit,ConfigureConsumeTopologyandInstanceIdsetters forward unchanged (:196-215).- Both
AddConfigureEndpointCallbackoverloads forward toinner(: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
IEndpointRegistrationConfiguratorare 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.INotificationHubClientandILogger<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 anFcmV1Notificationsend and anAppleNotificationsend 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 theNativePushsection is present withEnabled: 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.CompletedTaskand 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 anINativePushSender?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) andBroadcastAsync(:17-18) are expression-bodied returns ofTask.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
AddInfrastructurethroughTryAddTransient(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:331), immediately alongside NullPushDeviceRegistrar (:580).AddNativePushNotificationsuses plainAddTransientfor 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
PushNotificationssection: 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:
NotificationScopeKeyfromMMCA.Common.Shared.Notifications(:1), for thePatternconstant that supplies the default. See NotificationScopeKey. No externals beyond a single[Range(0, 10_000)]data annotation onMaxConnectionsPerUser(PushNotificationSettings.cs:45), which is whatValidateDataAnnotations()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 callsGroups.AddToGroupAsyncis 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.ChannelKeyPatternis 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 toNotificationScopeKey.Pattern(PushNotificationSettings.cs:33, the constant"^(event|session):[0-9]+$"atNotificationScopeKey.cs:32), the SAME constant the producersNotificationScopeKey.ForEventandForSessionformat 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 coversMaxConnectionsPerUser: 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).0opts a host back out of the cap entirely.Walkthrough: one static field and four
initproperties.SectionName = "PushNotifications"(PushNotificationSettings.cs:11).Enabled(:14): plainbool, defaultfalse.HubPath(:21): defaults to"/hubs/notifications". Its doc comment (:16-20) carries a coupling: the JWT bearer handlers accept the query-stringaccess_tokenon this path (and under/hubs), and a host that moves the hub elsewhere must also setRateLimiting:HubPathPrefixes, which the rate limiter reads separately.ChannelKeyPattern(:33). Enforcement lives in NotificationHub:EnsureValidChannelKeypulls aRegexout of a staticConcurrentDictionarycache keyed on the pattern string (NotificationHub.cs:177-179) and throwsHubException("Invalid channel key.")on an empty or non-matching key (NotificationHub.cs:181-184); bothJoinChannelAsync(:46) andLeaveChannelAsync(:62) call it before touchingGroups. 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), default20;0disables the cap (PushNotificationSettings.cs:35-38). Tagged SEC-ADC-25 in the doc comment: the hub is[Authorize]but had noOnConnectedAsyncoverride, 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()inAddPushNotifications(MMCA.Common.Infrastructure/DependencyInjection.Notifications.cs:43-46), which then registers SignalR (:48), adds the Redis backplane when aredisconnection string is present (:51-61), and replaces the null implementations with SignalRPushNotificationSender and SignalRLiveChannelPublisher (:65-66). The settings object itself is injected asIOptions<PushNotificationSettings>and read by NotificationHub in two places:EnsureValidChannelKey(NotificationHub.cs:178) andOnConnectedAsync(:56), which readsMaxConnectionsPerUserintocap, tracks a staticConnectionsPerUsercount keyed byContext.UserIdentifier, and throwsHubException("Too many concurrent connections for this account.")when a new connection would push the count overcap(: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 whencap <= 0or the identifier is empty (:59). The default is pinned byMMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Settings/SettingsTests.cs:347-348and an override by:352,:357; the connection cap itself is covered byMMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Hubs/NotificationHubConnectionCapTests.cs.Caveats: this class stands alone. There is no
IPushNotificationSettingsabstraction 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
MessageBussection. 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.DataAnnotationsfor the[StringLength]and[Range]guards (:1).Concept introduced: a tri-state flag that resolves from the transport. Two of the properties here are
bool?, notbool, and each has a computed companion.EnableInbox(:117) is the raw configured value andIsInboxEnabled(:125) is what every framework component actually reads:EnableInbox ?? Provider != MessageBusProvider.InProcess.EnableOutbox(:151) andIsOutboxEnabled(: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/RetryMaxIntervalSecondsfeed the in-process exponentialUseMessageRetryfilter applied to every broker receive endpoint (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:292-296for RabbitMQ,:309-313for Azure Service Bus).EnableDelayedRedeliveryandRedeliveryIntervalsSecondsfeed 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.EndpointPrefixexists 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
initproperties, and two computed properties.SectionName = "MessageBus"(MessageBusSettings.cs:14), the bind key.Provider(:17): defaults toMessageBusProvider.InProcess, so the modular monolith is the zero-configuration case.ConnectionString(:26): nullable, and only the first of three sources.ResolveBrokerConnectionStringprefers it, then falls back toConnectionStrings:rabbitmqandConnectionStrings:messaging(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:225-234), which is what Aspire injects viaWithReference(broker). Without that fallback MassTransit would default tolocalhost:5672and 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-clientsHostoverload 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 fromConnectionStringalone (:33-40). It is read only when the connection string carriesUseDevelopmentEmulator=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,AddMassTransitinstallsnew KebabCaseEndpointNameFormatter(settings.EndpointPrefix, includeNamespace: false)(DependencyInjection.Messaging.cs:79-87).includeNamespace: falseis 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), defaultfalse: settrueonly on an existing deployment to keep MassTransit's bare default endpoint names (no prefix), which is what every release before 1.188.0 produced whenEndpointPrefixwas 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), default5;0disables retries and a faulted message goes straight to the_errorqueue.RetryMinIntervalSeconds(:83) with[Range(0, 300)], default1, andRetryMaxIntervalSeconds(:89) with[Range(0, 3600)], default30: the floor and the cap of the exponential backoff.EnableInbox(:117) andIsInboxEnabled(:125).AddBrokerMessagingregisters 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). TheInboxMessagestable 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) andIsOutboxEnabled(:159).AddInfrastructurereads 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:EnsureOutboxAvailableForProviderthrows 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 bothAddInfrastructure(:188) andAddBrokerMessaging(:749) so a service host that wires only the broker still fails loudly. TheOutboxMessagestable stays mapped either way, so flipping the flag is never a migration.EnableDelayedRedelivery(:179), defaultfalse. It gates second-level redelivery on RabbitMQ only, because that transport needs therabbitmq_delayed_message_exchangeplugin 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).BuildRedeliveryIntervalsdrops non-positive entries, because a zero or negative interval schedules an immediate redelivery, which is whatUseMessageRetryalready 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) andConcurrentMessageLimit(:227): both nullableint, both unset by default, and both range-guarded in code rather than by[Range], becauseAddBrokerMessagingbinds this section withGet<MessageBusSettings>(), which never runs DataAnnotations, so an attribute would document the bound without enforcing it (:209-215,:219-225).ApplyBackpressureapplies each one, unset or non-positive, to the transport's own default rather than pushing a stalling value (DependencyInjection.Messaging.cs:360-371).PrefetchCountcaps 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).ConcurrentMessageLimitcaps 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()inAddInfrastructure(DependencyInjection.cs:169-172), then re-read eagerly in the same method to gate the outbox hosted services (:186-198).AddBrokerMessagingreads it again, falling back tonew MessageBusSettings()when the section is absent (:738-739), short-circuits onInProcess(: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 readsIsInboxEnabledto decide whether to purge inbox rows (OutboxCleanupService.cs:52). Emulator wiring is delegated to ServiceBusEmulatorSupport.ApplyBackpressurereadsPrefetchCountandConcurrentMessageLimitoff the bound instance inside the RabbitMQ and Azure Service Bus branches ofConfigureBrokerTransport(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:297,:314). Covered byMMCA.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
EmulatorAdminEndpointare 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 forTEventexhausts its retry policy. It turns a message that would otherwise appear only as a row in the broker's_errorqueue into one structured Error log plus abroker.fault.countmetric tagged by event type (FaultIntegrationEventConsumer.cs:7-11). - Depends on: MassTransit's
IConsumer<Fault<TEvent>>andConsumeContext<T>,BrokerMetrics.FaultCounter(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:30-31),IIntegrationEventas the type constraint (FaultIntegrationEventConsumer.cs:28), andILogger<T>with theLoggerMessagesource 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 aFault<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-22states the rule and the reasoning: a fault consumer that itself faults would publishFault<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 theArgumentNullException.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-38gives 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-43is 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 anevent_typetag, 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 passesregisterFaultConsumer: false(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:38-46), andRegisterUpcastedIntegrationEventConsumer<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'sUserRegistered; in MMCA.Store it is the Sales service'sProductVariantChanged. Covered byMMCA.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
IHostedServicewhose entire job is to resolveIEventUpcasterRegistryat 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:
IEventUpcasterRegistryas its single primary-constructor parameter (EventUpcasterStartupValidator.cs:20),Microsoft.Extensions.Hosting.IHostedService, andIIntegrationEventas 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:EventUpcasterRegistryvalidates 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 ofupcasters.ResolveTerminalType(typeof(IIntegrationEvent))(:27) and returns a completed task. The comment at:25-26explains 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>()), notAddHostedService, because two modules callingAddInfrastructuremust not run the same validation twice.TryAddEnumerablede-duplicates on the implementation type, whichAddHostedServicedoes 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 byMMCA.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:
INotificationHubClientandILogger<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:FCMV1andAPNS, compared afterToUpperInvariant, map toNotificationPlatform.FcmV1and.Apns, and anything else yieldsnull(: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
Installationis assembled with the client-supplied id, the resolved platform, the push channel, and exactly one tag, the owner stamp (AzureNotificationHubDeviceRegistrar.cs:36-42).CreateOrUpdateInstallationAsyncis 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 toPushDevice.UpsertFailedafter 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-67also 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.
MessagingEntityNotFoundExceptionreturns success, because an unknown installation is already the desired state (AzureNotificationHubDeviceRegistrar.cs:79-83); a generalMessagingExceptionlogs and returnsPushDevice.DeleteFailed(:84-88). OwnedBy(AzureNotificationHubDeviceRegistrar.cs:91-92) is the ownership predicate: the installation's tags contain the owner tag, compared withStringComparer.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
AddNativePushNotificationsalongside 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 byMMCA.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) andDeleteAsync(:19-20) are both expression-bodiedTask.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 calledAddNativePushNotificationswith 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
Platformvalue. 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:
AmbientOriginfor the actual restore, MassTransit'sHeadersandIServiceProvider,MessageHeadersfor the four header names, and theUserIdentifierTypealias with itsTryParse. - 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 alongeven when it was published as anint. 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 anIDisposable(:35): the handle that withdraws the published origin, which the caller holds for the whole consume (:34). It parsesMessageHeaders.UserIdwithUserIdentifierType.TryParseunder the invariant culture, defaulting to null on failure (:42-48).- It then returns
AmbientOrigin.Restorewith that id and the remaining three headers,UserRoles,TenantId, andCorrelationId, read straight through as strings (ConsumerOriginRestore.cs:50-56), so the handle isAmbientOrigin'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
AmbientOriginitself, 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) andUpcastingIntegrationEventConsumer<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, andILogger<T>as its four primary-constructor parameters (UpcastingIntegrationEventConsumer.cs:32-36); MassTransit'sIConsumer<TEvent>;EventNameResolverfor the inbox key;ConsumerOriginRestoreto restore the publisher's identity, tenant, and correlation id before the inbox is touched;System.Linq.ExpressionsandSystem.Collections.Concurrentfor 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 registeredIEventUpcasterchain 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 plainIntegrationEventConsumer<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 constantIntegrationEventConsumer<TEvent>uses, because an upcast changes the contract, never the hop the identity came back from.ConsumecallsConsumerOriginRestore.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 withusing var originand 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 viaConsumerOriginRestore.Apply(:68), then takesintegrationEvent.MessageIdbefore 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(...)returningfalsemeans already processed: log at Debug and return (UpcastingIntegrationEventConsumer.cs:80-84).TryBeginalso stages the inbox row in the scope's unit of work; it is written byCompleteAsynconce every handler has succeeded (:78-79), as inIntegrationEventConsumer.- 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. UpcastToTerminalproduces 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 closedIIntegrationEventHandler<>interface and a compiled invoker.BuildInvoker(:156-176) builds an expression tree that casts the twoobjectparameters to their concrete types and calls the strongly-typedHandleAsyncdirectly, so reflection is paid once per contract instead of once per message. This mirrors whatDomainEventDispatcherdoes for the in-process path. - The dispatch loop (
UpcastingIntegrationEventConsumer.cs:109-134) counts handlers and awaits each invoker. Itscatch(: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 configuredUseMessageRetrypolicy before dead-lettering (:128-132).OperationCanceledExceptionis 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.csand 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 matchingFaultIntegrationEventConsumer<TEvent>. Covered byMMCA.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 onBaseIntegrationEventand onOutputCacheEvictionRequestedas 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
FileStorageconfiguration 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 setsServiceUriand authenticates withDefaultAzureCredential, so no storage key exists to leak, whileConnectionStringis documented as the local-development alternative (FileStorageSettings.cs:15-19).[Rubric §33, Developer Experience]:AddAzureBlobFileStorageis 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 atDependencyInjection.Notifications.cs:125-126, an empty-stringServiceUribinds to a relativeUri, 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), nullableUri, 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 absoluteServiceUrinor a connection string is present (:127-130).
Walkthrough of its one consumer:
AddAzureBlobFileStoragebinds 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 singletonBlobContainerClientbuilt from either the URI plusDefaultAzureCredentialor 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) meansIOptions<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 ofScheduledJobRunner.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 returnsJobClaim?, sonullis "nothing due" and a non-null instance with a nullLockTokenis "lost the race" (ScheduledJobRunner.cs:406-409). Collapsing the latter two into onenullwould 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), andGuid? LockToken. It is constructed once, atScheduledJobRunner.cs:447, where the token is passed asclaimed == 0 ? null : lockToken: the count of rows the claimingExecuteUpdateAsyncactually matched IS the race result.Why it's built this way:
private sealed recordkeeps 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 byRunDueJobsAsync(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 byScheduledJobRunneron 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,DateTimeandGuidfrom 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 anIAuditableEntityand not an aggregate root, exactly likeOutboxMessage, 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 plainSaveChangesAsyncwith 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 theDefaultdata 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),requiredandinit-only: the primary key, matchingIScheduledJob.Name, so a job has exactly one schedule row per host.CronExpression(ScheduledJobEntry.cs:34),requiredwith a setter: the five-field UTC expression currently in force, which is either the job's code default or theScheduler:Jobs:{Name}:Cronoverride. 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 atDateTime.MaxValueso it is never claimed, which is how one bad cron string in configuration fails just that job.LastRunOn(:43),LastOutcome(:50, one ofSucceeded,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), andLastDurationMs(:60): the operator-facing run record.LockedUntil(:67) andLockToken(: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 setScheduler:Enabledand this context targets theDefaultsource (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 setsToTable("ScheduledJobs", "dbo")(:603), theJobNamekey (:604), column widths matching the entity's documented truncation (:605-608), and one index onNextRunOnwith 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 inScheduledJobRunner.
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
CronExpressioncompiled 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-blankCronreplaces it (SchedulerSettings.cs:54-59,:68-73).[Rubric §15, Best Practices & Code Quality]: the override is keyed byIScheduledJob.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.ResolveCronExpressionlooks 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 aDateTime.MaxValuenext 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 atDebugrather thanInformationprecisely 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 theMeterNamemeter; 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 staticMeterbuilt from it (:21). The class doc is explicit that one meter serves every scheduler instrument and that a secondMeterwith this name must never be created (SchedulerMetrics.cs:11-14), since duplicate meters are a classic source of double-counted telemetry.BrokerMetricsrepeats the same shape for the broker transport.RunCounter(SchedulerMetrics.cs:28-31),scheduler.job.runs, unitruns, tagged byjobandoutcome: the failure rate of a schedule is this counter split by outcome.DurationHistogram(:39-42),scheduler.job.durationin seconds, tagged byjob, measured around the job's ownExecuteAsynconly so it excludes claim and bookkeeping cost. Its stated purpose is the lease relationship: a job whose duration approachesScheduler:LeaseSecondsis about to lose its claim mid-run (SchedulerMetrics.cs:33-38).LagHistogram(:50-53),scheduler.job.lagin 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 readonlyinstruments 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.ExecuteClaimedJobAsyncrecords 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 implementsIUserScopedRequest(GetUserPreferencesQuery.cs:5).Depends on:
IUserScopedRequest(IUserScopedRequest.cs:8), and theUserIdentifierTypealias, which resolves tointthrough 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
DeleteUserCommandrecords, for instance, implementICacheInvalidatingwith aCachePrefixbuilt from their ownUsertype (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 notICacheInvalidating, notIQueryCacheable, notICommandWithRequest<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-sideChangePreferencesCommandhandled byChangePreferencesHandlerBase<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. ImplementingIUserScopedRequestis 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
UserPreferencesResponsereply were byte-identical in both app Identity modules, so the handler base could be made generic in theUseraggregate 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 itsGET preferencesaction (UserAccountAuthControllerBase.cs:151) and handled byGetUserPreferencesHandlerBase<TUser>(GetUserPreferencesHandlerBase.cs:33-35). Both apps'AuthControllertake 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
Schedulersection for the recurring-job runner: the on switch, the fallback poll interval, the row-claim lease, which engine holds theScheduledJobstable, and a per-job cron override map. Every property has a default, so a host that opts in needs onlyScheduler: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.DataAnnotationsfor[Range](:1).Concept introduced: one setting that gates both the behavior and the schema. Most feature flags gate behavior.
Enabledalso decides whether theScheduledJobstable is mapped into the EF model at all: ApplicationDbContext readsIOptions<SchedulerSettings>withGetService(notGetRequiredService) 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 (AddScheduledJobsbinds and registers the runner, but the runner returns immediately unlessEnabledis 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.LeaseSecondsis 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 atScheduledJobRunner.cs:432).[Rubric §31, Cost and FinOps]assesses idle-cost defaults.PollingIntervalSecondsis 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
initproperties and one get-only dictionary.SectionName = "Scheduler"(SchedulerSettings.cs:19).Enabled(:26): defaultfalse, 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), default30.LeaseSeconds(:43):[Range(10, 3600)](:42), default300, documented as needing to sit comfortably above the longest expected job duration.DataSource(:52): defaultDataSource.SQLServer. Jobs are host-scoped, so there is exactly oneScheduledJobstable 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 againstDataSourceKey.DefaultName(ScheduledJobRunner.cs:224).Jobs(:60): a get-onlyDictionary<string, ScheduledJobOverrideSettings>keyed by IScheduledJob.Nameand bound fromScheduler:Jobs:{Name}.ResolveCronExpressiontakes the override only when the entry exists AND itsCronis 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 underValidateOnStart, 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 throughTryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, ScheduledJobRunner>())rather thanAddHostedService, 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 sodotnet efcan 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 aResultrather than letting a storage exception escape (AzureBlobFileStorageService.cs:10-15). - Depends on:
Azure.Storage.Blobs.BlobContainerClientandILogger<T>as its two primary-constructor parameters (AzureBlobFileStorageService.cs:15-17),IFileStorageServiceas the contract, andResult/Errorfor 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. ACreateIfNotExistsin 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:RequestFailedExceptionis 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
IsConfiguredis a constanttrue(AzureBlobFileStorageService.cs:20). Its whole purpose is to differ from thefalseonNullFileStorageService.UploadAsync(blobName, content, contentType, ct)(AzureBlobFileStorageService.cs:23-24) is a thin overload that forwards toUploadAsync(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 targetedSuppressMessageforRS0026(:27): two public overloads each ending in an optionalCancellationTokenis 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-guardsoptions(:30), resolves a blob client from the container (:34), and uploads withBlobUploadOptionscarrying the content type plusoptions.ContentDispositionandoptions.CacheControlas HTTP headers (:35-46), returning the blob's absolute URI on success (:48). SettingContentTypeat 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 returnsError.Failurewith codeFileStorage.UploadFailedand a caller-safe message (:52-56). Anything that is not a storage failure still propagates. DeleteAsync(blobName, ct)(AzureBlobFileStorageService.cs:61-77) usesDeleteBlobIfExistsAsync, 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 codeFileStorage.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):ServiceUriwithDefaultAzureCredentialis the managed-identity production path,ConnectionStringis the local Azurite path, andContainerNameis required for either. There is one carefully commented trap at:125, an empty-stringServiceUribinds to a relativeUri, so onlyIsAbsoluteUricounts as configured. An incomplete section returns before registering anything, which leaves the null default in place, so a host can callAddAzureBlobFileStorageunconditionally. - 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:SetUserAvatarHandleruploads the normalized jpeg and deletes the previous blob, andRemoveUserAvatarHandlerandDeleteUserHandlerdelete 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 aResult(ImageSharpImageProcessor.cs:15-20). - Depends on:
SixLabors.ImageSharp(plus itsFormats.JpegandProcessingnamespaces),IImageProcessoras the contract, andResult/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 isImageContentSniffer, which narrows the accepted formats by magic bytes rather than by the client-declared content type. - Walkthrough
- Two public constants bound the decode:
MaxDecodedPixelsis 50,000,000 (ImageSharpImageProcessor.cs:31), andMaxDecodedDimensionis 20,000 on either edge (:38). The remarks onMaxDecodedPixelsname 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.MaxDecodedDimensioncloses 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-guardscontent(:43), then reads the format header withImage.IdentifyAsyncbefore allocating any frame (:51-52), passingCreateDecoderOptions(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 theOutOfMemoryExceptionthat would otherwise escape the catch clause below as a 500.- A declared size past either constant returns
Error.Validationwith codeImage.TooLargeand the offending dimensions in the message (ImageSharpImageProcessor.cs:54-60); the stream position is restored when seekable (:62-65) beforeImage.LoadAsyncactually 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.MaxFramesis 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 theDecoderOptionsper call with a restrictedConfigurationthat 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 asUnknownImageFormatException(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 theNuGetAuditSuppresslist inDirectory.Build.props). It is built per call rather than cached so it takes the currentConfiguration.Defaultmemory 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()thenResizewithResizeMode.Cropto asizebysizesquare (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 aMemoryStream, returned as a byte array (ImageSharpImageProcessor.cs:96-101). The stream is disposed throughawait usingwithConfigureAwait(false). - The catch is exception-filtered to exactly two ImageSharp types,
UnknownImageFormatExceptionandInvalidImageContentException(ImageSharpImageProcessor.cs:103-109), and turns them intoError.Validationwith codeImage.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 pastMaxDecodedDimension, or the area (cast tolongbefore multiplying, to avoid anintoverflow on a large declared size) pastMaxDecodedPixels.
- Two public constants bound the decode:
- 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 bySetUserAvatarHandlerbefore the upload. Covered byMMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Storage/ImageSharpImageProcessorTests.cs, with the single-frame bound pinned separately byMMCA.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 = 1bounds the frame count, but nothing here further bounds working-set memory beyond that;ImageContentSnifferrestricts 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:
IFileStorageServiceas the contract andResult/Errorfor 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
IsConfiguredreturnsfalse(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 theFileUploadOptionsoverloadUploadAsync(blobName, content, contentType, options, ct)(NullFileStorageService.cs:17-18,:22-23) both simply returnNotConfigured(): the extra options parameter changes nothing about a store that never stores anything. The options overload carries the same grandfatheredRS0026suppression 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 failedResultcarryingError.Failurewith codeFileStorage.NotConfigured, a message naming the actual condition ("No file storage is configured for this host"), andsourceset to the type name. Factoring it out is what let the second overload's implementation be a one-line forward whenFileUploadOptionswas 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 aResultand 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
AddInfrastructurethroughTryAddTransient(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:336), replaced byAzureBlobFileStorageServicewhenAddAzureBlobFileStoragefinds a completeFileStoragesection (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 byMMCA.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:14andAzureBlobFileStorageService.cs:20). The flag is exercised downstream: MMCA.ADC'sUploadSessionAssetHandlerchecks it before touching storage and returns its ownSessionAsset.StorageNotConfiguredfailure, 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,ITwoFactorStoreand anILoggeras primary-constructor parameters (:35-38); implementsICommandHandler<in TCommand, TResult>closed overResult<TwoFactorSetupResponse>(:38).TCommandis constrained toIUserScopedRequest(: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 declaredpartial(: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
HandleAsynccallstwoFactorService.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 toGetType().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), returningError.NotFoundwhen absent (:55-59); returns aConflict(Authentication.TwoFactorAlreadyEnabled) when already enabled (:61-67); resolves the account label through the one abstract hook (:69), failingNotFoundwhen it comes back blank (:70-74); generates the secret (:79); starts the enrollment in the store (:81), propagating its failure untouched (:82-85); logsTwoFactorEnrollmentStarted(: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'sUserexposes 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.csthrough 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,ITwoFactorStoreand anILogger(:29-32); implementsICommandHandler<in TCommand, TResult>overResult<TwoFactorRecoveryCodesResponse>(:32).TCommandis anIUserScopedCommand<out TRequest>carrying aTwoFactorCodeRequest(:33). Also usesTwoFactorErrors, and logs through a private source-generated[LoggerMessage]method,TwoFactorEnabled(:75-76), hence thepartialdeclaration (: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.TwoFactorEnrollmentMissingwhen 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 withTwoFactorErrors.TwoFactorInvalid(:55-58); generate recovery codes (:60); complete the enrollment with their hashes (:62-68); logTwoFactorEnabled(: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 byMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/TwoFactorHandlerBaseTests.csthrough a shared test subclass.Caveats: a failed
VerifyCodedoes 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,ITwoFactorStoreand anILogger(:29-32); implementsICommandHandler<in TCommand, TResult>overResult(:32).TCommandis anIUserScopedCommand<out TRequest>carrying aTwoFactorCodeRequest(:33). Also usesTwoFactorErrors, and logs through a private source-generated[LoggerMessage]method,TwoFactorDisabled(:73-74), hence thepartialdeclaration (:29).Concept introduced: a distinguishable not-enrolled outcome.
ChallengeAsyncreturns aTwoFactorOutcome, and the handler treatsNotEnrolledas 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); theNotEnrolledoutcome fails distinctly (:57-60);DisableAsync(:62), returning its failure unchanged (:63-66); logTwoFactorDisabled(:68); return success (:70).
Why it's built this way: the challenge/store split mirrors the enrollment pair:
ITwoFactorAuthenticatorowns the live code check,ITwoFactorStoreowns 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,ITwoFactorStoreand anILogger(:30-34), the widest primary constructor of the four TwoFactor bases; implementsICommandHandler<in TCommand, TResult>overResult<TwoFactorRecoveryCodesResponse>(:34).TCommandis anIUserScopedCommand<out TRequest>carrying aTwoFactorCodeRequest(:35). Logs through a private source-generated[LoggerMessage]method,TwoFactorRecoveryCodesRegenerated(:79-80), hence thepartialdeclaration (:30).Concept introduced: regeneration replaces, it does not append.
ReplaceRecoveryCodesAsynctakes 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 sameNotEnrolleddistinct 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); theNotEnrolledoutcome fails distinctly (:58-62); generate a new code batch (:64); replace them in the store (:66-68), propagating a failure untouched (:69-72); logTwoFactorRecoveryCodesRegenerated(: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 withConfirmTwoFactorEnrollmentHandlerBase<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:
IUnitOfWorkas its single primary-constructor parameter (:21); implementsIQueryHandler<in TQuery, TResult>closed overGetUserPreferencesQueryandResult<UserPreferencesResponse>(:22). One constraint:TUseris anAuditableBaseEntity<TIdentifierType>implementingIUserPreferences(:23). Also usesError. 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
GetUserPreferencesQuerycarries no pipeline markers, it could be shared outright, so the base is generic in theUseraggregate only (:10-14). Note also the weaker entity constraint:AuditableBaseEntity<UserIdentifierType>rather thanAuditableAggregateRootEntity<...>(: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 viaGetReadRepository(:39) rather thanGetRepository, and never callsSaveChangesAsync. 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 asChangePreferencesHandlerBase<TUser, TCommand>keep usingGetRepositoryinstead.[Rubric §15: Best Practices & Code Quality]assesses consistency of error shape. The not-found path produces the identicalError.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):unitOfWorkonly. HandlerName(:30): the sameGetType().Namedefault, keeping the errorsourceasGetUserPreferencesHandlerfor clients that match on it (:25-29).HandleAsync(GetUserPreferencesQuery, CancellationToken)(:33-45): null-guards the query (:37); resolvesGetReadRepository<TUser, UserIdentifierType>()(:39); loads byquery.UserId(:40); and returns either the stampedError.NotFoundfailure (:42-43) or a success wrappingnew UserPreferencesResponse(user.PreferredCulture, user.PreferredTheme)(:44). A ternary, not a branch chain: the whole method is a load and a projection.
- Primary constructor (
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 closedIQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>>interface byUserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>(UserAccountAuthControllerBase.cs:46,:57,:149-150). Pinned byMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:14through 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
nulland returnsNotFoundrather 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
BackgroundServicethat runs the host's registeredIScheduledJobimplementations 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 optionalTimeProvider(ScheduledJobRunner.cs:39-44). At run time it resolvesIDbContextFactoryper cycle (:222) and works againstApplicationDbContext; it emits throughSchedulerMetricsand returns its claim results asJobClaim. Externals: EF Core (ExecuteUpdateAsync),Microsoft.Extensions.Hosting, and Cronos, aliased asCronSchedule(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 singleExecuteUpdateAsyncthat stampsLockedUntilandLockTokenover aWhereadmitting 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 injectedTimeProviderplus aninternalRunCycleAsyncmean a test drives one whole cycle deterministically without waiting on wall-clock timers (ScheduledJobRunner.cs:212-213).Walkthrough:
- State and constants.
_settingssnapshotsIOptions<SchedulerSettings>.Valueonce (ScheduledJobRunner.cs:46), and_timeProviderfalls back toTimeProvider.System(:47). The three outcome stringsSucceeded,FailedandSkippedare named constants (:50,:53,:59),MaxErrorLengthis 2048 and matches theLastErrorcolumn width (:62),StartupDelayis 15 seconds so the host finishes module registration and migration before the first cycle touches the table (:69), andMinimumWaitis one second, the floor that stops an overdue row hot-looping the runner (:72).StampTimeoutis five seconds (:80), the budget for recording an outcome after a job has returned (seeExecuteClaimedJobAsyncbelow). ExecuteAsync(:83-135), the loop. It returns immediately whenScheduler:Enabledis 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.OperationCanceledExceptionduring 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 atMinimumWaitand capped at the configured interval. This is the same "sleep until there is something to do" shapeOutboxProcessoruses, 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 theDefaultlogical source on the configured engine throughIDataSourceResolver(:222-224), reconciles registrations, runs due jobs, and finally reads back the earliestNextRunOnacross the registered names (:229-235) which becomes the next wait.ResolveRegisteredJobs(:243-258) groupsGetServices<IScheduledJob>()byName(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, recomputingNextRunOnevery cycle would push every schedule forward forever and nothing would ever fire. A changed expression goes through the set-basedUpdateScheduleAsync(: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 atDateTime.MaxValueand recordsSkippedinstead of throwing (:295-316, and on the update path at:356-365).ResolveCronExpression(:170-174) is the configuration override point:Scheduler:Jobs:{Name}:Cronwhen present and non-blank, otherwise the job's compiled-in default.TryGetNextOccurrence(:184-205) wraps Cronos and catches exactlyCronFormatExceptionandArgumentException(: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 anattemptedset so each name is tried at most once per cycle (:378-390). AJobClaimwith a nullLockTokenmeans 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 aGuidtoken and a lease ofScheduler:LeaseSecondsfrom 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, logsLogLeaseLostand drops its stale outcome (:514-518) rather than overwriting the current holder's record. That stamp runs under its ownCancellationTokenSource(StampTimeout, _timeProvider)(:500, passed at:511) rather than the host stopping token (the source calls this "the outbox M8 shape"): the doc onStampTimeout(: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 injectedTimeProviderkeeps 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 returnsSkippedwith 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 asFailed(: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 underStampTimeoutregardless 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 staysErrororWarning(:562-596).
- State and constants.
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
AddScheduledJobsthroughTryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, ScheduledJobRunner>())(MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:47-48), deliberately notAddHostedService, 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 isAuditTrailCleanupJob, registered byAddAuditTrail(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 sharedSchedulerTestHarness(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/SchedulerTestHarness.cs), andSchedulerModelGateTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Scheduling/SchedulerModelGateTests.cs:25) pins the model gate.Caveats / not-in-source: which hosts actually call
AddScheduledJobsand setScheduler:Enabledlives 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.
Principalis 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), andAmbientOriginfor the seed value itsPrincipalinitializer 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 calledSet,Principalstays null andImpersonatingCurrentUserServicereads through to whatever the innerICurrentUserServicewould have returned anyway. - Walkthrough
Principal(ScopedUserOverride.cs:30) has a private setter, soSetandClearare the only ways to change it after construction. Its initializer isAmbientOrigin.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) setsPrincipalback 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
ICurrentUserServiceinterface 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 byImpersonatingCurrentUserService. Registered per scope inDependencyInjection.cs(2 references). Exercised directly byMMCA.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), andMMCA.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
ClaimsPrincipalfrom 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:
ScopedUserOverrideandITenantContext(fromTenantContext) as the scope services it restores onto;OriginSnapshotas the published value andRestoreHandleas the disposable it returns;ICorrelationContextfor the correlation id; theUserIdentifierTypealias;System.Security.ClaimsforClaim/ClaimsIdentity/ClaimsPrincipal;AuthClaimTypesfor 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 byInternalCommands.UserRolesandOutboxMessages.UserRoles;FlattenRolestruncates to fit rather than throw, and is explicitly null-tolerant even thoughICurrentUserService.Rolesis declared non-nullable, becauseRolesis 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
Restorewill 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, butITenantContextis 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.Restoretherefore 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
Restorecall.Restoretherefore also writes anOriginSnapshotinto a privateAsyncLocal(AmbientOrigin.cs:24,:180-185), exposed read-only asCurrent(:32).TenantContext,ScopedUserOverrideandCorrelationContextreadCurrentonce, 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 toMaxRolesLength, returning null for a null or empty source.BuildPrincipal(userId, userRoles, authenticationType)(AmbientOrigin.cs:86-109) returns null whenuserIdis null, meaning no user was captured, and the execution stays anonymous rather than inventing an identity. Otherwise it builds asubclaim from the id and one role claim per comma-separated entry, then wraps them in aClaimsIdentitystamped withauthenticationType, which is what makesIsAuthenticatedtrue.Restore(services, userId, userRoles, tenantId, correlationId, authenticationType)(AmbientOrigin.cs:156-188) now returns anIDisposable. 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 withGetServicerather 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 viaRestoreTenant(: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 clearsScopedUserOverrideviaRestorePrincipal(:172,:206-220); then sets the correlation id when one was carried (:174-178); then publishes the newOriginSnapshotand returns aRestoreHandlethat 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 namesTenantDataSourceTargets.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 byInternalCommandScheduler(2),InternalCommandProcessor(2),InternalCommandOriginCapture(2),TenantDataSourceTargets(2, theSuppresscaller),BrokerMessageBus(1),DbContextFactory(1), andOutboxProcessor(1), all underMMCA.Common/Source/Core/MMCA.Common.Infrastructure. The seeding readers areScopedUserOverride,TenantContextandCorrelationContext. Exercised byMMCA.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
Restoreand set the three scope services directly; every first-party caller goes through this helper. TheAsyncLocalis 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
AmbientOriginpublishes 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
UserIdentifierTypealias andSystem.Security.Claims.ClaimsPrincipal; its one method calls back intoAmbientOrigin.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 liveScopedUserOverrideorTenantContextmeans 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-nullAuthenticationType. BuildPrincipal()(AmbientOrigin.cs:261) rebuilds the principal from the stored id and roles, or returns null when no user was captured.ScopedUserOverridecalls it from its initializer (ScopedUserOverride.cs:30).
- Positional members (
- 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 throughCurrent; 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 inAmbientOrigin)
- What it is: the
IDisposablethatRestoreandSuppresshand back; disposing it puts the previously published origin back (AmbientOrigin.cs:240-243). - Depends on:
OriginSnapshot(the saved previous value) and the privateCurrentOriginAsyncLocalof its enclosingAmbientOrigin(: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" ausing-shaped contract:Disposeis the single lineCurrentOrigin.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), andDispose(:242) assigning it back.Restorepasses the origin it displaced (:187) andSuppresspasses the one it withdrew (:236). - Why it's built this way: a private nested class keeps the
AsyncLocalwritable only fromAmbientOrigin, while callers see onlyIDisposable. - Where it's used: constructed in
RestoreandSuppressin the same file; callers hold it asIDisposable. - 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:
ITenantContextas the contract,System.Globalizationfor the exception message, andAmbientOriginfor 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 namedTenantquery 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) assignsTenantId = AmbientOrigin.Current?.TenantId, so the value is read once at construction and null outside a hop (every HTTP request). TenantIdhas a private setter (TenantContext.cs:22), so the only way to write it after construction is throughSetTenant.IsResolvedisTenantId 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 throwsInvalidOperationExceptionon a genuine change (:37-43).- The idempotent middle branch is the load-bearing one, and the comment at
:30-31names 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.
- The public parameterless constructor (
- Why it's built this way: the registration comment in
AddInfrastructureexplains 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. OnlyAddMultiTenancy(configuration)turns the mechanism itself on. - Where it's used: written by
TenantResolutionMiddlewareon 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, andAuditTrailCleanupJob. It is read byDbContextFactoryfor routing, byTenantSaveChangesInterceptor, and by the caching decorators throughTenantCacheKey. Covered byMMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Context/TenantContextTests.cs, and exercised for routing byMMCA.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
SetTenantwith 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,IPasswordHasherand anILoggeras primary-constructor parameters, plus a requiredIRefreshSessionStore, a requiredILoginProtectionServiceand an optionalTimeProvider(:42-48); it implementsICommandHandler<in TCommand, TResult>closed overResult(:48). Its two constraints are the interesting part:TUsermust be anAuditableAggregateRootEntity<TIdentifierType>keyed byUserIdentifierTypeand implementIPasswordChangeableUser(:49), andTCommandmust be anIUserScopedCommand<out TRequest>carrying aChangePasswordRequest(:50). It also usesError, and its audit line is a private source-generated[LoggerMessage]method,PasswordChanged(:131-132), which is why the class is declaredpartial(:42). Externals:System.GlobalizationandMicrosoft.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.TUservaries because each app owns its ownUseraggregate and the framework must never reference either; the capability it needs is named by an interface constraint instead, so the base can callChangePasswordwithout knowing the type (IPasswordChangeableUser.cs:19).TCommandvaries because the command record carries app-specific pipeline policy: ADC'sChangePasswordCommandisICacheInvalidating(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 throughIUserScopedCommand<out TRequest>, which is deliberately notICommandWithRequest<out TRequest>: that marker also opts the command into automaticCommandRequestValidator<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 theHandlerNamehook, 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
PasswordHashandPasswordSalt(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):
CheckLockoutAsyncbefore the verify, returning its failure as-is when the key is locked (:88-93);IncrementFailedAttemptsAsyncon a mismatch (:97); andResetFailedAttemptsAsynconce the password verifies (:102). The counter key ispassword-change:{userId}, built by the privateProtectionKeyhelper with the id rendered underCultureInfo.InvariantCulture(:128-129). The constructor documentation gives the two reasons it is not keyed by email:IAuthUserexposes 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 anUnauthorizederror 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 toIPasswordHasher, 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 requiredIRefreshSessionStore, via the sharedRefreshSessionRevocationhelper (:112-114, comment at:110-111), so a stolen refresh chain cannot outlive the rotation that was meant to remediate it (ADR-097). Only thetimeProviderparameter defaults tonull; the base caches it in a private_timeProviderfield, falling back toTimeProvider.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 callsuser.ChangePassword(newHash, newSalt)(:105) and returns whateverResultthe 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 requiredrefreshSessionsandloginProtection, and the optionaltimeProvider. The logger is typed as the non-genericILoggerso a subclass can pass its ownILogger<TAppHandler>and keep the log category app-specific while the message text stays in the base's own[LoggerMessage]declaration (ADC passesILogger<ChangePasswordHandler>atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:27). UnitOfWork(:55): aprotectedpass-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 toGetType().Name. This is the detail that made the hoist behavior-preserving: because each app keeps a subclass literally namedChangePasswordHandler, thesourcefield 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 viaGetRepository(:71) because this path saves; loads bycommand.UserId(:72) and returnsError.NotFoundstamped 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 returningError.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 logsPasswordChanged(:116). The aggregate's result is returned either way (:119), so a domain failure propagates unchanged.ProtectionKey(UserIdentifierType)(:128-129):private static, thepassword-change:counter key described above.PasswordChanged(ILogger, UserIdentifierType)(:131-132): the source-generated Information-level audit line, "User {UserId} password changed".
- Primary constructor (
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 byScanModuleApplicationServices<TAssemblyMarker>()(seeDependencyInjection) and are then wrapped by the decorator pipeline. The workflow is pinned directly byMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePasswordHandlerBaseTests.cs:18, which drives it through a test double subclass that also takes anILoginProtectionService(:254-260).Caveats: new-password strength is not checked here. Both apps' commands additionally implement
ICommandWithRequest<ChangePasswordRequest>(ChangePasswordCommand.cs:15in ADC,:13in 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 implementsITransactional, so the singleSaveChangesAsyncat:108is the whole atomic unit for the aggregate write; the lockout counter is written throughILoginProtectionService, 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:
IUnitOfWorkand anILogger(:23-25); implementsICommandHandler<in TCommand, TResult>overResult(:25). Constraints:TUseris anAuditableAggregateRootEntity<TIdentifierType>implementingIUserPreferences(:26), andTCommandis anIUserScopedCommand<out TRequest>carrying aChangePreferencesRequest(:27). Also usesError, and logs through a private source-generated[LoggerMessage]method,PreferencesChanged(:65-66), hence thepartialdeclaration (: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.PreferredCultureand the matching line for the theme (:53-55). The domain interface documents the same contract from its side, so an aggregate author knows thatUpdatePreferencesalways 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):unitOfWorkandlogger; no hasher, lockout or session store, since nothing here is credential material. UnitOfWork(:30) andHandlerName(:37): the same two protected members, with the same rationale (an app subclass namedChangePreferencesHandlerkeeps the pre-hoist errorsource,:32-36).HandleAsync(TCommand, CancellationToken)(:40-63): null-guard (:44); write repository viaGetRepository(:46); load bycommand.UserId(:47);Error.NotFoundstamped with handler and aggregate names when missing (:50); the merged call touser.UpdatePreferences(...)(:53-55); and, only when the aggregate succeeded,SaveChangesAsyncplus thePreferencesChangedlog (:56-60). The aggregate's result is returned unchanged (:62).
- Primary constructor (
Why it's built this way: identical to the rationale for
ChangePasswordHandlerBase<TUser, TCommand>, and with the same asymmetry on the command record: ADC'sChangePreferencesCommandisICacheInvalidating(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,:20andMMCA.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 fromUserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which builds the app's command through a factory hook and returns204 No Contenton success (UserAccountAuthControllerBase.cs:126-132). Covered byMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePreferencesHandlerBaseTests.cs:16through 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,IEmailConfirmationTokenServiceand anILogger(:33-36); implementsICommandHandler<in TCommand, TResult>overResult(:36). Constraints:TUseris anAuditableAggregateRootEntity<TIdentifierType>implementingIEmailConfirmableUser(:37), andTCommandis anICommandWithRequest<out TRequest>carrying aConfirmEmailRequest(:38). Also usesEmailConfirmationErrors, and logs through two private source-generated[LoggerMessage]methods,EmailConfirmed(:95-96) andEmailConfirmationRejected(:100-101), hence thepartialdeclaration (: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.IsEmailConfirmedafter 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 identicalEmailConfirmationErrors.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 wayResetPasswordHandlerBase<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 toGetType().Name.HandleAsync(TCommand, CancellationToken)(:50-93): null-guard (:52); redeem the token viaValidateAndConsumeAsync(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); calluser.ConfirmEmail()(:83), returning its failure untouched (:84-87); save (:89); logEmailConfirmedand 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 byMMCA.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, anICacheServiceand anILogger(:62-65); implementsICommandHandler<in TCommand, TResult>overResult(:65). Constraints:TUseris anAuditableAggregateRootEntity<TIdentifierType>implementingIErasableUser(:66), andTCommandis anIUserOwnedRequest(:67), not anIUserScopedCommand<out TRequest>: this workflow needs the caller as well as the target, and carries no request payload. It also usesUserOwnershipRule,ErrorandSoftDeletedUserCache, and logs through two private source-generated[LoggerMessage]methods,UserErasedat Information (:196-197) andSoftDeletedMarkerFailedat Warning (:199-200), hence thepartialdeclaration (: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 pairIAnonymizableplusIErasableUser: keep the row, overwrite the personal fields with non-identifying placeholders, and requireAnonymize()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-117is one of the most instructive in the framework. An app'sUsermay hide the base entity'sDelete()withpublic 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 bareuser.Delete()inside this base would bind toAuditableBaseEntity<TIdentifierType>.Delete()and silently skip the app's version. The workflow therefore assigns the user to anIErasableUserlocal first and calls through the interface (:118-119), because the interface map resolves to the most derivedDelete()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.
OnAfterSoftDeleteAsyncreceives anICollection<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 afterSaveChangesAsync(: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 anITransactionalcommand the save is not the commit, so the marker and everyafterCommitaction 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 atry/catch(excludingOperationCanceledException) that logsSoftDeletedMarkerFailedand 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 sameGetType().Namedefault that preserves the pre-hoist errorsource(:72-76).HandleAsync(TCommand, CancellationToken)(:80-163):- Authorization first (
:86-92) throughUserOwnershipRule.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.NotFoundwhen 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 viaIRefreshSessionStorein the same unit of work (:107-111).- Allocate the
afterCommitlist and callOnAfterSoftDeleteAsync(: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 theUserErasedlog (:160) andResult.Success()(:162).
- Authorization first (
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 namesUserRole.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 toTask.FromResult(Result.Success()); returning a failure aborts the erasure before anything is persisted (:173-175). Its position is load-bearing and documented: it runs afterDelete()and beforeAnonymize()(: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 afterDelete()rather than before means an already-deleted account fails with its ownAlreadyDeletederror rather than one raised by a cascaded aggregate (:54-58).
- Primary constructor (
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-serviceUserDeletedevent 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 throughIInternalCommandSchedulerinside the erasure transaction rather than queueing it onafterCommit, 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 linkedCustomerthat 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, withHasDeletePrivilegereturningUserRole.IsOrganizer(...)at:39-40;MMCA.Store/.../DeleteUser/DeleteUserHandler.cs:25,:29, withUserRole.IsAdmin(...)at:32-33). Both subclasses take theICacheServicepurely to hand it to this base. Covered directly byMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:17, whose fixture user type isTestHidingDeleteUser(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
afterCommitactions 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 anITransactionalcommand 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'DeleteUserCommandrecords areICacheInvalidating, 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 anILogger(:36-41); implementsICommandHandler<in TCommand, TResult>overResult(:41). Constraints:TUseris only anAuditableAggregateRootEntity<TIdentifierType>keyed byUserIdentifierType(:42) with no capability interface at all, because the workflow reads nothing off the aggregate exceptId(:73), andTCommandis anICommandWithRequest<out TRequest>carrying aForgotPasswordRequest(:43). It also uses theEmailvalue object (:58), and logs through three private source-generated[LoggerMessage]methods (:157-166), hence thepartialdeclaration (:36). Externals:System.Globalization,System.Net.WebUtility,Microsoft.Extensions.LoggingandMicrosoft.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 same202 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-164says so). Only the paths that already proved an account exists log a user id:PasswordResetRequested(:157-158) and the Warning-levelPasswordResetEmailFailed(: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 excludesOperationCanceledException(: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-ownedUseraggregate, 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 readsSettings.TokenLifetimeMinutesrather thansettings.Value....HandleAsync(TCommand, CancellationToken)(:52-101): null-guard (:56);Email.Createon 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 threeCompose*hooks and sent as HTML (:84-89); and finally thePasswordResetRequestedlog and success (:99-100).FindUntrackedByEmailAsync(Email, CancellationToken)(:110):protected abstract. The only app-specific step, because each app'sUserstores 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 throughWebUtility.HtmlEncodebefore interpolation into the HTML (:129,:133), and the expiry is rendered withCultureInfo.InvariantCulture(:126).ComposeResetLink(string email, string token)(:152-155):protected virtual. ReturnsnullwhenPasswordResetSettings.ResetUrlis blank, so an unconfigured host degrades to a token-only email rather than emailing a broken link; otherwise it appends#email=...&token=...with both valuesUri.EscapeDataString-encoded, as a URL fragment rather than a query string.PasswordResetRequested,PasswordResetEmailFailedandPasswordResetRejected(: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.queryis exported unredacted), and out of anyRefererheader the reset page emits; the page itself reads the values fromlocation.hashand scrubs them from the address bar (:145-155, remarks onComposeResetLink).- Primary constructor (
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 throughICommandWithRequest<ForgotPasswordRequest>(:27-32).Where it's used: subclassed once per app, each override implementing the address lookup as an untracked
GetAllAsyncfiltered on theEmailvalue 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 throughPasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, whosePOST forgot-passwordaction is[AllowAnonymous], rate-limited by the auth-IP policy and[Idempotent](PasswordResetAuthControllerBase.cs:75-93). Pinned by six tests inMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:21through a test subclass (:190-195), one per rejection path plus the unconfigured-ResetUrldegradation (:27,:42,:57,:79,:92,:108).Caveats: the anonymous command carries no user identifier, which is why it implements
ICommandWithRequest<out TRequest>rather thanIUserScopedCommand<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 callsSaveChangesAsync; 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,ILoginProtectionServiceand anILogger, plus a requiredIRefreshSessionStoreand an optionalTimeProvider(:41-48); implementsICommandHandler<in TCommand, TResult>overResult(:48). Constraints:TUseris anAuditableAggregateRootEntity<TIdentifierType>implementingIPasswordChangeableUser(:49), the same capabilityChangePasswordHandlerBase<TUser, TCommand>requires, andTCommandis anICommandWithRequest<out TRequest>carrying aResetPasswordRequest(:50). Also usesError, and logs through two private source-generated[LoggerMessage]methods,PasswordResetCompleted(:122-123) andPasswordResetRejected(:127-128), hence thepartialdeclaration (: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 singleAuth.InvalidResetToken(:116-120, produced at:82and:91). The privateInvalidToken()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 same401(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 finalResetFailedAttemptsAsynccall (: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 sharedRefreshSessionRevocationhelper (:105-107, comment at:103-104), the same mechanismChangePasswordHandlerBase<TUser, TCommand>uses (ADR-097). Only thetimeProviderparameter defaults tonull; the base caches it in a private_timeProviderfield, falling back toTimeProvider.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 callsuser.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 requiredrefreshSessions, and the optionaltimeProvider. Seven collaborators, one more thanChangePasswordHandlerBase<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) andHandlerName(:62): the same two protected members as the other bases, with the same rationale (an app subclass namedResetPasswordHandlerreports that name as the errorsource,:57-61).HandleAsync(TCommand, CancellationToken)(:65-114): null-guard (:69); redeem the token viaValidateAndConsumeAsync(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); logPasswordResetCompletedand return the aggregate's success result (:112-113).InvalidToken()(:116-120): the singleError.Unauthorized("Auth.InvalidResetToken", ...)construction, stamped withHandlerName.PasswordResetCompletedandPasswordResetRejected(:122-128):private static partial, the source-generated audit lines.
- Primary constructor (
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 becauseResetPasswordRequestValidatorincludes the sameStrongPasswordRules<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 thePOST reset-passwordaction onPasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>(PasswordResetAuthControllerBase.cs:99-118), which answers204 No Contenton success. Pinned by five tests inMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:20through 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
ResetPasswordCommandisICacheInvalidatingwith a prefix built from its ownUsertype (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:110runs 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 separatepassword-change:{userId}counter thatChangePasswordHandlerBase<TUser, TCommand>keeps is not touched here. Not determinable from source: whether any deployment configures aResetFailedAttemptsAsyncimplementation that can fail in a way the caller would notice, since the contract returns a bareTaskwith 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 asForgotPasswordHandlerBase<TUser, TCommand>.Depends on:
IUnitOfWork,IEmailConfirmationTokenService,IEmailSender,IOptions<EmailConfirmationSettings>and anILogger(:40-45); implementsICommandHandler<in TCommand, TResult>overResult(:45).TUseris only anAuditableAggregateRootEntity<TIdentifierType>keyed byUserIdentifierType(:46), with no capability interface required on the constraint itself, andTCommandis anICommandWithRequest<out TRequest>carrying aSendEmailConfirmationRequest(:47). Also uses theEmailvalue object, and logs through three private source-generated[LoggerMessage]methods (:176-185), hence thepartialdeclaration (:40).Concept introduced: an optional capability check with a safe default. Unlike
ConfirmEmailHandlerBase<TUser, TCommand>, which constrainsTUsertoIEmailConfirmableUser, this base does not: itsIsAlreadyConfirmedhook pattern-matches the aggregate against the interface at runtime and answersfalsewhen 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 concernForgotPasswordHandlerBase<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 identicalResult.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); onlyEmailConfirmationRequested(:176-177) and the Warning-levelEmailConfirmationEmailFailed(: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) andSettings(:53).HandleAsync(TCommand, CancellationToken)(:56-111): null-guard (:58);Email.Createon the raw string (:60-65);FindUntrackedByEmailAsync(:68); the already-confirmed short-circuit (:75-81);tokenService.IssueAsync(:83-88); the send, composed from the threeCompose*hooks (:92-100); and theEmailConfirmationRequestedlog 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) andComposeConfirmationLink(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, likeForgotPasswordHandlerBase<TUser, TCommand>.ComposeResetLink, riding the address and token in the URL fragment rather than the query string for the same ingress-log andRefererreasons (: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 ofForgotPasswordHandlerBase<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 byMMCA.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 caveatForgotPasswordHandlerBase<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
ICurrentUserServicedecorator that lets background work answer identity questions as the user who originally triggered it. Every member checksScopedUserOverride.Principalfirst and falls through to the innerICurrentUserServiceonly when nothing was captured (ImpersonatingCurrentUserService.cs:20-52). - Depends on:
ICurrentUserService innerandScopedUserOverrideas its two primary-constructor parameters (ImpersonatingCurrentUserService.cs:20-22), and implementsICurrentUserServiceitself, 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 resolvesICurrentUserService, whether a handler, a query-cache key builder, or an audit-field stamper, gets the captured identity automatically onceAmbientOrigin.Restorehas set the override, with no code path needing to know it is running outside an HTTP request. - Walkthrough
User(ImpersonatingCurrentUserService.cs:24-25) returnsuserOverride.Principal ?? inner.User.UserId(ImpersonatingCurrentUserService.cs:27-29) readsGetUserId()off the override principal when set, else delegates toinner.UserId.Role(ImpersonatingCurrentUserService.cs:31-35) reads the firstClaimTypes.Roleclaim off the override principal when set, else delegates toinner.Role.GetClaimValue<T>(claimType)(ImpersonatingCurrentUserService.cs:38-52) delegates outright toinner.GetClaimValue<T>when there is no override principal (:41-44); otherwise it reads the claim and parses it itself. The comment at:46-48records a real trap: claims are machine-written in the invariant culture exactly as the innerCurrentUserServicereads them, so parsing under the ambient culture would misread decimal andDateTimeseparators.
- 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
ICurrentUserServicecontract handlers already depend on. A decorator over the interface, rather than a branch inside every consumer ofICurrentUserService, keeps that substitution invisible to callers. - Where it's used: registered in
DependencyInjection.cs(1 reference) as the decorator over the host's realICurrentUserService. Depends onScopedUserOverride(1 reference, the file that declares it also references this type in return). Exercised byMMCA.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'sMigrationbase class,MigrationBuilder, and theDbContextAttribute/MigrationAttributepair fromMicrosoft.EntityFrameworkCore.Migrationsand.Infrastructure(:1-2). Its project takes a singleProjectReferenceonMMCA.Common.Infrastructureand is markedIsPackable=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 intoMMCA.Common.Infrastructure.Testswould 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.csand no model snapshot accompany it: neither is needed to apply a migration at run time, and both exist only sodotnet ef migrations addcan diff the next one (:19-21). The direct consequence shows up inUp, 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 onCreateMigrationProofTable.MigrationIdinstead of repeating the string.MigrationId(:27):"20260831000001_CreateMigrationProofTable", the timestamp-prefixed form EF expects and the value written into__EFMigrationsHistory.TableName(:30):"MigrationProof", the tableUpcreates and the evidence a test queries for.Up(MigrationBuilder)(:33-48): null-guards the builder (:35), then a singleCreateTablewith an autoincrementinglongIdcarrying theSqlite:Autoincrementannotation (:43-44), a non-nullstring Name(:45), and a primary key namedPK_MigrationProofbuilt 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()onDbContextFactoryis 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 throughDataSourceEntrySettings.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.
MigrationApplyProofTestsapplies it throughDbContextFactoryand 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 secondMigrateAsyncover an up-to-date database is a no-op rather than a re-apply whoseCREATE TABLEwould collide (:124-133).DatabaseInitializationExtensionsTestsuses it for the production guard inDatabaseInitializationExtensions: 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 isDbContextFactoryMigrationTargetTests, 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 inMMCA.Common.slnxunderTests/(MMCA.Common/MMCA.Common.slnx:37), so nothing in the published packages carries this migration. Because there is no model snapshot,dotnet ef migrations addcannot 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) ➡