Onboarding guide
5. CQRS: Commands, Queries & the Decorator Pipeline
What this group covers. Every write and every read in an MMCA application is a use case: a small, single-purpose object (a command or a query) handed to a handler that does exactly one thing. This group is the framework's implementation of CQRS (Command/Query Responsibility Segregation), the cross-cutting pipeline wrapped around it, and the generic write side that saves a module from hand-writing the same create/update/delete handler for every aggregate. There are six families:
- The two handler contracts,
ICommandHandler<in TCommand, TResult>andIQueryHandler<in TQuery, TResult>, plus the payload markers that describe a use case's shape:ICommandWithRequest<out TRequest>(a command that embeds a request DTO) andICreateRequest(the constraint that ties a create DTO to its mapper). - The decorator pipeline: seven command decorators
(
FeatureGateCommandDecorator<TCommand, TResult>,AuthorizationCommandDecorator<TCommand, TResult>,LoggingCommandDecorator<TCommand, TResult>,CachingCommandDecorator<TCommand, TResult>,ValidatingCommandDecorator<TCommand, TResult>,TimeoutCommandDecorator<TCommand, TResult>,TransactionalCommandDecorator<TCommand, TResult>), six query decorators (FeatureGateQueryDecorator<TQuery, TResult>,AuthorizationQueryDecorator<TQuery, TResult>,LoggingQueryDecorator<TQuery, TResult>,CachingQueryDecorator<TQuery, TResult>,ValidatingQueryDecorator<TQuery, TResult>,TimeoutQueryDecorator<TQuery, TResult>), an optional profiling pair (ProfilingCommandDecorator<TCommand, TResult>,ProfilingQueryDecorator<TQuery, TResult>), and the helpers they lean on:ResultFailureFactory,CqrsMetrics,TenantCacheKey, and the two lock tablesQueryCacheKeyLocksandCacheKeyLocks. - The opt-in marker interfaces that let one use case switch one concern on:
ITransactional,ICacheInvalidating,IQueryCacheable,IFeatureGated,IRequiresPermission, andIHasTimeout. - The generic write side: the load-mutate-save core
MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>with its per-run side channelMutationContext, the three result shapes built on it (MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>, its DTO-returning sibling, andMutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload>), the create workflow (CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>and its hook-free floorCreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>), the update and delete pairs (UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>,UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>,UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>,DeleteEntityCommand<TEntity, TIdentifierType>,DeleteEntityHandler<TEntity, TIdentifierType>), the child-collection pair (AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO>,RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType>), and the two applier contracts that keep field names out of the framework (IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>,IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>). - The declared-contract markers and their inspector:
ICommand<TResult>andIQuery<TResult>, read byCqrsContractInspector, which reports eachCqrsContractMismatchclassified byCqrsContractMismatchKind. - The Application-layer contracts that sit beside the pipeline, implemented in Infrastructure and
consumed by use cases:
ITenantContext(which tenant this scope runs as),IDistributedLock(mutual exclusion across replicas),IScheduledJob(recurring work on a cron schedule),IAuditTrailReader(the recorded change history of one entity),IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>(opt-in projection pushdown on list reads), and the event-versioning pairIEventUpcaster/IEventUpcasterRegistry.
This is the central column of [Rubric §6, CQRS & Event-Driven] (reads separated from writes,
intent-revealing use cases) and [Rubric §12, Performance & Scalability] (the place those concerns are
implemented once, uniformly, instead of scattered through handlers). The governing decision is
ADR-014, revised four times:
2026-07-19 for the transactional semantics, 2026-08-18 for the pipeline order (an Authorization
decorator between FeatureGate and Logging, a Timeout decorator between Validating and Transactional,
on both chains), 2026-08-26 for the query-side Validating decorator plus the sealed composition path,
and 2026-08-31 to correct the validator semantics. The ADR's own Status block warns that the order
printed in its Decision section is the pre-2026-08-18 one and points at the later revisions, so read
the revisions, not the decision, when you need the current chain.
The shape: thin handlers, fat pipeline
A handler is deliberately tiny. ICommandHandler<in TCommand, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandHandler.cs:9) and
IQueryHandler<in TQuery, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/IQueryHandler.cs:9) are one method each,
Task<TResult> HandleAsync(T, CancellationToken cancellationToken = default), with in
(contravariant) variance on the input (ICommandHandler.cs:17, IQueryHandler.cs:17). TResult is
almost always the Result or Result<T> of the
Result pattern
(ADR-013): a handler returns a failure
value, it does not throw for expected error paths. Commands mutate state; queries are
side-effect-free reads. Splitting the two into distinct interfaces is what lets the container apply a
different set of cross-cutting concerns to each (writes get a transaction, reads get result
caching), and it is the boundary [Rubric §1, SOLID] rewards: each handler has one reason to change,
and each decorator one responsibility.
Everything that is not the business logic of a use case lives outside the handler, in a stack of
decorators. A decorator implements the same handler interface, takes the next handler in through
its primary constructor (inner), does its cross-cutting job, and delegates. Because each decorator
is an ICommandHandler/IQueryHandler, they nest arbitrarily and the concrete handler at the
bottom never knows it is wrapped. This is the textbook Decorator pattern
([Rubric §2, Design Patterns]), applied at the application boundary so that logging, caching,
validation, feature flags, permission checks, execution budgets, and transactions are each written
once and reused by every handler in every module, and so that the same behavior applies whether the
call arrives over REST, gRPC, or an integration-event consumer.
How the pipeline is assembled (Scrutor, registration versus execution order)
The wiring lives in DependencyInjection.cs
(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:27), exposed as
extension(IServiceCollection services) members (the C# extension(T) syntax,
primer §4). The sequence a host must follow is
strict and ordered:
AddApplication()(DependencyInjection.cs:35) registers the core singletons: the domain event dispatcher, the upcaster registry, the navigation metadata provider and theEntityQueryPipeline(DependencyInjection.cs:37,:41,:43-44), then Common's own validators (DependencyInjection.cs:51).ScanModuleApplicationServices<TAssemblyMarker>()(DependencyInjection.cs:163, delegating to theAssembly-typed overload atDependencyInjection.cs:181) runs once per module and uses Scrutor assembly scanning to register domain and integration event handlers (singleton,DependencyInjection.cs:187-198), DTO mappers, DTO projectors, request mappers and both update appliers (scoped,DependencyInjection.cs:200-238), and every concreteICommandHandler<,>/IQueryHandler<,>(scoped,DependencyInjection.cs:240-250), plus FluentValidation validators (DependencyInjection.cs:252).AddApplicationDecorators()(DependencyInjection.cs:117) is called last. It uses Scrutor'sTryDecorateto wrap the already-registered handlers. This ordering is load-bearing:TryDecoratecan only wrap registrations that already exist, which is why decorators must come after every module's handler scan (DependencyInjection.cs:57-58).
The subtle rule is registration order versus execution order. TryDecorate applies decorators in
reverse registration order, so the last one registered becomes the outermost wrapper
(DependencyInjection.cs:60-61). The command registrations (DependencyInjection.cs:131-137), read
top to bottom, therefore list innermost-first, and the XML doc above them draws the resulting nesting
(DependencyInjection.cs:66-73):
FeatureGateCommandDecorator outermost (registered last)
-> AuthorizationCommandDecorator
-> LoggingCommandDecorator
-> CachingCommandDecorator
-> ValidatingCommandDecorator
-> TimeoutCommandDecorator
-> TransactionalCommandDecorator innermost (registered first)
-> ConcreteHandler the actual business logic
The query side (DependencyInjection.cs:140-145, drawn at DependencyInjection.cs:79-85) is the same
chain minus the transaction, since there is nothing to commit on a read:
FeatureGateQueryDecorator
-> AuthorizationQueryDecorator
-> LoggingQueryDecorator
-> CachingQueryDecorator
-> ValidatingQueryDecorator
-> TimeoutQueryDecorator
-> ConcreteHandler
The order is pinned by a test, not only by the comments. DecoratorPipelineOrderTestsBase
(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/DecoratorPipelineOrderTestsBase.cs:38) resolves both
handler types from a real ServiceCollection and unwraps the constructed object graph by reflection,
asserting the two sequences outermost-first (DecoratorPipelineOrderTestsBase.cs:72, :76, with the
final entry asserted not to be a decorator at DecoratorPipelineOrderTestsBase.cs:96). Both
expected lists are protected virtual (DecoratorPipelineOrderTestsBase.cs:49, :61), so a consumer
whose chain differs can override them; MMCA.Common subclasses the base against its own registration
sequence without overriding either list
(MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/Conformance/DecoratorPipelineOrderTests.cs:23, the real
registration sequence at DecoratorPipelineOrderTests.cs:38-40). That is [Rubric §14, Testability]
doing governance work: the diagram above cannot silently drift from the registrations below it.
Two later additions close the remaining hole, which was never the order but the timing. First, the
collection is sealed: AddApplicationDecorators() ends by adding a private marker type
(DependencyInjection.cs:147, SealPipeline at DependencyInjection.cs:714), and every registration
entry point that adds handlers (ScanModuleApplicationServices, AddEntityCrud,
AddEntityUpdateVerb, AddEntityUpdate, AddMmcaApplicationPipeline) calls ThrowIfPipelineSealed
first, so a late module scan throws an InvalidOperationException that says exactly what went wrong
instead of silently producing an undecorated handler (DependencyInjection.cs:717-726). Second,
AddMmcaApplicationPipeline(...) (DependencyInjection.cs:614) runs the whole sequence in the one
order that works: AddApplication(), then the caller's registrations through an
MmcaApplicationPipelineBuilder,
then AddApplicationDecorators() (DependencyInjection.cs:616-622). Seven production service hosts
compose it that way (for example
MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:348 and
MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:233), while MMCA.Helpdesk's web
host still writes the hand-composed form and ends with the decorators call
(MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:120). Alongside it,
VerifyDecoratorPipeline() (DependencyInjection.cs:651) is a registration-shape assertion an
architecture fitness test can call: it never builds a provider, it just looks for a surviving
non-keyed descriptor that still carries an implementation type, which after TryDecorate is proof
that nothing wrapped it (DependencyInjection.cs:662-693). MMCA.Store's architecture tests call it
today (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/DecoratorPipelineOrderTests.cs:66).
A separate, optional call layers MiniProfiler on top: AddApplicationProfiling()
(DependencyInjection.cs:567-571) registers
ProfilingCommandDecorator<TCommand, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ProfilingCommandDecorator.cs:12,
one MiniProfiler.Current?.Step(...) around the inner call at ProfilingCommandDecorator.cs:18) and
its read twin ProfilingQueryDecorator<TQuery, TResult>
(.../Decorators/ProfilingQueryDecorator.cs:11, :17). No host in this workspace calls it today: the
only call sites are the framework's own DependencyInjectionTests
(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:113, :119),
which matches ADR-014's note
that the profiling pair is opt-in and unwired.
Why this exact order, and what each layer guards
The nesting order is a deliberate cost-and-correctness argument, spelled out in the registration
XML-doc (DependencyInjection.cs:90-112):
- Feature-gating is outermost so a disabled feature is rejected with zero downstream work: no
permission check, no log scope, no cache touch, no validation, no budget, no transaction
(
DependencyInjection.cs:91-94).FeatureGateCommandDecorator<TCommand, TResult>(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/FeatureGateCommandDecorator.cs:20) and its read twinFeatureGateQueryDecorator<TQuery, TResult>(.../Decorators/FeatureGateQueryDecorator.cs:18) callIFeatureManager.IsEnabledAsynconly when the use case opts in viaIFeatureGated(FeatureGateCommandDecorator.cs:50-53,FeatureGateQueryDecorator.cs:48-51) and short-circuit with aNotFoundfailure carrying the codeFeature.Disabled(FeatureGateCommandDecorator.cs:56-58,FeatureGateQueryDecorator.cs:54-56). A disabled feature reads as "this does not exist" rather than "you may not", which is the deliberate posture of ADR-031, and it is also why the gate stays outside authorization: an off feature must answer identically for every caller instead of leaking which permission guards it (DependencyInjection.cs:91-94). - Authorization sits directly inside the gate and outside caching, so a denied request neither
reads nor populates the cache (
DependencyInjection.cs:95-97).AuthorizationCommandDecorator<TCommand, TResult>(.../Decorators/AuthorizationCommandDecorator.cs:26) andAuthorizationQueryDecorator<TQuery, TResult>(.../Decorators/AuthorizationQueryDecorator.cs:21) takeICurrentUserServiceandIPermissionRegistry, pass straight through when the use case does not implementIRequiresPermission(AuthorizationCommandDecorator.cs:58-59,AuthorizationQueryDecorator.cs:53-54), and otherwise ask the registry whether any of the caller's roles grants the named permission (AuthorizationCommandDecorator.cs:61,AuthorizationQueryDecorator.cs:56). When none does, the decorator returns aForbiddenErrorwith the codeAuthorization.PermissionDeniedwithout invoking the handler (AuthorizationCommandDecorator.cs:67-71,AuthorizationQueryDecorator.cs:62-66) and counts the denial onCqrsMetrics(AuthorizationCommandDecorator.cs:65,AuthorizationQueryDecorator.cs:60). Both decorators are registered unconditionally and both take anIPermissionRegistry, soAddApplicationDecorators()TryAddsUnconfiguredPermissionRegistryfirst (DependencyInjection.cs:126): a host that declared its own grants keeps its registry, and a host with no permission model still resolves every handler. This is defense in depth beside the endpoint's[Authorize]policy rather than a replacement for it: the capability check travels with the use case, so a command reached over gRPC, from a scheduled job, or from another module is checked the same way it is over HTTP. That is[Rubric §11, Security]moving inward, and it is the pipeline-side surface of ADR-020. - Logging sits just inside authorization so it measures only enabled, permitted executions
(
DependencyInjection.cs:98).LoggingCommandDecorator<TCommand, TResult>(.../Decorators/LoggingCommandDecorator.cs:15) opens a source-generated structured-logging scope carrying the command name, the owning module and theCorrelationIdfromICorrelationContext(LoggingCommandDecorator.cs:26, scope shape atLoggingCommandDecorator.cs:67-69). The module name is derived once per closed generic type throughModuleNameConventions, so every log line a handler emits can be filtered by module without paying for a namespace parse per execution (LoggingCommandDecorator.cs:76). It times the whole inner pipeline withStopwatch.GetTimestamp()/Stopwatch.GetElapsedTimerather than aStopwatchinstance (one fewer allocation per command,LoggingCommandDecorator.cs:33,:37), and separates three outcomes:completed(Information),failed(aResultin a failure state, Warning with an error summary), andexception(Error, then rethrown), atLoggingCommandDecorator.cs:39-59with the levels declared atLoggingCommandDecorator.cs:86-95. Each outcome is also recorded to theCqrsMetricsduration histogram taggedcommandandoutcome(LoggingCommandDecorator.cs:78-82). This is the RED (Rate, Errors, Duration) anchor of[Rubric §13, Observability & Operability](ADR-041). The read sideLoggingQueryDecorator<TQuery, TResult>(.../Decorators/LoggingQueryDecorator.cs:14) is the same shape againstCqrsMetrics.QueryDuration(LoggingQueryDecorator.cs:76-80), with one calibration difference: a completed query logs at Debug rather than Information (LoggingQueryDecorator.cs:82), because reads are the high-volume half. - Cache invalidation sits outside validation and outside the transaction, so the cache is only
cleared after a valid, committed mutation (
DependencyInjection.cs:104-105).CachingCommandDecorator<TCommand, TResult>(.../Decorators/CachingCommandDecorator.cs:31) callsICacheService.RemoveByPrefixAsynconly when the command opts in viaICacheInvalidating, its prefix is non-blank, and the result is not a failure (CachingCommandDecorator.cs:59-61). Three details there are worth memorizing: the blank-prefix guard is the opt-out and a safety catch, sinceRemoveByPrefixAsync("")would evict the entire cache (CachingCommandDecorator.cs:56-58); the eviction runs withCancellationToken.Noneand swallows every fault into a warning, because the command has already committed and a cache outage must not turn a committed write into a failure (CachingCommandDecorator.cs:69-71,CachingCommandDecorator.cs:80-87); and a second, delayed eviction fires afterReInvalidationDelay(5 seconds by default,CachingCommandDecorator.cs:43) to remove an entry that an in-flight read repopulated with pre-write state (CachingCommandDecorator.cs:73-79). That follow-up task is held on an internal property rather than dropped, so it is observed and a test can await it deterministically (CachingCommandDecorator.cs:49). On the read side,CachingQueryDecorator<TQuery, TResult>(.../Decorators/CachingQueryDecorator.cs:41) serves hits without touching the handler (CachingQueryDecorator.cs:71-76), stores only non-failure results (CachingQueryDecorator.cs:115-129), and is fail-open throughout: a failed read is logged and treated as a miss, a failed populate returns the answer uncached, and onlyOperationCanceledExceptionescapes either guard (CachingQueryDecorator.cs:122,:215). Both halves are the pipeline's[Rubric §12, Performance & Scalability]story (ADR-026). - Validation sits outside the budget and, on the write side, outside the transaction, so a
malformed command never spends its timeout allowance or opens a database transaction
(
DependencyInjection.cs:99-103).ValidatingCommandDecorator<TCommand, TResult>(.../Decorators/ValidatingCommandDecorator.cs:31) materializesIEnumerable<IValidator<TCommand>>into an array (ValidatingCommandDecorator.cs:36), passes straight through when it is empty (ValidatingCommandDecorator.cs:64-67), and otherwise runs every registered validator sequentially, unions their failures, and returns a typed failure without ever calling the handler (ValidatingCommandDecorator.cs:72-93). Running them all rather than only the first is deliberate: a command commonly carries a module-authored validator beside a framework one, and honoring only the first turns the others into silently unenforced rules (ValidatingCommandDecorator.cs:17-22); the loop is sequential because a validator may read through a scoped repository and aDbContextis not thread-safe (ValidatingCommandDecorator.cs:69-71).ValidatingQueryDecorator<TQuery, TResult>(.../Decorators/ValidatingQueryDecorator.cs:34) is the same shape for reads (ValidatingQueryDecorator.cs:68-96), so a query carrying paging, filter or sort input rejects a malformed request instead of pushing bad values into the data source. It sits inside caching on purpose: a cached entry can only exist because the same query already passed validation when the entry was produced, so re-validating on a hit spends work to reach a conclusion already reached (ValidatingQueryDecorator.cs:20-30). Commands that embed a request DTO viaICommandWithRequest<out TRequest>get a validator wired automatically: the module scan reflects over the assembly andTryAdds aCommandRequestValidator<TCommand, TRequest>for each (DependencyInjection.cs:256-269), withTryAddsemantics so an explicitIValidator<TCommand>always wins (DependencyInjection.cs:254-255), andAddCommandRequestValidator<TCommand, TRequest>()(DependencyInjection.cs:477-480) is the explicit form for a closed generic command the scan cannot see. That whole story belongs to G06, Validation ([Rubric §24, Forms, Validation & UX Safety]). - The timeout budget sits inside validation and outside the transaction, so it covers the database
work that actually hangs, does not charge the caller for validation, and cancels the transaction
rather than leaving it open (
DependencyInjection.cs:106-109).TimeoutCommandDecorator<TCommand, TResult>(.../Decorators/TimeoutCommandDecorator.cs:33) passes through unless the command implementsIHasTimeoutwith a positive budget (TimeoutCommandDecorator.cs:63-64), otherwise links a freshCancellationTokenSourceto the caller's token and callsCancelAfter(TimeoutCommandDecorator.cs:66-67). Thewhenclause on the catch is the whole design: only a cancellation raised by the decorator's own source, with the caller's token still un-cancelled, becomes a failure result (TimeoutCommandDecorator.cs:73), so a genuinely aborted request still surfaces exactly as the inner handler would. An expired budget is reported asError.Failure("Request.TimedOut", ...)(TimeoutCommandDecorator.cs:79-84) because the framework'sErrorTypetaxonomy maps to HTTP status codes and has no member for 408 or 504, so the machine-readable code, not the type, is what callers branch on; the expiry is also counted onCqrsMetrics(TimeoutCommandDecorator.cs:76). The read twinTimeoutQueryDecorator<TQuery, TResult>(.../Decorators/TimeoutQueryDecorator.cs:33) is line-for-line identical but sits innermost on the query side, so a cache hit is served without starting a budget at all. This is[Rubric §29, Resilience & Business Continuity]expressed per use case rather than per host. - Transaction is innermost (closest to the handler) so the unit-of-work boundary is as tight as
possible.
TransactionalCommandDecorator<TCommand, TResult>(.../Decorators/TransactionalCommandDecorator.cs:18) is sixteen lines (18-33): pass through unless the command implementsITransactional(TransactionalCommandDecorator.cs:26-27), otherwise hand the inner call toIUnitOfWork.ExecuteInTransactionAsync(TransactionalCommandDecorator.cs:29-31). Everything interesting happens on the other side of that call, inDbContextFactory([Rubric §8, Data Architecture]), and it is worth reading: a returned failedResultrolls the transaction back, exactly like an exception (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:564-570); the call is re-entrant, so a nested transaction joins the ambient one and only the outermost call begins, commits, or rolls back (DbContextFactory.cs:511-512); in-process domain event dispatch is deferred until after a successful commit and dropped on rollback (DbContextFactory.cs:579-584); and a failure of the commit itself is never retried, surfacing asTransactionCommitAmbiguousExceptioninstead (DbContextFactory.cs:481-486,DbContextFactory.cs:573-575).
Opt-in by marker interface, pay only for what you use
The pipeline is registered for every handler, but most decorators are dormant unless the use case
asks for them. The switch is a set of tiny marker / role interfaces in
MMCA.Common.Application.UseCases:
ITransactional(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/ITransactional.cs:6): an empty marker, declared with the interface-body-less syntax, meaning "open a transaction".ICacheInvalidating(.../UseCases/ICacheInvalidating.cs:8): exposes aCachePrefixstring to evict after success (ICacheInvalidating.cs:14).IQueryCacheable(.../UseCases/IQueryCacheable.cs:8): exposes aCacheKeyplus aCacheDuration(IQueryCacheable.cs:14,:19).IFeatureGated(.../UseCases/IFeatureGated.cs:10): exposes aFeatureNamethat must match a key in theFeatureManagementconfiguration section (IFeatureGated.cs:16).IRequiresPermission(.../UseCases/IRequiresPermission.cs:16): exposes thePermissionstring the caller must hold (IRequiresPermission.cs:23). An unknown permission is granted by no role and therefore denies every caller, which is a deliberate fail-closed default (IRequiresPermission.cs:20-21).IHasTimeout(.../UseCases/IHasTimeout.cs:14): exposes aTimeSpan Timeout(IHasTimeout.cs:21). A value at or belowTimeSpan.Zeromeans "no budget" rather than "fail instantly", so a misconfigured value degrades to the previous behavior instead of breaking every request (IHasTimeout.cs:16-19).
Each decorator does an is-check (command is not ITransactional, query is not IQueryCacheable,
and so on) and passes straight through when the interface is absent. This is
[Rubric §2, Design Patterns] (marker interfaces as declarative opt-in) layered with
[Rubric §1, SOLID] Open/Closed: a new handler turns a concern on by implementing an interface, with
no decorator, registration, or pipeline change. A command that reads nothing pays nothing for
transactions, and an uncached query pays nothing for caching, while the capability is uniformly
present.
Adoption is honest about that, and it is uneven. IQueryCacheable is wired and unit-tested, but
exactly one production query implements it today, ADC's GetNowNextQuery
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23,
a 30-second TTL at :38), plus the reference apps (Helpdesk's GetTicketByIdQuery,
MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdQuery.cs:23
with a 5-minute TTL at :29, and the ECommerce sample's GetProductByIdQuery,
MMCA.ECommerce/Source/Modules/Products/MMCA.ECommerce.Products.Application/Products/UseCases/GetById/GetProductByIdQuery.cs:23,
and GetOrderByIdQuery,
MMCA.ECommerce/Source/Modules/Orders/MMCA.ECommerce.Orders.Application/Orders/UseCases/GetById/GetOrderByIdQuery.cs:23).
MMCA.Store has no IQueryCacheable query at all; its public reads cache at the HTTP OutputCache
layer instead
(ADR-040).
The two newest markers are further back still: no use case in MMCA.ADC, MMCA.Store or MMCA.Helpdesk
implements IRequiresPermission or IHasTimeout yet, so both decorators are exercised only by the
framework's own tests
(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs,
.../Decorators/AuthorizationQueryDecoratorTests.cs, .../Decorators/TimeoutCommandDecoratorTests.cs,
.../Decorators/TimeoutQueryDecoratorTests.cs). The capability shipped; the adoption has not started.
The generic write side: three verbs a module does not have to write
Below the pipeline sits a second body of shared code: the workflows every aggregate write repeats. They are ordinary handlers, so the decorators wrap them exactly as they wrap a hand-written one.
The create workflow is
CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:42): prepare
the request, map it through the module's
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>
(which runs the entity factory), add, save, log, and map the result through the module's
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
(CreateEntityHandlerBase.cs:84-102). Every step is a virtual hook, so
CreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>
(.../UseCases/CreateEntityHandler.cs:28) is the base with nothing overridden: an aggregate whose
create needs no pre-map step, no module-specific log line and no post-commit publish needs no
subclass at all. It is sealed on purpose, because the base, not this, is the extension point
(CreateEntityHandler.cs:14-19). CreateCoreAsync takes the unit of work as a parameter
(CreateEntityHandlerBase.cs:77-81), which is what lets a manual-id create wrap the whole workflow in
a retry loop and run each attempt against a fresh DI scope's unit of work; the ambient DbContext
still tracks the failed insert, so a retry on the injected one would never persist.
The mutate workflow is
MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>
(.../UseCases/MutateEntityHandlerBase.cs:51): resolve the repository from
IUnitOfWork (MutateEntityHandlerBase.cs:279), load
the aggregate tracked and with the includes the mutation needs (MutateEntityHandlerBase.cs:280),
fail with a NotFound Error stamped with the handler
name and entity type when it is gone (MutateEntityHandlerBase.cs:282), stamp the caller's
optimistic-concurrency token back as the original so a stale edit fails the save rather than winning
it (ADR-035,
MutateEntityHandlerBase.cs:290-291), run the domain mutation
(MutateEntityHandlerBase.cs:293-295), and save only when it succeeded
(MutateEntityHandlerBase.cs:302), then log and run the post-save hook
(MutateEntityHandlerBase.cs:304-305). The core deliberately does not implement
ICommandHandler<in TCommand, TResult>
(MutateEntityHandlerBase.cs:16-27): a single type advertising two handler interfaces would register
a bogus second handler entry during the module scan. Three thin subclasses supply the three shapes a
real handler answers with: a bare Result for verb-style commands
(MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>,
MutateEntityHandlerBase.cs:319), a Result<TEntityDTO> carrying the refreshed DTO
(MutateEntityHandlerBase.cs:342), and a Result<TResultPayload> the handler builds itself
(MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload>,
MutateEntityHandlerBase.cs:387, with the abstract BuildResult at MutateEntityHandlerBase.cs:418).
MutationContext (.../UseCases/MutationContext.cs:31) is the per-command side
channel that makes those hooks composable without handler instance state, which a scoped handler must
not carry between calls (MutationContext.cs:11-18). It is a typed bag (Set, TryGet,
GetOrDefault, Contains at MutationContext.cs:56, :69, :93, :99) threaded through load,
mutate and the post-save hooks and finally into the result builder, so a value the mutation derived
while the aggregate was loaded (its pre-mutation state, the blob about to be orphaned, a warning the
caller has to see) can reach the response. It also carries the short-circuit: SkipSave()
(MutationContext.cs:49) marks the command already satisfied, and the workflow then returns the
loaded aggregate as a success with no save, no LogMutated and no OnMutatedAsync
(MutateEntityHandlerBase.cs:297-300). That is the idempotent no-op (remove an avatar that is not
there, close an already-closed record), which is a success rather than a refused invariant and must
not log a mutation that did not happen.
The update path is where the generic write side pays off most.
UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>
(.../UseCases/UpdateEntityCommand.cs:46) carries the id, the request and the caller's If-Match
row version, implements ICommandWithRequest<out TRequest> so the
validator bridge finds it, and implements ICacheInvalidating with a defaulted
CachePrefix of typeof(TEntity).FullName + ":" (UpdateEntityCommand.cs:63), because the generic
controller constructs the command itself and cannot supply one. The record is not sealed
(UpdateEntityCommand.cs:25-35): a real update often carries state beside the request (a route-derived
child id, a server-decided flag, a second concurrency token), and a derived positional record inherits
everything.
UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>
(.../UseCases/UpdateEntityHandler.cs:48) is that command's handler: it maps EntityId and
RowVersion off the command and implements MutateAsync as one call into the module's
IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>
(UpdateEntityHandler.cs:84-92), which is the piece that knows field names. The derived-command twin
UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>
(UpdateEntityHandler.cs:185) runs the same workflow but hands the whole command to an
IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>
(.../UseCases/IEntityUpdateCommandApplier.cs:38, applied at UpdateEntityHandler.cs:218-223), so
state the server decided stays on the command instead of being smuggled into a request DTO a caller
could set (IEntityUpdateCommandApplier.cs:14-21). Both appliers answer with a bare
Result, because the instance handed in is the tracked
one: a successful apply has already mutated it, and a refusal must leave it untouched
(IEntityUpdateCommandApplier.cs:28-32,
MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:79). Neither handler
raises events: domain events belong to the aggregate's own mutation methods, which is what keeps the
generic path and a hand-written one indistinguishable from the outside (UpdateEntityHandler.cs:33-37).
Delete is the shortest complete example.
DeleteEntityCommand<TEntity, TIdentifierType>
(.../UseCases/DeleteEntityCommand.cs:11) is a one-property record with the same defaulted cache
prefix and the same empty-string opt-out (DeleteEntityCommand.cs:14-20), and its TEntity parameter
earns its keep twice: it distinguishes DeleteEntityCommand<Session, int> from
DeleteEntityCommand<Speaker, int> so DI routes each to its own handler, and it supplies that
prefix (DeleteEntityCommand.cs:3-7).
DeleteEntityHandler<TEntity, TIdentifierType>
(.../UseCases/DeleteEntityHandler.cs:35) loads the aggregate (DeleteEntityHandler.cs:70-71),
returns NotFound when the row is missing (DeleteEntityHandler.cs:73), gives a pre-delete
invariant its chance to refuse through OnDeletingAsync (DeleteEntityHandler.cs:75-77), calls the
aggregate's own Delete() (which enforces invariants and may raise domain events), and saves only
when that succeeded (DeleteEntityHandler.cs:79-85). It is left unsealed and split into overridable
steps because the two things a real delete outgrows are structural rather than behavioral: the child
collections the aggregate's cascade has to see (Includes, DeleteEntityHandler.cs:59) and a
cross-aggregate refusal (DeleteEntityHandler.cs:12-21).
Two more bases cover child collections.
AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO>
(.../UseCases/ChildEntityHandlerBase.cs:30) loads the parent tracked with its child collection
included, calls the aggregate method that owns the invariant, saves on success, and maps the new child
through an abstract MapChild rather than an injected DTO mapper, because the DTO belongs to the
child, whose identifier type is usually not the parent's (ChildEntityHandlerBase.cs:19-23,
workflow at ChildEntityHandlerBase.cs:101-125). Includes is abstract on purpose: loading the child
collection is what makes the aggregate's duplicate check meaningful, so naming it has to be a
deliberate act, and an unloaded collection turns a double submit into a raw unique-index 409
(ChildEntityHandlerBase.cs:13-18, :105-106).
RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType>
(ChildEntityHandlerBase.cs:142) is the mutate workflow with Includes promoted from virtual to
abstract for the same reason: a remove that cannot see the collection cannot find the child and
reports the wrong NotFound (ChildEntityHandlerBase.cs:147-151).
Registration for all of this is three extension methods, each of which registers closed, not
open-generic, service types (Scrutor's TryDecorate wraps concrete service types, and an open
registration would resolve completely undecorated) and each of which uses TryAdd, so a module that
outgrows one verb registers its own handler ahead of the call and keeps the generic pair for the other
two. AddEntityCrud<...>() (DependencyInjection.cs:331) registers create, update and delete plus
the update command's validator bridge (DependencyInjection.cs:339-354);
AddEntityUpdateVerb<...>() (DependencyInjection.cs:393) registers one verb of the applier-
discriminated update (DependencyInjection.cs:401-409); and AddEntityUpdate<...>()
(DependencyInjection.cs:444) registers the derived-command handler
(DependencyInjection.cs:452-456). All three are adopted: MMCA.Helpdesk's Tickets module calls
AddEntityCrud after its scan
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/DependencyInjection.cs:54,
with the ordering rationale at :41), and MMCA.Store's Catalog, Sales and Identity modules use the
same idiom. This whole family is [Rubric §15, Best Practices & Code Quality] and [Rubric §5, Vertical Slice]
pulling in the same direction: a straightforward CRUD slice becomes a request DTO, a mapper, an
applier and one registration line, while the moment a slice needs something of its own it drops back
to the base class and keeps the shared workflow.
Declaring the contract: the opt-in request markers
ICommand<TResult> (.../UseCases/ICommand.cs:31) and
IQuery<TResult> (.../UseCases/IQuery.cs:26) let a request DTO declare the result
type its handler produces. Both are body-less interfaces whose single type parameter is a phantom:
nothing consumes it in a member, and the Sonar suppression on each says exactly why
(ICommand.cs:27-30, IQuery.cs:22-25). They are purely additive. Handlers keep implementing
ICommandHandler<in TCommand, TResult>, nothing in the
registration or the decorator pipeline reads them, and a request that does not carry one behaves
identically (ICommand.cs:14-19).
The pay-off is CqrsContractInspector
(.../UseCases/CqrsContractInspector.cs:72), a reflection-only static that walks the concrete types in
a set of assemblies (CqrsContractInspector.cs:83-99), reads each handler interface it implements
(CqrsContractInspector.cs:103-119), and compares the handler's TResult against the marker the
request carries (CqrsContractInspector.cs:122-148). It reports two things, classified by
CqrsContractMismatchKind (CqrsContractInspector.cs:8): a
ResultType disagreement, and a HandlerKind one where a request declared as a command is handled by
an IQueryHandler or vice versa. Each finding is a
CqrsContractMismatch record (CqrsContractInspector.cs:31) that renders
itself as a fitness-test failure line through Describe() (CqrsContractInspector.cs:42-50). Two
design choices keep it usable: a request carrying no marker is ignored entirely, so adoption stays
gradual and a repo with zero adoption sees an empty list rather than a wall of failures
(CqrsContractInspector.cs:59-65), and open generic handler definitions (the framework's own
decorators and every generic handler base above) are skipped, because their request type argument is a
type parameter rather than a request (CqrsContractInspector.cs:66-70, filter at
CqrsContractInspector.cs:91). The inspector degrades gracefully when a missing transitive reference
makes Assembly.GetTypes() throw, keeping the types that did load
(CqrsContractInspector.cs:169-179).
That is [Rubric §9, API & Contract Design] and [Rubric §14, Testability] offered rather than
imposed, and the honest status is that nothing has taken the offer yet: no MMCA.ADC, MMCA.Store or
MMCA.Helpdesk request implements either marker, and FindContractMismatches is called only from the
framework's own
MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/UseCases/CqrsContractInspectorTests.cs.
Tenant scoping and the two lock tables
Both caching decorators are multi-tenant aware, and the reason is a nice illustration of where a
cross-cutting concern has to live. ICacheService is a singleton and therefore cannot see the
scoped tenant, so two tenants computing the same cache key would serve each other's rows. Isolation
is applied where the key is computed instead: TenantCacheKey
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:25) turns a
key or prefix into t:{tenantId}:{key} when ITenantContext
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:22) reports a resolved
tenant, and returns it untouched when it does not (TenantCacheKey.cs:37-40, marker constant at
TenantCacheKey.cs:28). The scoped form is a prefix, not a suffix, precisely so prefix eviction
keeps working: a command's invalidation can only reach its own tenant's entries
(TenantCacheKey.cs:15-19). Because the query decorator uses the same helper for its reads
(CachingQueryDecorator.cs:55-56) and the command decorator for its evictions
(CachingCommandDecorator.cs:64), reads and invalidations stay symmetric by construction.
ITenantContext is injected as an optional constructor parameter defaulting to null
(CachingQueryDecorator.cs:45, CachingCommandDecorator.cs:35), so a single-tenant host keeps
byte-identical cache keys to the pre-tenancy framework
(ADR-073); the interface itself
exposes TenantId and IsResolved (ITenantContext.cs:28, :31), treats an unresolved tenant as a
meaningful state rather than inventing a fallback value (ITenantContext.cs:10-15), and refuses to
change tenant mid-scope, accepting the value it already holds and throwing on a different one
(ITenantContext.cs:16-20, :33-41).
The read path also guards against cache stampede. On a miss,
CachingQueryDecorator<TQuery, TResult> takes a per-key lock
and re-checks the cache inside it, so on expiry of a hot key exactly one caller runs the handler and
the rest are served the fresh entry (CachingQueryDecorator.cs:95-102). The wait on that lock is
bounded by Cache:PopulateLockTimeout, bound into
QueryCachePipelineSettings
(MMCA.Common/Source/Core/MMCA.Common.Application/Settings/QueryCachePipelineSettings.cs:20), whose
default is Timeout.InfiniteTimeSpan (QueryCachePipelineSettings.cs:29, :42): the pre-setting
behavior. A waiter that exhausts a finite budget is fail-open like every other cache failure here, so
it runs the handler itself and returns the answer deliberately uncached, leaving the entry to the
request that holds the lock (CachingQueryDecorator.cs:84-93). The miss counter is incremented once,
at the point where execution actually falls through to the handler rather than at either cache read,
so a request that misses the fast path and the double-check is not counted twice
(CachingQueryDecorator.cs:104-110). The lock table is
QueryCacheKeyLocks (.../Decorators/CachingQueryDecorator.cs:244), a
non-generic holder around a KeyedSemaphoreStripe so that
every closed generic decorator shares one table rather than one per closed type
(CachingQueryDecorator.cs:223-247). Its sibling CacheKeyLocks
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:142) does the same job
for the default ICacheService.GetOrCreateAsync implementation, and is deliberately a separate
table: different call sites over different keys, where sharing stripes would only widen the
unrelated-key collisions striping already tolerates (ICacheService.cs:134-140). Both are striped
rather than one semaphore per key, and both are honest about the limit: the lock is per process, so
across replicas stampede protection is at most one handler execution per instance, not one
cluster-wide (CachingQueryDecorator.cs:236-241).
Two supporting pieces
Two small helpers make the short-circuit decorators possible.
ResultFailureFactory
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ResultFailureFactory.cs:11)
builds a delegate that manufactures a TResult failure from an error list, taking a direct cast for
non-generic Result (ResultFailureFactory.cs:22-25), compiling an expression tree once per closed
Result<T> (ResultFailureFactory.cs:27-41), and throwing InvalidOperationException for anything
else (ResultFailureFactory.cs:43-45). All five short-circuiting decorator families (feature gate,
authorization, command validation, query validation, timeout) cache that delegate in a static field
but build it lazily, on the first short-circuit, not in a static constructor: since Scrutor's
TryDecorate is unconditional, an eager initializer turned an unsupported TResult into a
TypeInitializationException at resolve time for a handler that never short-circuits
(FeatureGateCommandDecorator.cs:38-45, AuthorizationCommandDecorator.cs:46-53,
ValidatingCommandDecorator.cs:43-58, ValidatingQueryDecorator.cs:46-63,
TimeoutCommandDecorator.cs:51-58). That repeated remark is a good example of the guide's general
rule: read the remarks, they usually record a bug that was paid for once.
CqrsMetrics (.../Decorators/CqrsMetrics.cs:21) is the internal static holder of
the MMCA.Common.Cqrs OpenTelemetry meter (CqrsMetrics.cs:24-26) and its six instruments: command
and query duration histograms in milliseconds (CqrsMetrics.cs:29-38), the query cache hit/miss
counters the caching decorator increments (CqrsMetrics.cs:41-50, recorded at
CachingQueryDecorator.cs:74, :91, :100, :110), and the two short-circuit counters,
cqrs.authorization.denied.count and cqrs.timeout.count, both tagged request_type
(CqrsMetrics.cs:53-62, recorded through RecordAuthorizationDenied and RecordTimeout at
CqrsMetrics.cs:76-82). A permission that is denying far more traffic than expected, or a handler
that keeps exhausting its budget, is therefore visible as a metric rather than only as a client-side
error rate (CqrsMetrics.cs:15-19). The meter name is duplicated as a literal in MMCA.Common.Aspire
because that package has no reference to Application (CqrsMetrics.cs:8-10), which is the one place
this group's metrics leak into another layer.
The other Application-layer contracts in this group
Several contracts sit beside the pipeline rather than inside it. They are declared here, in the Application layer, and implemented in Infrastructure or the composition root, which is what keeps a use case that depends on one extractable into its own service.
IDistributedLock
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IDistributedLock.cs:30) is best-effort
mutual exclusion on a logical key across every replica of a service. Its single method
TryAcquireAsync(key, ttl, wait, cancellationToken) returns an IAsyncDisposable handle or null
when the key was still held after wait elapsed (IDistributedLock.cs:59-63). The XML doc is
explicit about the three things that make it safe to use: it is not reentrant
(IDistributedLock.cs:19-22), the TTL is a crash guard rather than a lease you may rely on, so a
paused holder can lose the lock without knowing (IDistributedLock.cs:37-42), and release is
owner-scoped and idempotent (IDistributedLock.cs:54-57). No decorator takes it; its in-framework
caller is the API idempotency filter, which needs its execute-then-store window to be exclusive across
replicas (IdempotencyFilter,
MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:148, acquisition at
IdempotencyFilter.cs:250,
ADR-017), and the implementation
(RedisDistributedLock or the
InProcessDistributedLock fallback)
is chosen at the composition root (G14).
IScheduledJob (.../Interfaces/IScheduledJob.cs:36) is recurring work driven by a
five-field cron expression, with three members: a stable Name that doubles as the primary key of the
persisted job row (IScheduledJob.cs:44), a default CronExpression a host may override per job
through Scheduler:Jobs:{Name}:Cron (IScheduledJob.cs:68, :65), and ExecuteAsync
(IScheduledJob.cs:78). Occurrences are computed against the UTC clock, never a local or
configured time zone, so a schedule never shifts, doubles, or vanishes across a daylight saving
transition (IScheduledJob.cs:58-62). Four behaviors documented on the interface shape how you write
one: jobs resolve scoped, in a fresh DI scope per execution, so they may take a unit of work and
must hold no state between runs (IScheduledJob.cs:10-14); a claim lease in the job store makes an
occurrence run exactly once across replicas (IScheduledJob.cs:17-19); missed occurrences do not
pile up, so work that must not be skipped has to be idempotent and range-driven rather than
one-run-per-tick (IScheduledJob.cs:26); and a thrown exception is caught, logged, and stamped as a
failed outcome without retry inside the occurrence (IScheduledJob.cs:31). The runner lives in
ScheduledJobRunner
(ADR-074).
IAuditTrailReader (.../Interfaces/IAuditTrailReader.cs:20) reads the
recorded change history of one entity, keyed by the entity's full CLR type name and the invariant
string form of its primary key (composite keys joined in model key order), paged and newest first
(IAuditTrailReader.cs:23, :30, :37, ordering note at IAuditTrailReader.cs:17). It is registered
only by AddAuditTrail, so a host that never opted in has nothing to resolve
(IAuditTrailReader.cs:6), and the framework deliberately ships the read without an endpoint or page,
because who may see an entity's history is an application decision (IAuditTrailReader.cs:11). The
implementation is AuditTrailReader over the rows
written by
AuditTrailSaveChangesInterceptor
(ADR-075); this is
[Rubric §30, Compliance, Privacy & Data Governance] territory.
IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>
(.../Interfaces/IEntityDTOProjector.cs:51) is the opt-in pushdown counterpart of
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>:
a mapper maps rows after they materialize, so the query must select whole entities, while a
projector rewrites the queryable so the provider selects the DTO's columns directly
(IEntityDTOProjector.cs:11-15). Its one method is
IQueryable<TEntityDTO> ProjectTo(IQueryable<TEntity> source) (IEntityDTOProjector.cs:62), and the
implementation must stay translatable: no materializing inside it (IEntityDTOProjector.cs:58), and
no instance sub-mappers, custom mapping methods, or after-map hooks, because a projection is an
expression tree the database provider has to translate (IEntityDTOProjector.cs:38-39). Registering
one is the whole opt-in: the module scan picks it up scoped beside the mappers
(DependencyInjection.cs:207-211), and
EntityQueryService<TEntity, TEntityDTO, TIdentifierType>
declares a second, longer constructor purely so the container selects the projected path when one is
registered and the plain path when it is not. Because the two paths are chosen by registration, a
projector that disagrees with its mapper would make a response depend on which one happened to be
wired, which is why the contract says to pin the equivalence with a test
(IEntityDTOProjector.cs:43-45). That is [Rubric §12, Performance & Scalability] again, this time on
the read path (ADR-034).
IEventUpcaster (.../Interfaces/IEventUpcaster.cs:28) and
IEventUpcasterRegistry (.../Interfaces/IEventUpcasterRegistry.cs:24)
are the versioning contracts for integration events, declared in this layer because both delivery
paths consume them. A breaking event-shape change is a new event type plus a consumer-side
upcaster, never a silent reshape of the existing type
(ADR-010), and the
typed IEventUpcaster<in TSource, out TTarget> (IEventUpcaster.cs:67) is the one an application
writes: it supplies SourceType, TargetType, and the non-generic Upcast as default interface
implementations (IEventUpcaster.cs:72-75), so the class body is the single typed conversion method
(IEventUpcaster.cs:82). Registration is one call,
services.AddEventUpcaster<TSource, TTarget, TUpcaster>() (DependencyInjection.cs:551-557), which
appends a singleton to an enumerable so several upcasters compose into a chain. The registry
(IEventUpcasterRegistry.cs:24) is the composed view: it probes whether a type has an upcaster
(IEventUpcasterRegistry.cs:31), resolves the terminal (newest) contract by walking the whole chain
(IEventUpcasterRegistry.cs:39), and upcasts an instance hop by hop
(IEventUpcasterRegistry.cs:48). Two properties make it safe to depend on unconditionally: it is
always registered, and with no upcasters its operations are identity
(DependencyInjection.cs:37-41, IEventUpcasterRegistry.cs:15); and every hop preserves the
envelope, restamping MessageId and DateOccurred from the pre-hop instance, so consumer-side inbox
deduplication keeps working on the id the producer published (IEventUpcaster.cs:23). A bad
registration graph (duplicate source, a source mapped onto itself, or a cycle) throws from the
implementation's constructor and is resolved at host start, so a misconfiguration fails the host
rather than the first message (IEventUpcasterRegistry.cs:18-20). The in-process consumer is the
DomainEventDispatcher, the broker-side one is
UpcastingIntegrationEventConsumer<TEvent>
(ADR-090).
ICreateRequest (.../Interfaces/ICreateRequest.cs:8) is the smallest type in the
group: an empty marker used purely as a generic constraint by
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>
so request-to-entity mapping is type-safe (ICreateRequest.cs:3-7), and by
CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>
(CreateEntityHandlerBase.cs:47). It pairs with
ICommandWithRequest<out TRequest>
(.../UseCases/ICommandWithRequest.cs:14), whose single covariant Request property
(ICommandWithRequest.cs:17) is what the module scan looks for when it auto-registers the delegating
validator described above (ICommandWithRequest.cs:5-11).
Where this fits, and the failure-mode contract
These contracts sit in the Application layer of Clean Architecture
(primer §1), above Domain and below Infrastructure and the API. The
API layer (G12) resolves a closed handler from DI and calls
HandleAsync; the decorators it gets are invisible to the caller, and returned Result failures are
translated to HTTP status codes by
ApiControllerBase. Domain events raised inside
the transaction reach the outbox on save (G04,
ADR-003). Note that HTTP request
idempotency is not a decorator in this group: it is an API-layer filter that borrows this group's
IDistributedLock contract. And because the only things handlers and decorators
depend on are abstractions,
IUnitOfWork,
ICacheService,
ICorrelationContext,
ITenantContext,
ICurrentUserService,
IPermissionRegistry, IFeatureManager, IValidator<T>, the
whole pipeline survives a module being extracted into its own service unchanged
([Rubric §7, Microservices Readiness],
ADR-007 and
ADR-008).
The contract to memorize, because the rest of the system relies on it, has four clauses. On a
business failure (a Result with IsFailure, no exception thrown) the transaction is rolled
back, atomicity over partial persistence, and cache invalidation is skipped
(DependencyInjection.cs:108-109, enforced at DbContextFactory.cs:564-570 and
CachingCommandDecorator.cs:59-61). On an exception the transaction also rolls back and the
exception propagates outward through every decorator, which logs it and tags the metric exception
(DependencyInjection.cs:110, LoggingCommandDecorator.cs:53-58). On a short circuit (feature
off, permission denied, validation failed, budget expired) the handler is never called at all and the
caller gets a typed failure whose ErrorType is the decorator's own: NotFound, Forbidden,
Validation, Failure respectively. And on the read side only non-failure results are ever cached
(CachingQueryDecorator.cs:115). Note the revision history here: rollback-on-business-failure is the
current semantic, adopted in the 2026-07-19 revision of
ADR-014; older prose that said
a business failure still commits describes a framework version that no longer exists. The asymmetry
that remains, failures are values that flow through the pipeline while exceptions are escapes that
unwind it, is the same Result-pattern discipline the whole codebase is built on, expressed here as the
rules of the pipeline.
ICreateRequest
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Mapping·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/ICreateRequest.cs:8· Level 0 · interface (marker, empty)
- What it is: an empty marker interface for "create" request DTOs, used as a generic type constraint by IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and by the framework's generic create handler and controller bases, so the create path is distinguishable from every other mapping path at the type-system level.
- Depends on: nothing. Same presence-as-signal marker pattern as ITransactional.
- Concept introduced, type-system constraints as documentation and enforcement.
[Rubric §9, API & Contract Design]assesses how request contracts are modelled and kept unambiguous; tagging a DTO as "this is a create" lets the generic mapper, handler and controller infrastructure refuse anything that is not a create request on the create path, catching a wiring mistake at compile time rather than at runtime. - Walkthrough: the body is empty (
ICreateRequest.cs:8-10); the XML doc (ICreateRequest.cs:3-7) names the constraint site. All of the type's value is in the hierarchy: there is no member to implement, so opting in costs a base-list entry and nothing else. - Why it's built this way: a mapper constrained to
where TCreateRequest : ICreateRequestmakes it impossible to pass a non-create DTO into the create-mapping path, with no runtime check needed and no reflection. - Where it's used: as a generic constraint in six framework declarations, all of them writing
where TCreateRequest : ICreateRequest. In Application: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:42-45, the file that hosts both write-side mapper contracts),CreateEntityHandlerBase(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:47) andCreateEntityHandler(.../UseCases/CreateEntityHandler.cs:33). In the API layer:IAggregateRootEntityControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IAggregateRootEntityControllerBase.cs:22),AggregateRootEntityControllerBase(.../Controllers/AggregateRootEntityControllerBase.cs:43) andCrudEntityControllerBase(.../Controllers/CrudEntityControllerBase.cs:71). Implemented by create-request DTOs in every module: 7 inMMCA.ADC/Source(EventCreateRequest,SessionCreateRequest,SpeakerCreateRequest,SponsorCreateRequest,QuestionCreateRequest,ActivityCreateRequest,ConferenceCategoryCreateRequest), 8 inMMCA.Store/Source(ProductCreateRequest,ProductVariantCreateRequest,CategoryCreateRequest,OrderCreateRequest,CustomerCreateRequest,ShoppingCartCreateRequest,ShoppingCartItemCreateRequest,InventoryItemCreateRequest) and one in the reference app (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/Create/TicketCreateRequest.cs). - Caveats / not-in-source: the marker says "create", not "valid". Nothing in the type system checks that an
ICreateRequestomits an identifier or carries the fields the aggregate factory needs; that is FluentValidation's and the factory's job.
IDistributedLock
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IDistributedLock.cs:30· Level 0 · interface
- What it is: a one-method contract for mutual exclusion on a logical string key across every replica of a service.
TryAcquireAsynchands back anIAsyncDisposablehandle whose disposal releases the lock, ornullwhen the key was still held elsewhere after the caller's wait elapsed. - Depends on: BCL only (
Task,TimeSpan,IAsyncDisposable,CancellationToken), no first-party types. Its two implementations live in Infrastructure: InProcessDistributedLock and RedisDistributedLock. Contrast the per-process KeyedSemaphoreStripe, which is exactly what this interface exists to outgrow. - Concept introduced, cross-replica mutual exclusion as an Application-layer abstraction.
[Rubric §12, Performance & Scalability]assesses whether a design still holds once the service scales out horizontally, and the XML doc opens with precisely that failure (IDistributedLock.cs:6-13): aSemaphoreSlim(or a striped one) serializes callers inside one process, so a service running more than one replica executes an "only one of these at a time" section once per replica.[Rubric §29, Resilience, Reliability & Business Continuity]assesses behavior under partial failure; this contract is documented as best-effort, not a consensus protocol (IDistributedLock.cs:23-28): a holder paused past its time-to-live loses the lock without being told, so the guarded section must stay correct (merely slower, or duplicated) when exclusion is lost. The doc states the usage rule bluntly: take the lock to collapse duplicate work, never as the only guard on a correctness invariant that persistence can enforce.[Rubric §3, Clean Architecture]assesses whether the core depends on abstractions while technology choices sit at the edge; the contract carries no transport type at all, so the StackExchange.Redis dependency stays in Infrastructure and callers here never see it. - Walkthrough: line 30 declares the interface; lines 59-63 declare its single member,
Task<IAsyncDisposable?> TryAcquireAsync(string key, TimeSpan ttl, TimeSpan wait, CancellationToken cancellationToken = default). Every parameter carries a contract the implementations must honour.keyis the logical name that callers sharing one backing store have to agree on (IDistributedLock.cs:36).ttlis the crash guard: how long the lock survives with no explicit release, so a holder that dies mid-section cannot wedge the key; it must sit comfortably above the guarded section's expected duration, because work that outlives the TTL is no longer protected (:37-42).waitis how long to block for a current holder, andTimeSpan.Zeromakes the call a single non-blocking attempt (:43-46). The token cancels the wait, not the work that follows it (:47). The return contract matters as much as the parameters:nullmeans "still held elsewhere afterwaitelapsed", and the handle is meant to be disposed inside anawait usingso release happens even when the guarded work throws (:48-53). Release is owner-scoped and idempotent (:54-58): disposing a handle whose TTL already lapsed is a no-op, not a release of whatever holder now owns the key. Two remarks bound usage further: implementations are singletons and must be safe to call concurrently (:17), and the lock is not reentrant, so a caller that already holdskeyand asks for it again waits for itself and then fails to acquire (:20-21). - Why it's built this way: ADR-017 records the change that introduced it. The idempotency filter's execute-then-store window was previously guarded only by a process-local striped semaphore, which stops serializing anything the moment a service runs more than one replica, and both deployed apps do. Putting the contract in
MMCA.Common.Application.Interfacesrather than Infrastructure is what lets the API filter depend on "a lock" while the Redis-versus-process-local decision stays a composition-root concern. - Where it's used: the Infrastructure composition root registers exactly one implementation inside
AddCaching(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:287-301), choosing RedisDistributedLock when anIConnectionMultiplexeris resolvable (:275-282) and the warn-once InProcessDistributedLock otherwise (:284-286); the comment above the registration explains the pairing with the cache (:269-272). Two production consumers exist today.- The framework's IdempotencyFilter resolves it from request services (
MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:148) and spans the double-check plus action plus cache-store window with a 30-secondttl(LockTimeToLive,:97) and a 5-secondwait(LockWait,:104), callingTryAcquireAsyncat:249-251. When that wait expires with nothing cached it answers a 409 in-flight-duplicate result instead of executing a second time (:261-273); when the lock call itself throws, it records a degraded metric and executes anyway rather than failing the request (:253-259). - MMCA.ADC's
SessionScoringProcessortakes the lock per event around an AI-scoring pass (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringProcessor.cs:175-179) with a 15-minuteClaimTimeToLive(:85) and aClaimWaitofTimeSpan.Zero(:92), so a second replica that cannot claim the event simply skips its pass (:181-188). The inline rationale (:162-174) is a good worked example of both halves of the contract: the handle's disposal releases on every exit path, and the TTL releases for a replica that never reaches an exit path at all.
- The framework's IdempotencyFilter resolves it from request services (
- Caveats / not-in-source:
MMCA.Store/Sourcehas noIDistributedLockconsumer today. The idempotency filter also resolves it withGetService<IDistributedLock>()and falls back to the striped-semaphore path when the result isnull(IdempotencyFilter.cs:148-153), even thoughAddCachingregisters an implementation unconditionally, so that fallback is reachable only in a host that never callsAddCaching(or a test building its own provider). The ADC comment states the other honest limit: a host with no Redis gets the in-process implementation, where "cross-replica" exclusion degrades back to per-replica (SessionScoringProcessor.cs:172-174).
IScheduledJob
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IScheduledJob.cs:36· Level 0 · interface
- What it is: the contract for a unit of recurring work driven by a cron schedule: a stable
Name, a defaultCronExpression, and anExecuteAsyncthat runs one occurrence. - Depends on: BCL only (
Task,CancellationToken). Executed by ScheduledJobRunner, persisted as ScheduledJobEntry rows, configured through SchedulerSettings and ScheduledJobOverrideSettings, and cron-parsed by Cronos (NuGet). - Concept introduced, recurring work as a first-class Application abstraction.
[Rubric §13, Observability & Operability]assesses whether operators can see and steer background work; a job here is a named row with a schedule, an outcome and a last error, not an anonymousTimer.[Rubric §7, Microservices Readiness]and[Rubric §29, Resilience]assess behavior under scale-out and partial failure, and the interface's own doc is where the hard rules are written down (IScheduledJob.cs:8-35), so read them as contract, not commentary:- Lifetime: jobs are resolved scoped, in a fresh DI scope per execution, exactly like a request. A job may take scoped dependencies (a unit of work, a repository, a command handler) and must hold no state between runs, because the previous instance is already disposed (
:9-14). - Single runner across replicas: every replica runs a scheduler, but an occurrence executes once, because the persistent job store hands out a claim lease per row (the outbox processor's claim idiom) and only the claim winner runs (
:16-21). A replica that dies mid-execution releases its claim implicitly when the lease expires. - Missed occurrences do not pile up: after an outage the job runs once and its next run is computed from the current instant, not from the backlog (
:23-29). Work that must not be skipped therefore has to be idempotent and range-driven, processing everything since the last successful run rather than relying on one run per tick. - Failures are recorded, not fatal: an exception from
ExecuteAsyncis caught, logged and stamped on the row as a failed outcome while the schedule advances and the loop survives; there is no retry inside an occurrence (:31-33).
- Lifetime: jobs are resolved scoped, in a fresh DI scope per execution, exactly like a request. A job may take scoped dependencies (a unit of work, a repository, a command handler) and must hold no state between runs, because the previous instance is already disposed (
- Walkthrough: line 44 declares
string Name { get; }, the stable identity that is also the primary key of the persisted row, so renaming it strands the old row and starts a new schedule, and two registered jobs must never share it (:38-43). Line 68 declaresstring CronExpression { get; }, a five-field expression (minute hour day-of-month month day-of-week) parsed by Cronos, with worked examples on:51-56. Two properties of that field are load-bearing: all times are UTC, never a local or configured zone, so a schedule never shifts, doubles or vanishes across a daylight-saving transition (:57-62), and the value is only the default, overridden per job byScheduler:Jobs:{Name}:Cronin configuration whenever that key is present (:63-66). Line 78 declaresTask ExecuteAsync(CancellationToken cancellationToken), whose token is cancelled on host shutdown; work that ignores it delays shutdown and can outlive its claim lease (:73-76). - Why it's built this way: ADR-074 records the design. Keeping the interface in Application (with no EF, no
IHostedService, no cron library type in its signature) is what lets a module declare recurring work without taking an Infrastructure dependency, and lets the runner, the persistence of job state and the claim protocol all stay replaceable. Registration is deliberately split in two:AddScheduledJobs(configuration)enables the runner once per host (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:405, registering ScheduledJobRunner throughTryAddEnumerableat:401-402so two callers cannot start two runners racing the same rows,:398-400), whileAddScheduledJob<TJob>()adds one job, scoped and accumulating (:426-431, rationale at:412-424). Registering the scheduler is not the same as turning it on: everything stays inert untilScheduler:Enabledis true (:385-389). - Where it's used: two implementations exist in the workspace.
- The framework ships
AuditTrailCleanupJob(see AuditTrailCleanupJob), declared atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:48-55, named"audit-trail-cleanup"and scheduled"0 3 * * *", daily at 03:00 UTC (:63,67). It is registered byAddAuditTrailrather than byAddScheduledJobs(.../Infrastructure/DependencyInjection.cs:478), which keeps the trail and the scheduler independent features (:475-477). - MMCA.ADC's
SessionScoringSweepJob(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringSweepJob.cs:54-58) is named"conference-session-scoring-sweep"and runs"*/5 * * * *", every five minutes (:69,77), recovering AI-scoring passes interrupted inside aRecoveryWindowof 24 hours (:66). It is registered by the Conference module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:58). - The runner itself is switched on per host. Seven hosts call
AddScheduledJobs(builder.Configuration)today: ADC's three service hosts (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:293,.../MMCA.ADC.Engagement.Service/Program.cs:193,.../MMCA.ADC.Identity.Service/Program.cs:228), Store's three (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:211,.../MMCA.Store.Sales.Service/Program.cs:207,.../MMCA.Store.Identity.Service/Program.cs:189) and the reference app (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:79).
- The framework ships
- Caveats / not-in-source:
MMCA.Store/Sourceimplements noIScheduledJobof its own; its hosts enable the scheduler for the framework's retention job, which is exactly what the comment above those calls says (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:202-207). Retention only happens in a host that enables both features: registering the trail without the scheduler records every change and purges nothing, leavingAuditTrail:RetentionDaysinert, which is what theAddAuditTraildoc warns about (.../Infrastructure/DependencyInjection.cs:450-456).
ITenantContext
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:22· Level 0 · interface
- What it is: the scoped ambient contract for "which tenant is this scope running as": a nullable
TenantId, anIsResolvedflag, and aSetTenantthat may be called once per scope. - Depends on: BCL only. Implemented by TenantContext in Infrastructure, populated at the edge by TenantResolutionMiddleware, and consumed by the caching decorators in this group plus the persistence layer (G07). Configured through TenancySettings. Deliberately mirrors ICorrelationContext.
- Concept introduced, ambient scope state with an honest "unset" value.
[Rubric §11, Security]assesses whether data isolation is enforced structurally rather than remembered per query; every tenant-aware read filter, save interceptor and cache key reads this one object, so a handler cannot forget to scope itself.[Rubric §12, Performance & Scalability]: like the correlation id, the value is captured once at the edge and flows implicitly for the rest of the scope. The interesting design decision is the one the doc calls out (ITenantContext.cs:10-15): unlike the correlation id there is no generated fallback. An unresolved tenant is a meaningful state (a background service, a seeder, an admin flow) and reads as "see everything", so inventing a value would silently scope a system operation to a tenant that does not exist. - Walkthrough: line 28 declares
string? TenantId { get; }, null until resolved. Line 31 declaresbool IsResolved { get; }. Line 41 declaresvoid SetTenant(string tenantId), and its contract is the strict part: it throwsArgumentExceptionon a null, empty or whitespace id (:37) andInvalidOperationExceptionwhen a different tenant was already resolved for this scope (:38-40), while accepting the value it already holds (:34). The rationale is on:16-20: one scope, one tenant, because a scope whose tenant changed mid-flight has already read rows under the previous tenant and there is no honest way to reconcile that afterwards. The implementation matches exactly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/TenantContext.cs:11-45), whereIsResolvedis simplyTenantId is not null(:17), the firstSetTenantassigns (:24-28), a repeat of the same value returns quietly (:32-35), and anything else throws with a message that names both tenants (:37-43). - Why it's built this way: ADR-073 records the multi-tenancy model. Putting the contract in Application, not Infrastructure, is what lets the Application-layer caching decorators scope their keys without referencing EF Core: CachingCommandDecorator<TCommand, TResult> and CachingQueryDecorator<TQuery, TResult> both take it as an optional primary-constructor parameter defaulting to
null(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:37and.../CachingQueryDecorator.cs:45), so a host that never resolves a tenant pays nothing. - Where it's used: registered scoped in
AddServices(), not inAddMultiTenancy(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:553). The comment above the line is the design statement (:535-538): everything that reads the context 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.AddMultiTenancy(configuration)is then only the settings half: it binds and validates TenancySettings and adds the per-source override validator (:511-523). The context is written at the edge by TenantResolutionMiddleware from a claim or a header, and read by DbContextFactory for per-tenant routing and by both caching decorators throughTenantCacheKey.Scope, which prefixes the key witht:{tenantId}:only when a tenant is actually resolved (.../UseCases/Decorators/TenantCacheKey.cs:37-40, called atCachingCommandDecorator.cs:67andCachingQueryDecorator.cs:56). - Caveats / not-in-source: verified by source search, neither
MMCA.ADC/SourcenorMMCA.Store/SourcementionsITenantContextat all today. Multi-tenancy is a shipped, tested framework capability that no deployed app has opted into, so every scope in production runs unresolved and the tenant-scoped cache keys and query filters are no-ops there.
CqrsContractMismatchKind
MMCA.Common.Application ·
MMCA.Common.Application.UseCases·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/CqrsContractInspector.cs:9· Level 0 · enum
- What it is: the two-value classification of why a CQRS request and the handler written for it disagree. It is the
Kindcarried on every CqrsContractMismatch that CqrsContractInspector reports. - Depends on: nothing. It is a plain
int-backed enum with explicitly assigned members. - Concept introduced, naming the failure modes instead of formatting a string.
[Rubric §9, API & Contract Design]assesses whether the contracts a system publishes are checkable rather than conventional; giving the two disagreement kinds names is what lets a fitness test assert which problem it found, and what lets CqrsContractMismatch.Describe pick a message written for that specific problem rather than one generic sentence.[Rubric §15, Best Practices & Code Quality]: the members carry explicit values (ResultType = 0,HandlerKind = 1), so the enum's wire and log representation is pinned rather than positional. - Walkthrough: line 8 declares
public enum CqrsContractMismatchKind. Line 14 declaresResultType = 0: the request declared one result type through its ICommand<TResult> or IQuery<TResult> marker and its handler returns a different one (documented at:10-13). Line 20 declaresHandlerKind = 1: the request is declared as a command but handled by an IQueryHandler<in TQuery, TResult>, or declared as a query and handled by an ICommandHandler<in TCommand, TResult> (documented at:16-19). There is no "unknown" member; the switch inDescribestill carries a_arm (:48-49) so a future member cannot silently produce no message. - Why it's built this way: the two kinds are genuinely different bugs with different fixes. A
ResultTypemismatch means one of the two declarations is stale and the caller's expectations are already wrong; aHandlerKindmismatch means a request opted into the wrong side of CQRS entirely. Collapsing them into one "mismatch" would force whoever reads the failure to re-derive that distinction. - Where it's used: constructed only inside CqrsContractInspector (
CqrsContractInspector.cs:142forHandlerKind,:147forResultType), read byDescribe(:42-50), and asserted on in CqrsContractInspectorTests.
ICommand<TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Contracts·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommand.cs:31· Level 0 · interface (marker, empty)
- What it is: an opt-in marker a command record can implement to declare the result type its handler produces, so the request and its ICommandHandler<in TCommand, TResult> can be paired by a check instead of by convention alone. It has no members: the entire payload is the
TResulttype argument. - Depends on: nothing at runtime.
System.Diagnostics.CodeAnalysis.SuppressMessage(BCL) is applied to it. Read by CqrsContractInspector and paired against ICommandHandler<in TCommand, TResult>; the declared type is normally Result orResult<T>. - Concept introduced, the phantom type parameter. A phantom type parameter appears in a type's signature but in none of its members: nothing stores a
TResult, nothing returns one. Its only job is to travel with the type so a later reader (here, reflection) can recover it.[Rubric §9, API & Contract Design]assesses whether contracts are explicit and machine-checkable rather than agreed by naming convention; declaringICommand<Result<int>>on the command is what turns "the handler for this command returns what everybody assumes it returns" into a statement a test can verify.[Rubric §15, Best Practices & Code Quality]: the analyzer that flags unused type parameters (SonarS2326) is suppressed with a written justification rather than worked around (ICommand.cs:27-30), and the justification is the teaching text: consumingTResultin a member would force every command DTO to implement a method it has no business owning.[Rubric §15, Best Practices & Code Quality]: the marker is purely additive (:13-19), so adoption is per command at whatever pace suits the module, and a command that does not implement it behaves identically. - Walkthrough: line 31 is the whole declaration,
public interface ICommand<TResult>;, in the semicolon-body form the framework uses for every member-less interface. Lines 27-30 carry theSuppressMessageattribute described above. The doc comment does the rest of the work::13-19states the additive guarantee (handlers keep implementingICommandHandlerexactly as before, nothing in the registration or decorator pipeline reads the marker), and:20-25states the pay-off, that once a command declares its result type a fitness test can catch the "handler returnsResult<int>but every caller expectsResult<Guid>" drift. - Why it's built this way: this codebase does not route commands through a mediator, so nothing at runtime needs the result type; handlers are injected as closed
ICommandHandler<TCommand, TResult>and the compiler already checks the call site. What the compiler cannot check is whether the right handler was written for a command, because a second handler over the same command with a different result type compiles fine. The marker plus a reflection pass closes that gap without adding a runtime dispatcher. No ADR governs the marker; the rationale lives in the XML docs on the interface itself. - Where it's used: read only by CqrsContractInspector (
CqrsContractInspector.cs:129), which looks for exactly this open generic definition. - Caveats / not-in-source: verified by source search, no command in
MMCA.ADC/Source,MMCA.Store/SourceorMMCA.HelpdeskimplementsICommand<TResult>today. The only implementers in the workspace are the framework's own test fixtures (CqrsContractInspectorTests, for exampleAgreeingMarkedCommandandDriftedMarkedCommandatMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/UseCases/CqrsContractInspectorTests.cs:88and:89). It is a shipped, tested extension point with zero production adoption, which is exactly the state the inspector's own docs anticipate: a repo with no adoption sees an empty mismatch list rather than a wall of failures.
ICommandHandler<in TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Contracts·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandHandler.cs:9· Level 0 · interface
- What it is: the CQRS command-handler contract: one method,
HandleAsync, that accepts a mutation command and returns a result, typically Result orResult<T>. - Depends on: BCL only (
Task,CancellationToken). Implementations return types fromMMCA.Common.Shared(Result andResult<T>, with failures described by Error). - Concept introduced, the CQRS command side.
[Rubric §6, CQRS & Event-Driven]assesses the separation of mutating writes from side-effect-free reads; commands express intent to change state (create, update, delete) and return a Result.[Rubric §1, SOLID]: a one-method interface gives each handler a single responsibility and, via contravariance, clean substitutability. Implementations are auto-discovered by Scrutor (ScanModuleApplicationServicesscans forICommandHandler<,>and registers them scoped,MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-244) and then wrapped by a layered decorator pipeline. The verified registration inDependencyInjection.cs:131-137registers, innermost to outermost,Transactional,Timeout,Validating,Caching,Logging,Authorization,FeatureGate; because Scrutor'sTryDecorateapplies decorators in reverse registration order, the execution order is FeatureGateCommandDecorator<TCommand, TResult>, AuthorizationCommandDecorator<TCommand, TResult>, LoggingCommandDecorator<TCommand, TResult>, CachingCommandDecorator<TCommand, TResult>, ValidatingCommandDecorator<TCommand, TResult>, TimeoutCommandDecorator<TCommand, TResult>, TransactionalCommandDecorator<TCommand, TResult>, then the concrete handler. That is exactly the nesting diagram in the method's own doc comment (DependencyInjection.cs:64-73). The ordering is load-bearing and is the subject of ADR-014 (the CQRS decorator pipeline), whose 2026-08-18 revision inserted Authorization and Timeout into both chains. The defaultCancellationToken = default(line 17) lets token-less callers invoke handlers while still letting every implementation honour cancellation. - Walkthrough: line 9 declares
public interface ICommandHandler<in TCommand, TResult>. Theinvariance onTCommandis contravariant: a handler accepting a base command can stand in where a handler of a derived command is expected. Line 17 declares the sole member,Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);. - Why it's built this way: a thin one-method interface keeps handlers focused; the decorator pipeline adds cross-cutting concerns without each handler knowing about them. A single open-generic interface is exactly what lets Scrutor register every closed handler in one assembly pass and lets the decorator chain wrap them generically. The registration-order constraint is documented on the method itself (
DependencyInjection.cs:57-58) and inMMCA.Common/CLAUDE.md:AddApplicationDecorators()runs last, becauseTryDecoratecan only wrap handlers already in the container, and the framework now enforces that by sealing the pipeline after the decorators are registered (DependencyInjection.cs:147). - Where it's used: every command handler in ADC and Store implements it, plus the framework's own generic write handlers (DeleteEntityHandler<TEntity, TIdentifierType>, CreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>, UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>), which
AddEntityCrudregisters as closed handlers withTryAddScoped(DependencyInjection.cs:339-349), and the notification command handlers (G10). - Caveats / not-in-source:
ProfilingCommandDecoratorexists but is not in the standard pipeline; it is added only by the separate opt-inAddApplicationProfiling()(DependencyInjection.cs:567-570).
ICommandWithRequest<out TRequest>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Contracts·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandWithRequest.cs:14· Level 0 · interface
- What it is: a contract for commands that embed a request DTO as a
Requestproperty, enabling automatic FluentValidation of that DTO by the validating decorator before the handler runs. - Depends on: nothing first-party at the interface level. The validation plumbing resolves FluentValidation's
IValidator<TRequest>from DI through the framework-supplied CommandRequestValidator<TCommand, TRequest> and is enforced by ValidatingCommandDecorator<TCommand, TResult>. Cross-reference ICommandHandler<in TCommand, TResult>. - Concept introduced, automatic validator wiring via an interface contract.
[Rubric §24, Forms, Validation & UX Safety]assesses how server-side validation is centralised rather than scattered; instead of each handler calling_validator.ValidateAsync(command.Request), module scanning reflects over the assembly, finds every type closingICommandWithRequest<>, builds the closedCommandRequestValidator<TCommand, TRequest>and registers it asIValidator<TCommand>withTryAddsemantics (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:254-270).TryAddTransientat line 267 is what makes an explicitly writtenIValidator<TCommand>win:AddValidatorsFromAssemblyruns first (:250) and the auto-wired validator only fills the gap.[Rubric §1, SOLID](open for extension, closed for modification): a new command gets validation for free by implementing the interface, with no decorator or registration change. The same closed validator can also be registered explicitly throughAddCommandRequestValidator<TCommand, TRequest>(:475-478), which is what the generic CRUD registration does for the update command (:349-351). - Walkthrough: line 14 declares
public interface ICommandWithRequest<out TRequest>. Theout(covariant) position means a command whose request is a derived type satisfies a constraint expecting the base request type. Line 17 declaresTRequest Request { get; }, the embedded payload, typically deserialized from the HTTP body. The XML doc on lines 5-11 is the contract for the auto-registration described above. - Why it's built this way: it collapses the usual web-API sequence (receive body, map to command, validate body, call handler) into "map to a command that implements
ICommandWithRequest, decorator validates, handler runs", removing per-handler validation boilerplate. - Where it's used: on the write commands whose payload is a request record rather than a flat set of positional parameters. Verified by source search, 6 implementers in
MMCA.ADC/Source(UpdateEventCommand.cs:16,UpdateQuestionCommand.cs:15,UpdateSessionCommand.cs:16,ChangePasswordCommand.cs:16,ForgotPasswordCommand.cs:13,ResetPasswordCommand.cs:16) and 8 inMMCA.Store/Source(ChangeVariantPriceCommand.cs:19,ChangeVariantSkuCommand.cs:19,ChangePasswordCommand.cs:14,ForgotPasswordCommand.cs:12,ResetPasswordCommand.cs:14,SetInventoryItemCommandinAdjustInventoryCommand.cs:20,BulkSetInventoryCommand.cs:23,AddItemCommand.cs:12); a ninth Store file,ChangePreferencesCommandValidator.cs, only names the interface in a doc comment. Commands that carry their parameters positionally simply do not implement it and pass the validating decorator unchanged.
IQuery<TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Contracts·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/IQuery.cs:26· Level 0 · interface (marker, empty)
- What it is: the read-side twin of ICommand<TResult>: an opt-in, member-less marker by which a query record declares the result type its handler produces.
- Depends on: nothing at runtime;
SuppressMessage(BCL) is applied to it. Read by CqrsContractInspector and paired against IQueryHandler<in TQuery, TResult>. - Concept reinforced, the phantom type parameter (taught under ICommand<TResult>).
[Rubric §6, CQRS & Event-Driven]assesses how cleanly the two sides are separated; having a separate marker per side is what makes aHandlerKindmismatch detectable at all: a request that declaresIQuery<T>but is handled by an ICommandHandler<in TCommand, TResult> has stated one thing and been implemented as another, and the inspector reports it as CqrsContractMismatchKind.HandlerKind. A single sharedIRequest<T>marker would make that class of bug invisible. - Walkthrough: line 26 is the whole declaration,
public interface IQuery<TResult>;. Lines 22-25 carry theS2326suppression with the same justification as the command marker, cross-referencing it explicitly. The doc comment states the two guarantees: additive, exactly likeICommand<TResult>(:12-16), and consumed by CqrsContractInspector so a fitness test can assert that no handler contradicts the result type its query declares (:17-20). - Why it's built this way: see ICommand<TResult>. The pair is deliberately symmetric so that a reader who has learned one has learned both, and so that the inspector's
Comparecan treat "matching marker" and "opposite marker" as a single parameterised test (CqrsContractInspector.cs:129-133). - Where it's used: read only by CqrsContractInspector (
CqrsContractInspector.cs:130). - Caveats / not-in-source: as with the command marker, no production query in MMCA.ADC, MMCA.Store or MMCA.Helpdesk implements
IQuery<TResult>today; the only implementers are the inspector's own test fixtures (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/UseCases/CqrsContractInspectorTests.cs:92).
IQueryHandler<in TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Contracts·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/IQueryHandler.cs:9· Level 0 · interface
- What it is: the CQRS query-handler contract:
HandleAsyncaccepts a read-only query and returns a result without mutating state. - Depends on: BCL only. Mirrors ICommandHandler<in TCommand, TResult> on the read side.
- Concept introduced, the lighter read pipeline.
[Rubric §6, CQRS & Event-Driven]: this is the query half of the segregation. The verified registration inMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140-145registers, innermost to outermost,Timeout,Validating,Caching,Logging,Authorization,FeatureGate, so the execution order is FeatureGateQueryDecorator<TQuery, TResult>, AuthorizationQueryDecorator<TQuery, TResult>, LoggingQueryDecorator<TQuery, TResult>, CachingQueryDecorator<TQuery, TResult>, ValidatingQueryDecorator<TQuery, TResult>, TimeoutQueryDecorator<TQuery, TResult>, then the concrete handler (DependencyInjection.cs:77-85). The one decorator the read chain does not have is Transactional: queries do not mutate, so there is nothing to commit or roll back, and that single omission is the clearest expression of CQRS in this codebase. Two orderings differ from the write side and both are argued in the registration doc. Validation sits inside caching here (:97-101): a cached entry can only exist because the same query already passed validation when the entry was produced, so re-validating on a cache hit spends work to reach a conclusion already reached. And the budget is innermost (:104-107), so a cache hit is served without starting a budget at all. - Walkthrough: line 9 declares
public interface IQueryHandler<in TQuery, TResult>with the same contravariantinon the query type. Line 17 declaresTask<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);. - Why it's built this way: an identical shape to
ICommandHandler(for the same Scrutor-discoverability and open-generic decorator reasons), but a separate interface so DI can tell "this is a query" from "this is a command" and apply the correct, lighter decorator set. ADR-014 records the chain and its 2026-08-26 revision is what added the Validating decorator to the query side. - Where it's used: every read handler in ADC and Store, plus the framework's notification query handlers (G10). Closed implementations are registered scoped by
ScanModuleApplicationServices(DependencyInjection.cs:246-250). - Caveats / not-in-source: like the command side,
ProfilingQueryDecoratoris added only by the opt-inAddApplicationProfiling()(DependencyInjection.cs:567-570), not by the standard pipeline.
IAuditTrailReader
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IAuditTrailReader.cs:20· Level 1 · interface
- What it is: the one-method read surface over the recorded change history of a single entity: one page of changes, newest first.
- Depends on: AuditTrailEntryDTO (its return payload,
usingatIAuditTrailReader.cs:1). Implemented by AuditTrailReader over the AuditTrailEntry rows written by the audit-trail save interceptor. - Concept introduced, shipping the read without shipping the exposure.
[Rubric §30, Compliance, Privacy & Data Governance]assesses whether a system can answer "who changed this, and when"; the trail is that answer, and this is how an application asks.[Rubric §11, Security]assesses authorization placement, and the doc is explicit about the boundary it draws (IAuditTrailReader.cs:10-15): there is deliberately no shipped endpoint or page in v1, because who may see an entity's history is an application decision (an admin screen, a support tool, a data-subject request) rather than a framework one. Consumers wrap this in whatever query and authorization their domain calls for.[Rubric §3, Clean Architecture]: the contract speaks in strings and DTOs with no EF type in its signature, so the Application layer can offer history without knowing where rows live. - Walkthrough:
IAuditTrailReader.cs:37-42declare the single member,Task<IReadOnlyList<AuditTrailEntryDTO>> GetForEntityAsync(string entityType, string entityKey, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default). The two identity parameters are string-typed on purpose, because they must match what the interceptor recorded:entityTypeis the full CLR type name (:25-28, for exampletypeof(Order).FullName) andentityKeyis the invariant string form of the primary key, with composite parts joined by|in the model's key order (:29-32). Paging is forgiving rather than validating: values below 1 are treated as 1 for bothpageandpageSize(:33-34), which the implementation does literally (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailReader.cs:49-50). Ordering is part of the contract, not an implementation detail (:17-18,23): newest change first, so the first page is the most recent activity, and the implementation makes that stable by orderingChangedOndescending with the row id descending as the tie-break (AuditTrailReader.cs:62-63). - Why it's built this way: ADR-075 records the trail. The interface exists at all so the read is testable and swappable, and it returns a DTO rather than the entity so the Application layer never handles a tracked row. The implementation is honest about a v1 limitation worth knowing before you build on it (
AuditTrailReader.cs:16-21): trail rows are written to whichever database holds the entity that changed, which is what makes the write atomic, but this reader queries exactly one of them, theDefaultdatabase of the engine named byAuditTrail:DataSource(resolved atAuditTrailReader.cs:52-53). For a monolith, where every source collapses ontoDefault, that is the whole trail; for a database-per-module host it is only the modules living in the default database. - Where it's used: registered scoped by
AddAuditTrail(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:487), which is opt-in per host: a host that never calls it has no implementation to resolve (IAuditTrailReader.cs:6-7). The reader returns an empty list rather than throwing when the trail table is absent from the model (AuditTrailReader.cs:55-58, rationale at:27-29), so registering the feature before flippingAuditTrail:Enabledis safe. - Caveats / not-in-source: verified by source search, no MMCA.ADC or MMCA.Store type mentions
IAuditTrailReadertoday. It is a framework capability with no application consumer yet, which also means no shipped authorization decision to inherit: the first consumer owns that entirely.
CqrsContractMismatch
MMCA.Common.Application ·
MMCA.Common.Application.UseCases·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/CqrsContractInspector.cs:32· Level 1 · record (sealed)
- What it is: one disagreement between a request's declared CQRS contract and the handler written for it, carrying both types, both result types, and the CqrsContractMismatchKind that says which kind of disagreement it is. Its
Describe()renders it as a single line fit for a test-failure message. - Depends on: CqrsContractMismatchKind; BCL
System.Typefor its four type-valued members. Produced by CqrsContractInspector. - Concept reinforced, the finding as a value, not a string.
[Rubric §14, Testability]assesses whether a check produces something a test can assert precisely on; keepingRequestType,HandlerType,DeclaredResultTypeandHandlerResultTypeasTypevalues (rather than pre-formatted text) is what lets CqrsContractInspectorTests assertm.DeclaredResultType == typeof(Result<int>)instead of matching on a substring.Describe()is then a presentation method layered on top, not the finding itself.[Rubric §15, Best Practices & Code Quality]: the positional record gives value equality and a readable constructor for free, which is why a mismatch can be compared and de-duplicated without any hand-written members. - Walkthrough: lines 31-36 declare the positional record
public sealed record CqrsContractMismatch(Type RequestType, Type HandlerType, Type DeclaredResultType, Type HandlerResultType, CqrsContractMismatchKind Kind), each parameter documented at:26-30. Lines 42-50 hold the sole member,Describe(), a switch expression overKindwith a message written for each case: theHandlerKindarm (:44-45) explains the rule it broke, that anICommandmarker needs anICommandHandlerand anIQuerymarker needs anIQueryHandler; theResultTypearm (:46-47) names the declared type and the returned type side by side; and the discard arm (:48-49) is the safety net for a future member. Every message usesType.Name, notFullName, because these lines are read in a test runner's output. - Why it's built this way: a fitness test that fails with "3 mismatches found" teaches nobody. Making the type carry the whole finding, and making it responsible for its own one-line rendering, means the assertion (
mismatches.Should().BeEmpty()) can stay trivial while the failure message stays specific. - Where it's used: constructed at
CqrsContractInspector.cs:141-142and:146-147, returned asIReadOnlyList<CqrsContractMismatch>fromFindContractMismatches(:83), and asserted on in CqrsContractInspectorTests.
CacheKeyLocks
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:142· Level 2 · class (static, internal)
- What it is: a two-line internal holder for the process-wide stripe table that the default
ICacheService.GetOrCreateAsync<T>implementation uses to keep concurrent misses on one key from all running the factory. - Depends on: KeyedSemaphoreStripe (
MMCA.Common.Shared.Concurrency,usingatICacheService.cs:1). Used only by ICacheService's default interface method. - Concept introduced, cache stampede protection and why the lock table is striped.
[Rubric §12, Performance & Scalability]assesses behavior under load, and this is the classic thundering-herd guard: on a cold key, N concurrent readers would otherwise all miss, all call the expensive factory, and all write the same value. The interesting part is the shape of the guard. A per-key semaphore table forces a bad choice, spelled out onICacheService.cs:134-141: drop the entry on release and two callers can run concurrently, or never drop it and a parameterized cache key grows the table without bound. Striping sidesteps both by hashing keys onto a fixed number of semaphores (KeyedSemaphoreStripe) and accepting that two unrelated keys occasionally share one. That is a fixed, bounded cost, and the stripes are never disposed because the table outlives every caller. - Walkthrough: line 142 declares
internal static class CacheKeyLocks; line 145 declares its only member,internal static readonly KeyedSemaphoreStripe Locks = new(). The consuming sequence is the double-checked idiom atICacheService.cs:99-124: a null-check on the factory (:105), a lock-freeGetAsyncfast path that returns immediately on a hit (:107-110), then the stripe is taken (:112), then the key is re-read inside the stripe (:114-118) so the waiters see what the winner just wrote, and only a still-missing key runs the factory and stores it (:120-122). The class doc (:127-133) explains the non-generic holder: statics on a generic method's declaring type would already be shared, but a holder keeps the table addressable and matches the sibling QueryCacheKeyLocks in the caching decorator (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:246, used at:182and:189). - Why it's built this way: the two tables are separate on purpose (
ICacheService.cs:138-141): these are different call sites over different keys, so sharing stripes would only widen the unrelated-key collisions striping already tolerates. Two limits of the mechanism are documented on the member it guards (ICacheService.cs:80-97) and matter more than the class itself. First, caching there is unconditional: whatever the factory returns is stored, including a failed Result or a null-equivalent value, which is exactly why the caching decorators do NOT route throughGetOrCreateAsyncand keep their own read/execute/write sequence (:81-86). Second, stampede protection is per process: the stripe table is process-wide, so with several replicas over one shared cache the factory can still run once per replica; a cluster-wide guarantee would need an IDistributedLock and is deliberately not attempted here (:87-91). - Where it's used: only by the default implementation of
ICacheService.GetOrCreateAsync<T>(ICacheService.cs:112). Backing stores with a native two-level primitive override the method and never touch this table (:92-97); HybridCacheService is the shipped example, forwarding straight toHybridCache.GetOrCreateAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/HybridCacheService.cs:238-248). - Caveats / not-in-source: the type is
internal, so it is not part of the public package surface and cannot be referenced or replaced from a consumer app; it is documented here because the behavior it produces is visible to anyone callingGetOrCreateAsync.
CqrsContractInspector
MMCA.Common.Application ·
MMCA.Common.Application.UseCases·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/CqrsContractInspector.cs:73· Level 2 · class (static)
- What it is: a reflection-only inspector that pairs request types carrying the opt-in ICommand<TResult> / IQuery<TResult> markers with the handlers written for them, and reports every disagreement as a CqrsContractMismatch. It is built to be called from an architecture fitness test.
- Depends on: ICommand<TResult>, IQuery<TResult>, ICommandHandler<in TCommand, TResult>, IQueryHandler<in TQuery, TResult>, CqrsContractMismatch, CqrsContractMismatchKind. Externally,
System.Reflectiononly:Assembly.GetTypes,Type.GetInterfaces,GetGenericTypeDefinition,GetGenericArguments. No IL reading, no third-party fitness library. - Concept introduced, an architecture fitness check written as a library function.
[Rubric §34, Architecture Governance & Documentation]assesses whether architectural rules are enforced mechanically rather than by review habit; this type is the mechanism for one specific rule (a request's declared result type must equal its handler's), packaged so any consumer repo can enforce it in three lines: callFindContractMismatchesover its module assemblies and fail when the list is non-empty (:59-65).[Rubric §14, Testability]assesses how easy correctness is to assert: returning a list of values rather than throwing means the caller chooses the assertion and the failure message.[Rubric §15, Best Practices & Code Quality]: the check is gradual by construction. Requests carrying no marker are ignored entirely, so adopting the markers is per command and a repo with zero adoption sees an empty list rather than a wall of failures. That is the design decision that makes an opt-in contract check shippable into an existing codebase at all. - Walkthrough: line 72 declares
public static class CqrsContractInspector. The public surface is one method,FindContractMismatches(params IEnumerable<Assembly> assemblies)(:83), whoseparamscollection lets a caller list module assemblies inline or pass an existing sequence. It null-guards (:85), then builds its candidate set: every type across the assemblies that is not abstract, not an interface and not an open generic type definition (:89-91). That last filter is load-bearing and is documented at:66-70: the framework's own decorators (and any generic handler base) implementICommandHandler<,>with a type parameter in the request position, so inspecting them would compare a type parameter against a result type.InspectHandler(:101-120) walks each candidate's interfaces, keeps only the closedICommandHandler<,>andIQueryHandler<,>ones (:105-112), and pulls the request and result type arguments (:114).Compare(:122-148) is the rule itself:FindMarkerlooks forICommand<>andIQuery<>on the request (:128-129, helper at:154-163); the marker of the handler's own kind ismatchingand the other isopposite(:131-132). Ifmatchingis null, a nulloppositemeans the request simply never opted in and is skipped, while a non-nulloppositeis a genuine pairing bug reported asHandlerKind(:134-142). Otherwise a marker exists and the check is a single type equality:matching == handlerResultType, else aResultTypemismatch (:144-147). One robustness detail closes it out:GetLoadableTypes(:169-179) catchesReflectionTypeLoadExceptionand degrades to the types that did load, so one missing transitive reference cannot take down the whole scan. - Why it's built this way: the compiler already checks that a handler call site matches the handler's signature; what it cannot check is whether the handler written for a command is the one the command's authors and callers assume. A reflection pass over closed handler interfaces is the cheapest way to close that gap without introducing a runtime mediator or attributes. No ADR governs this type; its rationale is stated in its own remarks (
:58-70). - Where it's used: CqrsContractInspectorTests exercises all seven behaviors (mismatch detection, readable description, agreeing handlers, unmarked requests, the wrong-handler-kind case, an assembly with no handlers, a null argument, and the open-generic-decorator skip) against fixtures declared in the same file (
MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/UseCases/CqrsContractInspectorTests.cs:88-126). - Caveats / not-in-source: verified by source search, no architecture fitness test in MMCA.Common, MMCA.ADC, MMCA.Store or MMCA.Helpdesk calls
FindContractMismatchestoday; the only caller in the workspace is the framework's own unit test. Combined with zero marker adoption (see ICommand<TResult>), the inspector is a shipped and tested capability that currently guards nothing in a consumer repo.
IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Mapping·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOProjector.cs:51· Level 4 · interface
- What it is: an opt-in, one-method contract that rewrites an entity
IQueryableinto a DTOIQueryable, so the database returns only the columns the DTO actually has instead of whole entity rows that are mapped afterwards. - Depends on: AuditableBaseEntity<TIdentifierType> and IBaseDTO<TIdentifierType> as generic constraints (
IEntityDTOProjector.cs:52-54,usings at:1-2). It is the pushdown counterpart of IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, consumed by EntityQueryService<TEntity, TEntityDTO, TIdentifierType> and executed through IEntityQueryPipeline. Implementations are typically Mapperly-generated (Riok.Mapperly, NuGet). - Concept introduced, projection pushdown as an optional, additive read path.
[Rubric §12, Performance & Scalability]assesses whether reads pay for data they do not return; the doc states the two costs the entity path incurs (IEntityDTOProjector.cs:9-16): the query must select whole entities (every column, plus a JOIN per include the DTO happens to flatten), and every materialized row is mapped in .NET afterwards. A projector removes both by making the provider select the DTO's columns directly.[Rubric §8, Data Architecture]assesses how much shaping is pushed to the database. The design point worth internalising is that this is additive, never required: registering one for an entity is what switches that entity's list reads onto the projected path, and nothing breaks when none is registered because the query service falls back to materialize-then-map. The remarks then bound what a projection can express (:36-41): it is an expression tree the provider must translate, so no instance sub-mappers, no custom mapping methods (Use = nameof(...)), no after-map hooks, nothing that would have to run in .NET on a materialized object. A DTO whose shape needs any of those simply does not get a projector, and its reads keep using the mapper. - Walkthrough: line 51 declares
public interface IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, constrained to an auditable entity, anIBaseDTO, and anotnullkey (:52-54). Line 62 declares the single member,IQueryable<TEntityDTO> ProjectTo(IQueryable<TEntity> source), whose contract is stated in two halves: the input is an entity queryable already filtered, sorted, and paged (:60), and the output must still be a translatable queryable, so an implementation must not materialize insideProjectTo(:56-58). The doc carries a worked example (:23-35) of the idiomatic shape: a Mapperly[Mapper]static partial class exposingProjectToDTO, wrapped by a smallsealed classimplementing this interface. The last remark is the correctness obligation (:42-46): a projector MUST produce the same values as the entity's mapper for the same row, because the two paths are chosen by registration, so a divergence would make a response depend on whether a projector happened to be registered. The doc says to pin the equivalence with a test, and the framework's own projector does exactly that. - Why it's built this way: ADR-055 records the optional projector on the read contract. The interesting mechanical detail is how "optional" is expressed in DI, because
Microsoft.Extensions.DependencyInjectionhas no notion of an optional dependency: a single constructor naming an unregistered service fails to resolve, default value or not. EntityQueryService<TEntity, TEntityDTO, TIdentifierType> therefore declares a second, longer constructor that takes the projector (EntityQueryService.cs:70-78, rationale at:53-60); the container picks the longer one when a projector is registered and the shorter one when it is not, with no ambiguity because one parameter set is a strict superset of the other, and existing subclasses keep compiling untouched. - Where it's used: discovered by convention.
ScanModuleApplicationServices<TAssemblyMarker>()scans a module assembly forIEntityDTOProjector<,,>and registers each as itself plus its interfaces, scoped, beside the DTO mappers (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:209-213, with the opt-in rationale in the comment at:204-206), so a module only has to write the projector class. At read time EntityQueryService<TEntity, TEntityDTO, TIdentifierType> holds it as a nullableDTOProjectorproperty (EntityQueryService.cs:85) and takes the projected branch only whenCanProjectis true (:303-313, predicate at:489-492). That predicate is three conditions: a projector is registered, the caller did not ask for tracking, and the query has no unsupported (cross-source) includes, because those are loaded row by row after materialization by the navigation populator and a projection has no rows to hand it (:477-482). Field shaping deliberately does not disqualify, because shaping runs after materialization over whatever object the pipeline produced (:484-486). The framework ships one worked implementation, PushNotificationDTOProjector (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35-45), registered explicitly by the notification module (.../Notifications/DependencyInjection.cs:50-52). - Caveats / not-in-source: verified by source search, neither MMCA.ADC nor MMCA.Store registers a projector today; outside the framework's own notification projector the only implementation in the workspace is MMCA.Helpdesk's
TicketDTOProjector(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/DTOs/TicketDTOProjector.cs:38-47), which exists as the reference-app demonstration. Every list read in both production apps therefore still runs the materialize-then-map path. The equivalence obligation is also a convention, not a compiler rule: the framework's projector documents an enum-to-string divergence it had to inline by hand (the entity'sStatusis an enum, the DTO's is a string, and the instance mapper'sUse = nameof(MapStatusToString)is not expressible in an expression tree, so the projection inlines a conditional that the provider renders as a SQLCASE,PushNotificationDTOProjector.cs:13-20) and pins it with a test, but nothing stops a new projector from quietly disagreeing with its mapper.
IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Mapping·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:79· Level 4 · interface
- What it is: the contract that applies an incoming update request onto an already-loaded aggregate by calling that aggregate's own guarded mutation methods, returning a bare Result. It is the write-side twin of IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>: the mapper owns "request to a new aggregate", the applier owns "request onto an existing aggregate" (
IEntityDTOMapper.cs:62-69). - Depends on: AuditableBaseEntity<TIdentifierType> as a generic constraint and Result as its return type (
usings atIEntityDTOMapper.cs:1-3). Consumed by UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> and, for verb-scoped updates, by UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>'s applier-typed sibling. Its widened cousin for updates that need the whole command is IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>. - Concept introduced, keeping the generic write handler out of the domain's business.
[Rubric §4, DDD]assesses whether invariants live in the aggregate rather than in application services; this contract is the mechanism that keeps them there. The framework ships one generic update handler that owns the boring, identical parts of every update (load the aggregate, honour the optimistic-concurrency token, save), and the applier owns the one part that is genuinely per-aggregate: calling the guarded mutation method. Nothing in the framework writes a property (.../Application/DependencyInjection.cs:291-297).[Rubric §1, SOLID]: a one-method interface with three type parameters is the smallest possible extension point for "how does this request change this aggregate", and it is what makes the handler open for extension and closed for modification.[Rubric §5, Vertical Slice]: each applier lives beside its request and its command in the module'sUseCases/{Verb}folder, so a verb is one directory rather than an edit to a shared switch. - Walkthrough: line 79 declares
public interface IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>, constrained to an auditable entity and anotnullkey (:80-81); note thatTUpdateRequestcarries no constraint, unlike the create side'sICreateRequest. Line 91 declares the single member,Task<Result> ApplyAsync(TEntity entity, TUpdateRequest request, CancellationToken cancellationToken = default), and its two contract halves are on:83-90: theentityhanded in is the loaded, tracked aggregate, and the return is the aggregate's own Result, so a refused invariant stops the write before it is saved. The return type is the design decision worth pausing on (:70-74): the applier answers with a bareResultrather than a new entity, because a successful apply has already mutated the tracked instance in place, and a failure must leave it untouched so nothing reaches the database. A real implementation is therefore three lines of delegation, for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/UseCases/Rename/ProductRenameApplier.cs:16-22, which null-checks and returnsentity.Rename(request.Name), or the widerMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateApplier.cs:20-34, which forwards eight request fields intoActivity.Update. - Why it's built this way: putting the mutation behind this contract is what lets the framework ship one generic update handler at all. UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> takes the applier as a constructor dependency (
MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:48-50) and its wholeMutateAsyncoverride is one line,updateApplier.ApplyAsync(entity, command.Request, cancellationToken)(:91); the handler keeps loading, theRowVersionhook (:76-80) and the save, while the aggregate keeps its invariants and the domain events it raises (:29-35). The same file carries a five-parameter overload that resolves the applier by its concrete type (:115-127, mutation at:161), which is what lets one aggregate expose several verbs over one request DTO: two verbs become two commands, two handlers and two appliers, with nothing duplicated but the registration line (.../Application/DependencyInjection.cs:374-377). - Where it's used: discovered by convention and registered in three ways.
ScanModuleApplicationServices<TAssemblyMarker>()scans a module assembly forIEntityUpdateApplier<,,>and registers each as itself plus its interfaces, scoped, beside the request mappers (.../Application/DependencyInjection.cs:223-227, rationale at:219-222), so a module only writes the applier class.AddEntityCrud<...>()then wires the standard update verb, mappingUpdateEntityCommandtoUpdateEntityHandler(:329,342-343), andAddEntityUpdateVerb<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest, TApplier>()wires an extra verb, constrained onwhere TApplier : class, IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>(signature:391, constraint:395, registration:400-401, with the two-verb inventory example in its doc at:384-388). Fifteen implementations exist across theSource/trees: 11 inMMCA.Store/Source(threeCustomerverbs, fourProductverbs, twoCategoryverbs, and theIncreaseInventoryApplier/DecreaseInventoryApplierpair over oneInventoryItemAdjustRequest), 3 inMMCA.ADC/Source(SponsorUpdateApplier,ActivityUpdateApplier,ConferenceCategoryUpdateApplier) and 1 inMMCA.Helpdesk/Source(TicketUpdateApplier). - Caveats / not-in-source: the framework itself ships no implementation; every applier is application code, which is the point.
TUpdateRequestbeing unconstrained means nothing in the type system stops an applier being declared over a create request DTO, so the create/update symmetry the interface pair suggests is a convention here rather than a compiler rule. An applier that mutates the aggregate and then returns a failure would break the "a failure must leave it untouched" contract, and nothing enforces that either: it is a documented obligation on the implementer (IEntityDTOMapper.cs:70-74).
MutationContext
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:31· Level 0 · class (sealed)
- What it is: the per-command side channel of a mutate handler's load-mutate-save workflow. It is two things at once: a typed key/value bag for values a mutation derived while the aggregate was loaded, and the
SkipSaveshort-circuit that stops the write without failing the command. - Depends on: BCL only (
Dictionary<string, object?>,MaybeNullWhen). Created and threaded by MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> and read by MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> and IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>. - Concept introduced, a request-scoped side channel instead of handler instance state. The workflow answers with the mutated aggregate, so anything the mutation computed on the way (the pre-mutation state, the blob name about to be orphaned, a warning the caller must be told about) has nowhere to go. Storing it in a field on the handler is the tempting move and the wrong one: handlers are registered scoped, and a field would leak between calls the moment a handler instance is reused. One context instance per command, handed to every hook in order and then to the result builder, makes the value travel with the command and die with it (
MutationContext.cs:11-18).[Rubric §1, SOLID]: the hooks stay single-purpose because none of them has to widen its signature to pass a value to the next one.[Rubric §14, Testability]: the context is a plain object with no framework dependency, so a hook that reads it can be tested by constructing one and callingSet.[Rubric §15, Best Practices & Code Quality]: keys are ordinary strings compared ordinally, which is why every consumer declares its ownprivate const stringkey beside the handler that uses it rather than passing a literal twice. Not thread-safe by design (:26-29): one command runs on one logical flow, and a hook that fans out must collect its own results before writing them here. - Walkthrough: line 31 declares
public sealed class MutationContext; line 33 holds the backingDictionary<string, object?> _items = []. Line 39 exposesSaveSkipped { get; private set; }, the read side of the short-circuit, and line 42 exposesItemsas anIReadOnlyDictionaryfor a hook that wants to inspect the whole bag. Line 49 is the short-circuit itself,public void SkipSave() => SaveSkipped = true, documented as idempotent (calling it twice is harmless). The bag has three accessors:Set<TValue>(string key, TValue value)(:56-61) writes, replacing any value under the same key;TryGet<TValue>(:69-81) reads with a type test,_items.TryGetValue(key, out var stored) && stored is TValue typed, so a key holding another type reads as absent rather than throwing; andGetOrDefault<TValue>(:93) is the one-line convenience overTryGetfor when "absent" and "default" need not be told apart.Contains(:99-104) tests presence whatever the value's type. All four throwArgumentNullExceptionon a null key. - Why it's built this way: ADR-099 records it, in the 2026-08-30 revision that closed the shapes still forcing a hand-written handler. The
SkipSavehalf is the more subtle contribution. It marks the command as already satisfied, which is a success, not a refused invariant: the workflow returns the loaded aggregate successfully, issues no save, and runs neitherLogMutatednorOnMutatedAsync(MutationContext.cs:20-24, enforced atMMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:300-301). Without it, the idempotent no-op (remove an avatar that is not there, close an already-closed record) had only two bad options: fail a command that did nothing wrong, or save nothing while logging a mutation that never happened. - Where it's used: created once per run by
CreateContext()(MutateEntityHandlerBase.cs:98, overridable) and threaded throughMutateCoreAsync(:270-308) intoLoadAsync(:280),MutateAsync(:293),LogMutated(:304) andOnMutatedAsync(:305), plusBuildResulton the payload-returning base (:418). In the framework it also reaches UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> (UpdateEntityHandler.cs:221) and the applier contract (IEntityUpdateCommandApplier.cs:59). Six ADC handlers use it in production: RemoveUserAvatarHandler is the clearest worked example, declaringprivate const string BlobNameKey = "Avatar.BlobName"(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:23), callingcontext.SkipSave()when there is no avatar to remove (:39), writing the blob name withcontext.Setbefore clearing the URL (:43), and reading it back withcontext.GetOrDefault<string>in the post-commit hook that deletes the blob (:65). The others are SetUserAvatarHandler, UpdateSessionHandler,UpdateEventHandler,AddRoomHandlerandSpeakerUpdateApplier. - Caveats / not-in-source: a type-mismatched read is indistinguishable from an absent key, by design (
MutationContext.cs:73), so a writer and a reader that disagree onTValuefail quietly rather than loudly. Nothing in the type validates key spelling either; theprivate const stringconvention is what keeps a writer and reader in agreement, and it is a convention, not an enforced rule.
DeleteEntityCommand<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityCommand.cs:13· Level 1 · record (sealed)
- What it is: a generic delete command carrying the entity's primary key
Idplus a cache prefix to evict. TheTEntitytype parameter does double duty: it discriminates handlers in DI, and it supplies the defaultCachePrefix. - Depends on: ICacheInvalidating (implemented, line 11) and BCL reflection (
typeof(TEntity).FullName). Participates in the pipeline described under ICommandHandler<in TCommand, TResult>; handled generically by DeleteEntityHandler<TEntity, TIdentifierType>. - Concept introduced, a type parameter as both a dispatch key and a data source.
[Rubric §1, SOLID]assesses single responsibility and extension without modification; one closed handler per entity type keeps dispatch unambiguous.[Rubric §6, CQRS & Event-Driven]assesses the write/read segregation; this is a pure write command. WithoutTEntity, every delete command sharing aTIdentifierType(sayint) would collapse onto one closed generic and handler registration would be ambiguous. The second use is the more interesting one: because the generic controller constructs this command itself, no caller is in a position to pass a cache prefix, so the command computes the conventional one. - Walkthrough: line 11 declares
public sealed record DeleteEntityCommand<TEntity, TIdentifierType>(TIdentifierType Id) : ICacheInvalidating; the record primary constructor makesIda positionalinitproperty, andsealedprevents inheritance. Line 12 constrainswhere TIdentifierType : notnull, forbidding a nullable key type. Line 20 implements the interface member:public string CachePrefix { get; init; } = typeof(TEntity).FullName + ":";. Two things follow from that one line. It isinit, so a caller that wants a narrower prefix can set one at construction. And its default is the aggregate-prefix convention every consumer already keys its cached reads under, which is exactly the prefix ADC's cached GetNowNextQuery sits behind (see IQueryCacheable). Setting it to an empty string is the documented opt-out (lines 14-19), and it works because the caching decorator refuses a blank prefix rather than evicting the whole cache. - Why it's built this way: it avoids hand-writing a bespoke
DeleteSessionCommand,DeleteSpeakerCommandand so on in every module; one generic command plus one generic handler covers the boilerplate while the type system still routes each call to the correct closed handler. ADR-099 records the generic write-side family this belongs to. Defaulting the prefix rather than leaving it blank means the generic delete path invalidates caches by default, which is the safe direction for a mutation whose call site cannot make the decision. - Where it's used: the aggregate-root and CRUD controller delete actions construct it directly: AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> injects
ICommandHandler<DeleteEntityCommand<TEntity, TIdentifierType>, Result>(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:35) and calls it with a fresh command at:93, and CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> takes the same dependency (CrudEntityControllerBase.cs:63). The default handler for it is registered byAddEntityCrudwithTryAddScoped(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:347-349), so a module that wants a cascade registers its own override first and wins.
UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityCommand.cs:48· Level 5 · record
- What it is: the generic update command for any aggregate root: the entity's primary key, the update request the aggregate has to accept, and the caller's last-observed concurrency token. It is the write-side twin of DeleteEntityCommand<TEntity, TIdentifierType>, and unlike that record it is deliberately not sealed.
- Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating, both implemented at
UpdateEntityCommand.cs:52; BCL reflection for the default prefix (typeof(TEntity).FullName,:63). Served by UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>, and by UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> when a module derives from it. - Concept introduced, a framework command designed to be inherited.
[Rubric §9, API & Contract Design]assesses whether a shared contract can absorb a caller's extra requirements without either forking or being widened for everyone;[Rubric §1, SOLID]assesses extension without modification. The problem this record solves is that a real update usually carries state the request body must not carry: an id taken from the route rather than the body, a flag the server decided rather than the caller, a second concurrency token for a child row (UpdateEntityCommand.cs:28-37). Flattening those into the request DTO would let a caller set them. The answer is that a module declares a positional record deriving from this one, inheritingId,Request,RowVersion, the validator bridge and the cache prefix, and adds its own properties beside them. ADC'sUpdateSpeakerCommandis exactly that shape: it addsCallerIsOrganizer, bound at the API edge and never from the body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerCommand.cs:20-25).[Rubric §6, CQRS & Event-Driven]: the mutation itself is nowhere in this file. It lives on the aggregate, reached through the module's applier, which is what keeps one generic command usable for every entity without the command knowing a single field name (:20-24). - Walkthrough
- The declaration (
UpdateEntityCommand.cs:48-53):public record UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>(TIdentifierType Id, TUpdateRequest Request, byte[] RowVersion), implementing both marker interfaces and constrainedwhere TIdentifierType : notnull. The three positional parameters becomeinitproperties, and the missingsealedis the design decision described above. Requestand the validator bridge: because the command carries the request rather than flattening it, implementing ICommandWithRequest<out TRequest> is enough for the framework to register aCommandRequestValidatorclosed over it, so a module writes onlyIValidator<TUpdateRequest>and the command is validated before the transaction opens (:13-19).AddEntityCrudregisters that bridge for the closed command itself, because a generic built at registration time cannot be seen by the module scan's reflection bridge (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:351-353, rationale at:308-317).RowVersion(:42-45): the caller's last-observed token, taken from the request'sIf-Matchheader, and required, because a conditional write with no precondition never reaches a handler (ADR-035).CachePrefix(:63):public string CachePrefix { get; init; } = typeof(TEntity).FullName + ":";. Same reasoning as the delete command: the generic controller constructs the command, so no caller is in a position to supply a prefix, and the default is the aggregate-prefix convention every consumer already keys its cached reads under. An empty string is the documented opt-out, and a derived command inherits both the default and the opt-out and can narrow it per verb (:53-62).- The verb-discriminated sibling (
:102-116):UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType, TApplier>derives from this record and adds a fourth, purely phantom type parameter. It exists because the three-parameter command keys on (entity, request, identifier), so an aggregate with two verbs over one request shape (an inventory item increased or decreased by the sameQuantitypayload) cannot close it twice: both verbs would resolve the same handler and the same applier (:72-81).TApplierdiscriminates them, constrained to anIEntityUpdateApplierimplementation (:109), and theApplierTypeproperty (:116) surfaces the discriminator in a log line or a test assertion instead of only in the type name. The wire shape does not change: route and request DTO stay what they were (:82-87).
- The declaration (
- Why it's built this way: ADR-099 records the whole generic write side and states the constraint that shaped this record: the mutation cannot move into the framework, because an update names fields and a framework type that names fields is a framework type per aggregate; worse, writing properties directly would route around the aggregate's guarded methods, where the invariants and the domain events live.
- Where it's used: CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest> injects
ICommandHandler<UpdateEntityCommand<...>, Result<TEntityDTO>>(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:62, exposed at:77) and constructs a fresh command in its PUT action (:105).AddEntityCrudregisters the default handler withTryAddScoped(DependencyInjection.cs:341-343); Helpdesk's Tickets module calls it (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/DependencyInjection.cs:54), and Store's Identity module calls it three times, once per update request shape (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:65-67). Store's Sales module registers the verb-discriminated sibling twice for inventory (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/DependencyInjection.cs:78-79).
IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/IEntityUpdateCommandApplier.cs:38· Level 6 · interface
- What it is: the command-aware twin of IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>. It applies an incoming update command to an already-loaded aggregate, receiving the whole command rather than only its request, so an update that depends on state the request does not carry can still run on the generic path.
- Depends on: UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> (the
TCommandconstraint,IEntityUpdateCommandApplier.cs:41); AuditableBaseEntity<TIdentifierType> (:39); MutationContext and Result (:56-60). - Concept introduced, contravariance plus a constraint used as an extension point.
[Rubric §1, SOLID]assesses interface segregation and dependency inversion: the framework depends on this one-method abstraction and the module supplies the field-naming half.[Rubric §4, DDD]assesses whether mutation stays behind the aggregate's guarded methods; the interface returns a bareResultprecisely because the instance handed in is the tracked one, so a successful apply has already mutated it in place and a failure must leave it untouched (:28-32). The load-bearing detail is theTCommandconstraint at:41: it is what gives an implementation the inheritedId,RequestandRowVersionalongside its own properties, and theinmodifier makes the parameter contravariant, so an implementation written against a derived command is usable wherever the framework asks for that exact closed interface. The interface lives beside the command rather than with the request-only applier for the same reason (:22-27). - Walkthrough: line 38 declares the interface with four type parameters, the fourth contravariant. Constraints at
:39-41pinTEntityto an auditable entity,TIdentifierTypetonotnull, andTCommandto a subclass of the generic update command. The single member,ApplyAsync(TEntity entity, TCommand command, MutationContext context, CancellationToken cancellationToken = default)(:56-60), returnsTask<Result>. The third parameter is the run's MutationContext: write a derived value the handler's post-save hooks or its result need, or callSkipSave()to finish an already-satisfied command without writing (:49-53). Implementations are auto-registered by Scrutor assembly scanning, exactly like the request-only applier (:10-11). - Why it's built this way: a request DTO is the body a caller sent, and plenty of updates depend on more than the body (
:14-21). Those extra values belong on the command, not smuggled into the request DTO where a caller could set them, so the applier has to see the command. It is one of the five surface extensions the 2026-08-30 revision of ADR-099 added to close the shapes that still forced a hand-written handler. - Where it's used: resolved and called by UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> (
MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:187, invoked at:223), whichAddEntityUpdateregisters (DependencyInjection.cs:450-452). ADC'sSpeakerUpdateApplieris the production implementation: it takes the command-aware form because BR-214 turns onCallerIsOrganizer, which the server decides at the API edge and the body must never carry (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateApplier.cs:13-14), and it uses that flag to keep the entity's storedIsTopSpeakeron a self-edit (:32) before delegating toSpeaker.Update(:34). ADC's registration test asserts the applier is registered under this exact closed interface (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/ConferenceCrudRegistrationTests.cs:115).
AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:31· Level 8 · class (abstract)
- What it is: the shared "add a child to an aggregate" workflow: load the parent tracked and with its child collection included, fail with
NotFoundwhen it is gone, delegate to the aggregate method that owns the invariant, save only on success, and answer with the new child's DTO. - Depends on: IUnitOfWork (primary-constructor injected,
ChildEntityHandlerBase.cs:32); ICommandHandler<in TCommand, TResult> (implemented at:32); AuditableAggregateRootEntity<TIdentifierType> (theTParentconstraint,:33); Result and Error (:111,:115,:124). - Concept introduced, an abstract member used to force a decision rather than to allow one.
[Rubric §2, Design Patterns]assesses the template-method shape: the base owns the sequence and the subclass supplies the steps.[Rubric §8, Data Architecture]assesses how a write behaves against the store, and this base is the clearest example in the framework of an eager-load choice being a correctness question rather than a performance one.Includesis abstract (:55) even though an empty list is a legal answer, because loading the child collection is what makes the aggregate's duplicate check meaningful; the comment inHandleAsyncsays what happens otherwise, the check runs against an empty in-memory list and a double-submit surfaces as a raw unique-index 409 (:105-106). Naming the collection therefore has to be a deliberate act, and an add whose aggregate genuinely reads no existing child returns[]and says so (:13-18), which is what ADC'sAddCategoryItemHandlerdoes with an explanatory comment (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:21-23). - Walkthrough
- Declaration (
ChildEntityHandlerBase.cs:31-35): five type parameters,ICommandHandler<TCommand, Result<TChildDTO>>as the implemented interface, and constraints pinningTParentto an auditable aggregate root andTIdentifierTypetonotnull. Note there is no constraint onTChildorTChildDTO. UnitOfWork(:37): the injected unit of work re-exposed as a protected property, so an override can reach another repository.HandlerName(:43): defaults toGetType().Name, the concrete handler's own name, which is what every hand-written copy passed.AsTracking(:49):trueby default, because a no-tracking load would turn the add into a silent no-op.Includes(:55),ParentId(:60),Apply(:69) andMapChild(:74): the four abstract members.ApplyreturnsResult<TChild>and is where the aggregate method that owns the invariant (uniqueness, capacity, state) is called; a failure short-circuits before the save.LogAdded(:83-86) andOnAddedAsync(:97-98): the two virtual post-commit hooks, both no-ops by default. The logging hook is empty on purpose, with the comment stating the reason: logging is per-module vocabulary, so the base provides only the call site (:85).HandleAsync(:101-125): resolve the repository from the unit of work (:103), load withGetByIdAsync(ParentId(command), Includes, AsTracking, ...)(:107-109), returnError.NotFoundstamped withWithSource(HandlerName)andWithTarget(typeof(TParent).Name)when the parent is missing (:110-111), runApplyand return its errors unchanged on refusal (:113-115), save (:117), then log, run the async hook, and answer withResult.Success(MapChild(child))(:119-124).MapChildrather than an injected mapper (:19-23): the DTO belongs to the child entity, whose identifier type is usually not the parent's, so an injectedIEntityDTOMapperclosed over the parent's types would be the wrong mapper. Implementations are one-liners into the module's own child mapper (AddCategoryItemHandler.cs:33).
- Declaration (
- Why it's built this way: the add-a-child slice is the same nine lines in every module, and the parts that genuinely differ (which id addresses the parent, which aggregate method to call, which mapper) are exactly the four abstract members. ADR-099 covers the generic write-side family this belongs to.
- Where it's used: ADC subclasses it across Conference for every child-collection add:
AddCategoryItemHandler(AddCategoryItemHandler.cs:19),AddEventSpeakerHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:19),AddSpeakerCategoryItemHandler(.../Speakers/UseCases/AddSpeakerCategoryItem/AddSpeakerCategoryItemHandler.cs:19) andAddSessionSpeakerHandler(.../Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:20). Exercised by ChildEntityHandlerBaseTests.
CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:42· Level 8 · class (abstract)
- What it is: the shared create-an-aggregate workflow: map the request through the entity's factory, add the new aggregate to its repository, save, and return the mapped DTO. A module's create handler subclasses it and adds only what is genuinely its own.
- Depends on: IUnitOfWork, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, all three primary-constructor injected (
CreateEntityHandlerBase.cs:43-45); ICommandHandler<in TCommand, TResult> (:45); ICreateRequest, AuditableAggregateRootEntity<TIdentifierType> and IBaseDTO<TIdentifierType> as constraints (:46-49); IRepository<TEntity, TIdentifierType> as a hook parameter (:130). - Concept introduced, attempt scope: passing the unit of work as a parameter rather than only using the injected one.
[Rubric §2, Design Patterns]for the template method;[Rubric §8, Data Architecture]for the retry story. A create path that computes its own primary key can lose a race and hit a unique-constraint collision, and a naive retry against the injected unit of work never persists, because the ambient DbContext still tracks the failed insert.CreateCoreAsynctherefore takes the unit of work as a parameter (:76-79), so a subclass can overrideHandleAsync, wrap the workflow in a retry loop, and run each attempt against a fresh DI scope's unit of work while reusing the whole workflow (:28-35). The same attempt-scope shape appears on the mutate side in MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>.[Rubric §3, Clean Architecture]: the repository is obtained from IUnitOfWork inside the workflow rather than constructor-injected, because only the unit of work knows which physical data source the entity resolves to (:23-27). - Walkthrough
- Declaration (
CreateEntityHandlerBase.cs:42-50): an abstract class that implements the handler interface. The class doc states why that is the shape (:16-22): the concrete app subclass stays the registered handler, soScanModuleApplicationServiceskeeps discovering it and the decorator pipeline keeps wrapping it, and Scrutor never registers the abstract base itself. Note also thatTCreateRequestis the command: there is no separate create command type. UnitOfWork(:52): protected re-exposure of the injected instance.HandleAsync(:55-62): null-guards the command and delegates straight toCreateCoreAsync(unitOfWork, ...). It isvirtual, which is the hook a retrying subclass overrides.CreateCoreAsync(:76-102): the workflow proper. Null-guard (:81),PrepareAsyncwith an early return on failure (:83-85), map throughrequestMapper.CreateEntityAsyncand return its errors unchanged (:89-91), resolve the repository from the attempt unit of work (:94),PersistAsync(:96), thenLogCreated,OnCreatedAsyncandResult.Success(dtoMapper.MapToDTO(entity))(:98-101).PrepareAsync(:113-117): pass-through by default (Task.FromResult(Result.Success(command))). Override to resolve an app-assigned primary key or to run a cross-aggregate validation whose failure must stop the create; a retrying subclass overrides it to recompute the id per attempt.PersistAsync(:128-139):repository.AddAsyncthenattemptUnitOfWork.SaveChangesAsync, which is what every create path does today; it exists as a hook for a create that needs a different persist step.LogCreated(:147-150) andOnCreatedAsync(:160-161): the two post-commit hooks.OnCreatedAsyncruns after the save, which is what makes it the right place to publish an integration event carrying the now-known database-generated id (:152-156).
- Declaration (
- Why it's built this way: ADR-099 targets the module scaffolded from the template, where the create handler is the same handful of lines every time; the base keeps the sequence and leaves the module its
[LoggerMessage]partial, its pre-map validation and its post-commit publish. - Where it's used: ADC's Conference module subclasses it for every aggregate create:
CreateEventHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:20),CreateSpeakerHandler(.../Speakers/UseCases/Create/CreateSpeakerHandler.cs:20),CreateSponsorHandler(.../Sponsors/UseCases/Create/CreateSponsorHandler.cs:20),CreateActivityHandler(.../Activities/UseCases/Create/CreateActivityHandler.cs:20),CreateConferenceCategoryHandler(.../Categories/UseCases/Create/CreateConferenceCategoryHandler.cs:20) andCreateQuestionHandler(.../Questions/UseCases/Create/CreateQuestionHandler.cs:25). Its zero-override closure is CreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>. Exercised by CreateEntityHandlerBaseTests.
DeleteEntityHandler<TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityHandler.cs:36· Level 8 · class
- What it is: the generic delete handler that works for any aggregate root, implementing
ICommandHandler<DeleteEntityCommand<TEntity, TIdentifierType>, Result>(DeleteEntityHandler.cs:38). It loads the aggregate, gives a subclass one chance to refuse, calls the aggregate's ownDelete(), and saves only when that succeeded. - Depends on: IUnitOfWork (primary-constructor injected,
:36); DeleteEntityCommand<TEntity, TIdentifierType> (the command it handles); ICommandHandler<in TCommand, TResult>; AuditableAggregateRootEntity<TIdentifierType> (constraint,:38); IRepository<TEntity, TIdentifierType> (:101); Result and Error. - Concept reinforced, one handler for every aggregate, left open for the two things a delete outgrows.
[Rubric §1, SOLID]and[Rubric §6, CQRS & Event-Driven]. The class is not sealed, and the doc names the two structural reasons a real delete needs more (:12-21): the child collections the aggregate's ownDelete()cascade has to see, declared inIncludes, and a cross-aggregate invariant that must refuse the delete before it happens, implemented inOnDeletingAsync. A subclass that overrides neither behaves exactly like this handler, down to the query it issues.[Rubric §4, DDD]: no events are raised here (:22-25); domain events belong to the aggregate'sDelete(), which is what keeps the generic path and a hand-written one indistinguishable from the outside.[Rubric §34, Architecture Governance & Documentation]: the framework's own naming fitness rules (handlers end inHandlerand are sealed) and the vertical-slice co-location rule are scoped to a repo's module assemblies, so this deliberately unsealed framework generic sits outside them while a consumer's subclass is a normal module handler and must be sealed and co-located (:26-31). - Walkthrough
HandlerName(:49):nameof(DeleteEntityHandler<,>), the open handler name rather than the runtime backtick-2 suffixed one, so the failure reads the same whichever subclass produced it.Includes(:57): empty by default, which issues the same by-id query the handler has always issued. The doc states the failure mode of not overriding it: an unloaded collection leaves its rows live under a soft-deleted parent (:51-56).AsTracking(:63):true, because a no-tracking load would turn the delete into a silent no-op.HandleAsync(:66-88): resolve the repository (:70),LoadAsync(:71),Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TEntity).Name)when null (:72-73),OnDeletingAsyncand an early return on refusal (:75-77), thenentity.Delete()(:79) with the save and the log guarded byresult.IsSuccess(:80-85), returning the entity's own result either way (:87).LoadAsync(:100-113): materializesIncludesonce into a read-only collection (:108) and then branches, calling the bareGetByIdAsync(command.Id, ...)when there is nothing to include and the eager-loading overload underAsTrackingwhen there is (:110-112). That branch is why an un-overridden subclass issues a byte-identical query to the base.OnDeletingAsync(:125-129): returns success by default. It is the hook for an invariant the aggregate itself cannot check because it spans more than the aggregate; a failure returns to the caller unchanged and nothing is saved.LogDeleted(:137-140): the usual no-op logging call site.
- Why it's built this way: most deletes are identical (load,
Delete(), save), so the framework supplies the handler once on top of Common's soft-delete convention (ADR-005), and a module overrides only where its aggregate genuinely differs. Returning the entity's ownDelete()result lets a domain-level refusal propagate as a value rather than an exception. The extensible shape (theIncludes,AsTracking,LoadAsyncandOnDeletingAsynchooks) is one of the five extensions in the 2026-08-30 revision of ADR-099. - Where it's used: registered as the delete slot by
AddEntityCrudwithTryAddScoped, so a module that registers its own subclass first wins (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:347-349,:300-307). Subclassed where an aggregate cascades: ADC'sDeleteSessionHandlerdeclares the three owned join collections BR-55 soft-deletes, because the cascade only reaches children actually loaded (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Delete/DeleteSessionHandler.cs:19,:25-26), ADC'sDeleteConferenceCategoryHandlerandDeleteLivePollHandlerdo the same shape (.../Categories/UseCases/Delete/DeleteConferenceCategoryHandler.cs:15,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Delete/DeleteLivePollHandler.cs:15), and Store'sDeleteCategoryHandlerincludesProductsand the inverse self-reference so the aggregate's two refusals can see their rows (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Categories/UseCases/Delete/DeleteCategoryHandler.cs:33,:39-40). Exercised by DeleteEntityHandlerTests. - Caveats / not-in-source: the command it handles does not implement ITransactional, so a delete that raises domain events relies on
SaveChangesAsyncwriting the data plus its outbox rows in one transaction rather than on a handler-level transaction; the outbox is the durability mechanism (ADR-003). Cache invalidation is free, because the command implements ICacheInvalidating with a defaulted prefix.
MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:52· Level 8 · class (abstract)
- What it is: the shared load-mutate-save machinery behind every aggregate write handler: resolve the repository, load the aggregate tracked and with the includes the mutation needs, fail with
NotFoundwhen it is gone, stamp the caller's optimistic-concurrency token, run the domain mutation, and save only when the mutation succeeded. It is the single most reused type on the write side of this codebase. - Depends on: IUnitOfWork (primary-constructor injected,
MutateEntityHandlerBase.cs:52); AuditableAggregateRootEntity<TIdentifierType> (constraint,:52); IRepository<TEntity, TIdentifierType> (:152,:279,:291); MutationContext (:97,:273,:299); Result and Error. - Concept introduced, a base class that deliberately implements no handler interface.
[Rubric §1, SOLID]and[Rubric §6, CQRS & Event-Driven]. This type does not implement ICommandHandler<in TCommand, TResult>, and the reason is registration mechanics (:16-27): the three shapes a real handler returns (a bareResultfor verb-style commands, aResult<T>carrying the refreshed DTO, and aResult<T>carrying a payload the handler builds) are supplied by three thin subclasses, and keeping the machinery in a fourth, interface-free type means a single handler type never advertises two handler interfaces, which would otherwise register a bogus second handler entry during the module scan.[Rubric §8, Data Architecture]: the concurrency stamp at:290-291is the ADR-035 mechanism made generic, and the block comment above it spells out the alternative it prevents, silent last-write-wins (:284-289).[Rubric §29, Resilience, Reliability & Business Continuity]: the MutationContext is how a value derived while the aggregate was loaded reaches the post-save hooks and the handler's own result without handler instance state (:32-38). - Walkthrough
- Declaration (
:51-53):public abstract class MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>(IUnitOfWork unitOfWork), with no base type and no interface.TCommandis unconstrained. UnitOfWork(:56),HandlerName(:62, defaults toGetType().Name),Includes(:69, empty),AsTracking(:75,true): the same four knobs the delete handler exposes.EntityId(:80): the one abstract member, extracting the aggregate's primary key from the command.RowVersion(:90): returnsnullby default, for a mutation whose endpoint states no precondition; a command reached through a conditional (If-Match) endpoint overrides it, where the token is always present.CreateContext(:97):new()by default; override only to pre-seed values every hook expects.- The paired overloads.
MutateAsync(:118-120and:135-140),LoadAsync(:151-159and:171-176),LogMutated(:186-189and:199-200) andOnMutatedAsync(:212-213and:226-231) each exist twice, once context-free and once context-aware, with the context-aware overload forwarding to the context-free one by default. The workflow always calls the context-aware member, so a handler that needs no context keeps overriding the simple overloads and never sees aMutationContext. The exception isMutateAsync, whose context-free overload throwsInvalidOperationExceptionnaming the offending type (:118-120): overriding exactly one of the two is required, and overriding neither is a programming error caught at first execution rather than silently doing nothing. MutateCoreAsync, three overloads (:240-241,:254-258,:270-308). The first two are conveniences that create a context and forward; the third is the workflow. It null-guards (:276-277), resolves the repository from the attempt unit of work (:279), loads through the context-awareLoadAsync(:280), returnsError.NotFound.WithSource(HandlerName).WithTarget(typeof(TEntity).Name)when the aggregate is gone (:281-282), stampsrepository.SetOriginalRowVersion(entity, rowVersion)when the handler reported a non-empty token (:290-291), runs the mutation and returns its errors unchanged on refusal (:293-295), short-circuits oncontext.SaveSkippedby returning success without saving and without running either post-save hook (:297-300), otherwise saves (:302), logs, runsOnMutatedAsync, and returns the mutated aggregate (:304-307).- Attempt scope (
:39-46,:254-258): as on the create side, the unit of work is a parameter, so a handler whose write can lose a race can wrap the workflow in a retry loop and run each attempt against a fresh DI scope's unit of work.
- Declaration (
- Why it's built this way: the workflow existed before the generic write side did, and ADR-099 records that the missing pieces were a command and a handler generic enough to close over any aggregate, not the workflow itself. The paired-overload design is what let the mutation context and the skip-save short circuit be added additively: every existing subclass compiles and behaves exactly as before.
- Where it's used: as the base of MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> (
:320), of the four-parameter DTO-returningMutateEntityHandlerBase<TCommand, TEntity, TIdentifierType, TEntityDTO>(:345), and of MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> (:389). Every generic and hand-written aggregate write handler in ADC and Store reaches it through one of those three. Exercised by MutateEntityHandlerBaseTests and MutationContextTests.
CreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandler.cs:28· Level 9 · class (sealed)
- What it is: the ready-made create handler, CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> with none of its hooks overridden. Closing this type over an aggregate's four types is the whole create registration; no subclass is needed at all.
- Depends on: its base class and the same three injected collaborators, forwarded through the primary constructor (
CreateEntityHandler.cs:29-32): IUnitOfWork, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>. Constraints repeat the base's (:33-36). - Concept reinforced, "sealed floor, open base". See CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> for the workflow.
[Rubric §15, Best Practices & Code Quality]assesses whether the cheap path and the extensible path are distinguishable; here they are two types rather than one. This one issealedon purpose, and the doc says why (:14-20): the base, not this, is the extension point, so a create that needs a pre-map step, a module-specific log message, a post-commit publish or the manual-id retry loop reaches for the base instead of subclassing this. - Walkthrough: the entire type is a declaration with a semicolon body (
:28-36). There is no{ }block: every hook on the base is virtual and none is overridden, which is precisely the claim the type is making. - Why it's built this way: ADR-099 targets the scaffolded module where create, update and delete are the same lines every time; this type is the create verb of the one-call registration.
- Where it's used:
AddEntityCrudregisters it as the create slot withTryAddScoped(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:339-341), so an aggregate whose create outgrows it registers its own handler before that call and keeps the generic pair for the other two verbs (:300-307). Helpdesk's Tickets module gets its create this way (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/DependencyInjection.cs:54), as does Store's Identity module forCustomer(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:65-67).
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:320· Level 9 · class (abstract)
- What it is: the write handler over an existing aggregate that answers with a bare Result: the verb-style commands (publish, unpublish, open, close, moderate, rename, remove a child) where the caller needs only success or the refused invariant.
- Depends on: MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> (base,
MutateEntityHandlerBase.cs:321), which it forwards the injected IUnitOfWork to; ICommandHandler<in TCommand, TResult> closed overResult(:321); AuditableAggregateRootEntity<TIdentifierType> (constraint,:322). - Concept reinforced, the return-shape split. The teaching for the workflow itself is under MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>.
[Rubric §9, API & Contract Design]assesses what a use case hands back: this is the "nothing to render" answer, and choosing it rather than returning a DTO nobody reads is what keeps a verb endpoint from implying the caller should re-render from the response. - Walkthrough: the whole type is nine lines. The declaration (
:319-323) inherits the core and adds the handler interface.HandleAsync(:326-331) awaitsMutateCoreAsync(command, cancellationToken)and collapses theResult<TEntity>it returns into a bareResult: the errors on failure,Result.Success()otherwise, discarding the aggregate the core handed back. It isvirtual, which is the hook a subclass overrides to run a pre-flight check before the workflow (Store's inventory handlers do exactly that on the update side). - Why it's built this way: it is one of the three thin subclasses ADR-099 describes; splitting by return shape is what lets the core implement no handler interface and so avoid a duplicate registration during the module scan (
:16-27). - Where it's used: it is the most-subclassed write base in ADC. Conference uses it for
PublishEventHandlerandUnpublishEventHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:16,.../Unpublish/UnpublishEventHandler.cs:16),UpdateRoomHandler(.../Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:16),UpdateEventQuestionAnswerHandlerandRemoveEventQuestionAnswerHandler(.../UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:24,.../RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:24),UpdateCategoryItemHandler(.../Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemHandler.cs:22), andLinkUserToSpeakerHandler/UnlinkUserFromSpeakerHandler(.../Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:28,.../UnlinkUser/UnlinkUserFromSpeakerHandler.cs:28); Engagement uses it forModerateQuestionHandler,OpenLivePollHandlerandCloseLivePollHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:28,.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:26,.../LivePolls/UseCases/Close/CloseLivePollHandler.cs:24); Identity uses it forRemoveUserAvatarHandler(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:20). It is also the base of RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> (ChildEntityHandlerBase.cs:144). Exercised by MutateEntityHandlerBaseTests. - Caveats / not-in-source: a four-parameter sibling declared in the same file,
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType, TEntityDTO>(:342-359), is the DTO-returning flavor: it takes an IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> beside the unit of work and maps the mutated aggregate on success (:358). Generic types overload by arity, so the two share a name; ADC'sUpdateQuestionHandleruses the four-parameter one (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:23).
RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:143· Level 10 · class (abstract)
- What it is: the shared remove-a-child-from-an-aggregate workflow. It is MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> with exactly one change: the child collection becomes a required include.
- Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> (base,
ChildEntityHandlerBase.cs:144), to which it forwards the injected IUnitOfWork; AuditableAggregateRootEntity<TIdentifierType> (constraint,:144). Its workflow comes from MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> two levels up. - Concept introduced,
abstract override: re-opening an inherited virtual member to force the subclass to answer.[Rubric §1, SOLID]and[Rubric §15, Best Practices & Code Quality]. C# lets a derived class re-declare an inheritedvirtualmember asabstract override, which removes the base's default and makes the member mandatory again for anything deriving further. That single line is this type's entire body (:151), and the reason is stated at:128-133: a remove that cannot see the collection cannot find the child, so it reports a wrongNotFoundinstead of removing anything. The base's emptyIncludesdefault is a silently wrong answer for this one workflow, so the type deletes it. Compare AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO>, which reaches the same outcome by declaringIncludesabstract from the start. - Walkthrough: the declaration (
:142-145) and one member.protected abstract override IEnumerable<string> Includes { get; }(:151) is the whole body. Everything else (EntityId,MutateAsync,LogMutated, the load-stamp-mutate-save sequence and the bare-Resultreturn) is inherited. The type doc gives the intended implementation shape (:134-138):MutateAsyncis a one-line call into the aggregate's remove method wrapped inTask.FromResult, and a remove whose command can also arrive addressed by the child's own id (with no parent id) overridesLoadAsyncto resolve the owning root. - Why it's built this way: it is a two-line type that buys a compile-time error instead of a runtime
NotFoundbug, on the one workflow where the eager-load is not an optimization. Part of the generic write-side family in ADR-099. - Where it's used: ADC's Conference module subclasses it for every child removal:
RemoveRoomHandler, which is the canonical shape (declaration atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:16, the mandatoryIncludes => [nameof(Event.Rooms)]at:19,EntityIdat:22, andMutateAsyncas a singleTask.FromResult(entity.RemoveRoom(...))at:25-29), plusRemoveEventSpeakerHandler(.../RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:16),RemoveCategoryItemHandler(.../Categories/UseCases/RemoveCategoryItem/RemoveCategoryItemHandler.cs:16) andRemoveSpeakerCategoryItemHandler(.../Speakers/UseCases/RemoveSpeakerCategoryItem/RemoveSpeakerCategoryItemHandler.cs:16). Exercised by ChildEntityHandlerBaseTests.
UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:185· Level 10 · class
- What it is: the generic update handler for a derived command: an UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> subclass that carries state beside the request. It runs the same load-stamp-apply-save workflow but hands the whole command to an IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand>.
- Depends on: IUnitOfWork, the command-aware applier, and IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, all three primary-constructor injected (
UpdateEntityHandler.cs:186-188); the four-parameter DTO-returningMutateEntityHandlerBaseas its base (:189), and through it MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>. - Concept reinforced, the derived-command path. See UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> for why a command is derived at all.
[Rubric §5, Vertical Slice]assesses whether one use case's extra requirement stays inside that slice: here the module declares its own command and its own applier in the slice folder, and the framework supplies the handler unchanged.[Rubric §11, Security]: the shape matters most where the extra state is a server-side decision, since puttingCallerIsOrganizeron the command rather than in the request DTO is what stops a crafted body from setting it. - Walkthrough
- Declaration (
:185-193): five type parameters, withTCommandconstrained to a subclass of the generic update command (:190). Because the base is closed overTCommanditself, the handler serves the derived type directly. HandlerName(:199):$"{nameof(UpdateEntityCommandHandler<,,,,>)}<{typeof(TCommand).Name}>", so several derived commands over one aggregate produce distinguishableNotFoundfailures.EntityId(:202-207) andRowVersion(:210-215): both null-guard and return the inheritedcommand.Idandcommand.RowVersion, which is what keeps the ADR-035 stamp working for a derived command with no extra code.MutateAsync(:218-223): overrides the context-aware overload, not the context-free one, and forwardsentity, the wholecommand, the run's MutationContext and the token toupdateApplier.ApplyAsync. That is the one behavioral difference from the two sibling handlers in this file: the applier can read the context and can callSkipSave()on it.
- Declaration (
- Why it's built this way: it is one of the five 2026-08-30 extensions in ADR-099, added so a command carrying route-derived or server-decided state no longer forces a hand-written handler. Registration and the validator bridge stay one call (
:172-178). - Where it's used:
AddEntityUpdate<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>registers it withTryAddScopedand bridges the derived command toIValidator<TUpdateRequest>(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:444-459). ADC's Conference module rides this path forSpeaker, pairingUpdateSpeakerCommandwithSpeakerUpdateApplier, which its registration test asserts (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/ConferenceCrudRegistrationTests.cs:102,:114). MMCA.Common's ownWriteSideExtensionsTestscovers the registration, theTryAddprecedence and the validator bridge (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/UseCases/WriteSideExtensionsTests.cs:547,:558,:602).
UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:48· Level 10 · class
- What it is: the generic update handler for the plain (non-derived) command. It loads the aggregate, stamps the caller's concurrency token, hands the request to the module's IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>, saves only when the aggregate accepted the change, and answers with the refreshed DTO. It is the update counterpart of DeleteEntityHandler<TEntity, TIdentifierType>.
- Depends on: IUnitOfWork, the applier, and IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, primary-constructor injected (
UpdateEntityHandler.cs:49-51); the four-parameter DTO-returningMutateEntityHandlerBase, closed over UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> (:52-56); AuditableAggregateRootEntity<TIdentifierType> and IBaseDTO<TIdentifierType> as constraints (:57-59). - Concept reinforced, unsealed generic plus a documented "no new hook needed" pattern.
[Rubric §1, SOLID]and[Rubric §15, Best Practices & Code Quality]. Like the delete handler it is left unsealed so a module can subclass it to declare theIncludesits aggregate's mutation needs or to add a[LoggerMessage]partial without giving up the shared workflow (:15-21). The interesting part is what the doc argues against adding (:22-32): a subclass that has to touch the aggregate after it is loaded and before the applier runs (the common case being ADR-035's second concurrency token,SetOriginalRowVersionon a tracked child row that a nested update addresses, which the base'sRowVersionhook cannot reach because that hook stamps the root) does not get a new hook. It overridesMutateAsync, does its work againstUnitOfWork.GetRepository<TEntity, TIdentifierType>(), then awaitsbase.MutateAsync(...)to run the applier: the aggregate is already loaded and already root-stamped, and a refusal returned before the base call stops the write.[Rubric §4, DDD]: no events are raised here (:33-37); a handler that published anything of its own would fire for the generic path and stay silent for a hand-written one. - Walkthrough
HandlerName(:65):nameof(UpdateEntityHandler<,,,>), the open handler name rather than the runtime backtick-4 suffixed one, so aNotFoundfailure reads the same as the hand-written handlers it replaces.EntityId(:68-73) andRowVersion(:76-81): null-guard and returncommand.Idandcommand.RowVersion. Note the return type narrows to non-nullablebyte[], matching the command's required token.MutateAsync(:84-92): overrides the context-free overload and returnsupdateApplier.ApplyAsync(entity, command.Request, cancellationToken), passing only the request. That is the whole difference from UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>.- The verb-discriminated sibling (
:115-163): a five-parameterUpdateEntityHandlerthat serves the four-parameter command and injectsTApplierby its concrete type (:117), which resolves because the module scan registers an applier as itself as well as by its interfaces (:104-108). ItsHandlerNamefolds the applier name in (:133), so two verbs over one aggregate produce distinguishable failures.
- Why it's built this way: the update verb was the one every aggregate still hand-wrote, at roughly the same twelve lines each time, and ADR-099 closed it by pairing this handler with the generic command and the module-owned applier. The repository comes from the unit of work through the inherited workflow and is never constructor-injected, because only the unit of work knows which physical data source the aggregate resolves to (
:38-42). - Where it's used:
AddEntityCrudregisters it as the update slot withTryAddScoped(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:343-345), andAddEntityUpdateVerbregisters the five-parameter sibling once per verb (:391-410). Subclassed where an aggregate needs an eager load: Helpdesk'sTicketUpdateHandleradds nothing butIncludes => [nameof(Ticket.Comments)](MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/Update/TicketUpdateHandler.cs:37,:41), and Store'sCategoryAssignParentUpdateHandlertakes the same route (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Categories/UseCases/AssignParentCategory/CategoryAssignParentUpdateHandler.cs:40). Store's inventory pair rides the verb-discriminated sibling and overridesHandleAsyncto run a cross-service existence guard before the workflow (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Inventory/UseCases/AdjustInventory/IncreaseInventoryHandler.cs:44-65, and the decrease twin at.../DecreaseInventoryHandler.cs:29). Exercised byUpdateEntityHandlerTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/UseCases/Crud/UpdateEntityHandlerTests.cs:189).
MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Crud·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:388· Level 14 · class (abstract)
- What it is: the third return-shape flavor of the mutate workflow, the write handler that answers with a payload of its own choosing rather than the aggregate's DTO: the refreshed DTO plus a warning the caller has to surface, a projection of one changed field, a receipt for the file the write replaced.
- Depends on: MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> (base,
MutateEntityHandlerBase.cs:390), to which it forwards the injected IUnitOfWork; ICommandHandler<in TCommand, TResult> closed overResult<TResultPayload>(:390); MutationContext (:399,:418); AuditableAggregateRootEntity<TIdentifierType> (constraint,:391). - Concept introduced, an unconstrained result type plus a context that outlives the mutation.
[Rubric §9, API & Contract Design]assesses whether a use case can express an answer the resource DTO cannot.TResultPayloadcarries no constraint (:391-392), which is the whole point: the DTO flavor can only answer with anIBaseDTO<TIdentifierType>mapped by the aggregate's registered mapper, right for "the caller re-renders the aggregate" and wrong for everything else (:368-377).[Rubric §9, API & Contract Design]: the handler builds the answer itself and can read both the mutated aggregate and whatever the mutation wrote into the MutationContext while the aggregate was loaded, so a pre-mutation value can reach the response without handler instance state, which matters because handlers are scoped services and instance fields across an async workflow are a concurrency hazard. One naming note: it is a sibling of the other two rather than a fourth type parameter on the DTO flavor, because generic types overload by arity alone and a four-parameterMutateEntityHandlerBasealready exists (:378-381). - Walkthrough
- Declaration (
:387-392): four type parameters, the core as base, the command-handler interface closed overResult<TResultPayload>. HandleAsync(:395-406): creates the context itself withCreateContext()(:399), rather than letting the convenience overload create one, then calls the three-argumentMutateCoreAsync(UnitOfWork, command, context, ...)(:401) so the same context instance is still in hand after the workflow returns. On failure it forwards the errors; on success it callsBuildResult(result.Value!, command, context)(:403-405). That is the mechanical reason this flavor exists as its own class: the other two never need the context back.BuildResult(:418): the single abstract member,protected abstract Result<TResultPayload> BuildResult(TEntity entity, TCommand command, MutationContext context). It is synchronous and returns aResult, so a payload that cannot be built is a failure value rather than an exception. It is called only on success, after the save, or after aSkipSaveshort circuit, where the aggregate is the one that was loaded and left untouched (:408-413).
- Declaration (
- Why it's built this way: it is one of the five extensions the 2026-08-30 revision of ADR-099 added to close the shapes that still forced a hand-written handler, and it is additive: every existing subclass of the other two flavors compiles and behaves exactly as before.
- Where it's used: ADC's
UpdateEventHandleris the reference case (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:22). ItsMutateAsynccompares the incoming timezone against the stored one beforeEvent.Updateoverwrites it, only pays for a session-existence lookup when the value actually moves, and writes the BR-131 warning flag into the context under a private key (:43-57);BuildResultthen assembles theUpdateEventResultenvelope from the aggregate plus that flag (:80). ADC also uses it forAddRoomHandler(.../Events/UseCases/AddRoom/AddRoomHandler.cs:28) and forSetUserAvatarHandler(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:28). Exercised by MutateEntityHandlerBaseTests.
CqrsMetrics
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CqrsMetrics.cs:21· Level 0 · class (static, internal)
- What it is: an
internal staticclass that owns oneSystem.Diagnostics.Metrics.Meterand six instruments: two duration histograms (cqrs.command.duration,cqrs.query.duration), two cache counters (cqrs.query.cache.hit,cqrs.query.cache.miss), and two short-circuit counters (cqrs.authorization.denied.count,cqrs.timeout.count). The logging decorators record duration on every command and query; the caching query decorator records hit and miss; the authorization and timeout decorators record their denials and expiries. Together they give RED (Rate / Errors / Duration) instrumentation for the whole CQRS pipeline, plus a cache hit ratio and two short-circuit rates. - Depends on: BCL only (
System.Diagnostics.Metrics,CqrsMetrics.cs:1). Recorded into by LoggingCommandDecorator<TCommand, TResult>, LoggingQueryDecorator<TQuery, TResult>, CachingQueryDecorator<TQuery, TResult>, AuthorizationCommandDecorator<TCommand, TResult>, AuthorizationQueryDecorator<TQuery, TResult>, TimeoutCommandDecorator<TCommand, TResult>, and TimeoutQueryDecorator<TQuery, TResult>. - Concept introduced, BCL-native metrics and RED instrumentation.
[Rubric §13, Observability & Operability]assesses whether every unit of work emits rate, error, and latency signals an operator can dashboard and alert on. A single histogram tagged byoutcomesupplies all three dimensions at once: the measurement value is duration, the count is the rate, and the count filtered to a failureoutcomeis the error rate (the class doc says exactly this,CqrsMetrics.cs:6-10). UsingSystem.Diagnostics.Metricsrather than a third-party client means the OpenTelemetry SDK exports these once a host registers the meter name. The doc notes the meter name is duplicated as a literal string inMMCA.Common.Aspirebecause that package holds no reference to Application (CqrsMetrics.cs:8-10), and the literal is verifiably there:.AddMeter("MMCA.Common.Cqrs")in the Aspire service defaults (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:200), second in a list of seven meters that also covers Outbox, Idempotency, Scheduler, Broker, OutputCache, and BestEffort (Extensions.cs:199-205). The two names agree today, but nothing in the compiler enforces that they keep agreeing. - Walkthrough
MeterName(CqrsMetrics.cs:24):internal const string = "MMCA.Common.Cqrs", the single name a host registers for export.Meter(CqrsMetrics.cs:26): aprivate static readonly Metercreated once at class initialization, so no instrument is re-registered per decorator instance.CommandDuration(CqrsMetrics.cs:29-32) andQueryDuration(CqrsMetrics.cs:35-38):internal static readonly Histogram<double>instruments namedcqrs.command.durationandcqrs.query.duration, unit"ms", each described as tagged by name and outcome.QueryCacheHits(CqrsMetrics.cs:41-44) andQueryCacheMisses(CqrsMetrics.cs:47-50):Counter<long>instruments namedcqrs.query.cache.hitandcqrs.query.cache.miss, unit"{query}". Charting one over their sum is the per-query hit ratio, which is how an operator spots a cache that has quietly stopped serving reads (CqrsMetrics.cs:11-14).AuthorizationDenied(CqrsMetrics.cs:53-56):Counter<long>namedcqrs.authorization.denied.count, unit"{request}", taggedrequest_type. It exists so a permission that is denying far more traffic than expected is visible as a metric rather than only as a client-side error rate (CqrsMetrics.cs:15-19).TimeoutExpired(CqrsMetrics.cs:59-62):Counter<long>namedcqrs.timeout.count, unit"{request}", taggedrequest_type, counting the commands and queries whose IHasTimeout budget expired before the handler completed.- Recording helpers (
CqrsMetrics.cs:66-67,:71-72,:76-77,:81-82):RecordCacheHit,RecordCacheMiss,RecordAuthorizationDenied,RecordTimeout, each a one-lineAdd(1, ...)with a single tag. Exposing methods rather than the raw counters keeps every tag name spelled once. - Visibility: everything is
internal, so only decorators in this assembly can record measurements and no external code can pollute the series.
- Why it's built this way: one static holder avoids the duplicate-instrument problem that would follow from each closed generic decorator creating its own meter, and the tag dimension (rather than one counter per outcome) is the idiomatic OpenTelemetry shape for RED. The two short-circuit counters arrived with the decorators that emit them, in the 2026-08-18 revision of ADR-014.
- Where it's used: LoggingCommandDecorator<TCommand, TResult> records
CommandDuration(LoggingCommandDecorator.cs:79-83); LoggingQueryDecorator<TQuery, TResult> recordsQueryDuration(LoggingQueryDecorator.cs:77-81); CachingQueryDecorator<TQuery, TResult> callsRecordCacheHitatCachingQueryDecorator.cs:76and:100andRecordCacheMissat:91and:110; the authorization decorators callRecordAuthorizationDenied(AuthorizationCommandDecorator.cs:67,AuthorizationQueryDecorator.cs:62); the timeout decorators callRecordTimeout(TimeoutCommandDecorator.cs:78,TimeoutQueryDecorator.cs:78). - Caveats / not-in-source: the
outcometag values are set by the logging decorators, not here. They are"completed","failed", or"exception"(LoggingCommandDecorator.cs:44,:48,:57), a three-valued dimension rather than a bare success/failure pair. Note also the tag-name asymmetry: the duration histograms tagcommandandquerywhile the two short-circuit counters tagrequest_type, so a dashboard cannot join them on one label without a rename.
ProfilingCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ProfilingCommandDecorator.cs:12· Level 1 · class (sealed)
- What it is: a decorator that wraps command handler execution in a MiniProfiler step, so each command shows up as a timed node in a MiniProfiler trace.
- Depends on:
StackExchange.Profiling(NuGet, MiniProfiler,ProfilingCommandDecorator.cs:2); ICommandHandler<in TCommand, TResult> (both the inner handler it wraps and the interface it implements,ProfilingCommandDecorator.cs:12-13). - Concept introduced, opt-in profiling kept out of the standard pipeline.
[Rubric §13, Observability & Operability]assesses developer-facing profiling for pinpointing where time goes inside a request. This is the plain Decorator shape (a handler holding a handler), but unlike the seven standard command decorators it is not registered byAddApplicationDecorators()(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-137). It is added only by the separateAddApplicationProfiling()extension (DependencyInjection.cs:567-573), so a host that never calls that method never pays for it. - Walkthrough:
HandleAsync(ProfilingCommandDecorator.cs:16) opensusing var step = MiniProfiler.Current?.Step($"CommandHandler: {typeof(TCommand).Name}")(ProfilingCommandDecorator.cs:18) and then awaits the inner handler (ProfilingCommandDecorator.cs:19). The null-conditional?.Step(...)makes the whole body a no-op when no MiniProfiler is ambient for the current request, so there is no measurable cost when profiling is off; the step name carries the command type name so the profile is readable. - Why it's built this way: keeping profiling in its own opt-in decorator (rather than folding it into the always-on logging decorator) means the profiler overhead and its ambient-profiler dependency exist only when a host explicitly turns it on. It is also the one extension that may legally be added after the pipeline is sealed:
AddApplicationProfiling()does not callThrowIfPipelineSealed, unlikeAddApplicationDecorators()(DependencyInjection.cs:119,:565-568). - Where it's used: registered by
AddApplicationProfiling()(DependencyInjection.cs:569) to wrap every command handler. Its counterpart middleware is wired in the API layer (see MiniProfilerExtensions). - Caveats / not-in-source: no host in this workspace calls
AddApplicationProfiling()today. A repo-wide search for the method name finds only its declaration (DependencyInjection.cs:567) and two test files (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:109,:119, andMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/ApplicationPipelineCompositionTests.cs:139), so this decorator is a tested extension point with no current adopter.
ProfilingQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ProfilingQueryDecorator.cs:12· Level 1 · class (sealed)
- What it is: the query-side twin of ProfilingCommandDecorator<TCommand, TResult>. Same shape, one method, wrapping an IQueryHandler<in TQuery, TResult> (
ProfilingQueryDecorator.cs:12-13). - Depends on:
StackExchange.Profiling(NuGet,ProfilingQueryDecorator.cs:2); IQueryHandler<in TQuery, TResult>. - Concept reinforced: see ProfilingCommandDecorator<TCommand, TResult>.
[Rubric §13, Observability & Operability]. Also registered only by the opt-inAddApplicationProfiling()(DependencyInjection.cs:570), never by the standardAddApplicationDecorators(). - Walkthrough:
HandleAsync(ProfilingQueryDecorator.cs:16) opensMiniProfiler.Current?.Step($"QueryHandler: {typeof(TQuery).Name}")(ProfilingQueryDecorator.cs:18), a no-op when the profiler is inactive, then awaits the inner query handler (ProfilingQueryDecorator.cs:19). - Where it's used: registered by
AddApplicationProfiling()to wrap every query handler; like its command twin, no host calls that method today.
TenantCacheKey
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:25· Level 1 · class (static, internal)
- What it is: a two-member
internal statichelper that turns a cache key (or a cache prefix) into its tenant-scoped form. Both caching decorators run every key they touch through this one method, which is what keeps reads and invalidations pointing at the same entries. - Depends on: ITenantContext (
MMCA.Common.Application.Interfaces,TenantCacheKey.cs:1,:37). Nothing else, not even the cache abstraction: this type only computes strings. - Concept introduced, tenant isolation applied where the key is computed.
[Rubric §11, Security]assesses whether one customer's data can reach another;[Rubric §30, Compliance, Privacy & Data Governance]assesses tenant data boundaries. The problem the class doc states (TenantCacheKey.cs:9-14) is a lifetime mismatch:ICacheServiceis a singleton and cannot see the scoped tenant, so if two tenants both computed the cache keyproducts, the cache would happily serve one tenant's rows to the other. The framework's answer is not a tenant-aware cache but a tenant-aware key, computed once in one place so the query decorator's read and the command decorator's eviction cannot drift apart. The scoped form is deliberately a prefix, not a suffix (TenantCacheKey.cs:15-19), because prefix eviction is the invalidation primitive: evictingt:acme:productsremoves exactly that tenant's product entries, and no command in one tenant can evict another tenant's cache. Multi-tenancy as a whole is ADR-073, which is opt-in. - Walkthrough
Marker(TenantCacheKey.cs:28):internal const string Marker = "t:", the two-character opener of a scoped key. The doc comment gives the reason it is short: it is on every key (TenantCacheKey.cs:27).Scope(ITenantContext?, string)(TenantCacheKey.cs:37-40): the whole implementation is one expression. The patterntenantContext is { IsResolved: true, TenantId: { } tenantId }(TenantCacheKey.cs:38) tests three things at once: the context is not null (a host that never registered tenancy passesnull), it has resolved a tenant, andTenantIdis non-null, binding it in the same test. When all three hold it returnsstring.Concat(Marker, tenantId, ":", key); otherwise it returnskeyunchanged (TenantCacheKey.cs:39-40).
- Why it's built this way: the untouched-key fallback is the upgrade story (
TenantCacheKey.cs:20-23): a single-tenant host has byte-identical keys to the pre-tenancy framework, so upgrading orphans no cache entry and changes no behavior. Centralizing the transformation in oneinternal staticmethod rather than duplicating a$"t:{id}:{key}"interpolation in each decorator is what makes the symmetry argument checkable: there is exactly one function to read. - Where it's used: CachingQueryDecorator<TQuery, TResult> calls it from
EffectiveKey(CachingQueryDecorator.cs:57-58), whose one result is then used for the read, the stampede lock, and the populate; CachingCommandDecorator<TCommand, TResult> calls it on the command'sCachePrefixbefore eviction (CachingCommandDecorator.cs:67). - Caveats / not-in-source:
Scopedoes nothing to sanitizetenantIdorkey, so a tenant id containing:would produce an ambiguous key. Whether the tenant resolver can ever yield such an id is decided in ITenantContext's implementation, not here.
QueryCacheKeyLocks
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:246· Level 2 · class (static, internal)
- What it is: a tiny
internal staticholder for the process-wide lock set used by CachingQueryDecorator<TQuery, TResult> for cache-stampede protection. It is declared at the bottom of the same file as the decorator and has one member: a KeyedSemaphoreStripe, a fixed-width set of pre-allocated semaphores that cache keys are hashed onto. - Depends on: KeyedSemaphoreStripe (
MMCA.Common.Shared.Concurrency,usingatCachingQueryDecorator.cs:7; BCLSemaphoreSlimunderneath). Consumed exclusively by CachingQueryDecorator<TQuery, TResult>. - Concept introduced, why a shared lock table must live in a non-generic class.
[Rubric §12, Performance & Scalability]assesses whether a cache guards against the stampede (thundering herd) in which many concurrent requests all miss a just-expired hot key and all run the expensive query at once. The lock has to be shared by every request for the same key, but the decorator is an open generic. Astaticfield on a generic type is per closed type, soCachingQueryDecorator<QueryA, ...>andCachingQueryDecorator<QueryB, ...>would each get their own lock set and never serialize against each other on a shared key. Hoisting the field into this non-generic holder (the doc comment states exactly this rationale,CachingQueryDecorator.cs:225-229) gives every closed decorator one table. - Walkthrough:
Locks(CachingQueryDecorator.cs:249) isinternal static readonly KeyedSemaphoreStripe Locks = new(), the default-width stripe set:KeyedSemaphoreStripe.DefaultWidthis 256 (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:25) and every semaphore is allocated up front in the constructor asnew SemaphoreSlim(1, 1)(KeyedSemaphoreStripe.cs:43-46). Keys are bucketed, not distinguished: the stripe index folds a string hash onto a fixed width, so two unrelated cache keys can land on the same stripe and briefly serialize against each other. A caller takes a stripe withawait Locks.AcquireAsync(key, cancellationToken)(KeyedSemaphoreStripe.cs:60) and releases it by disposing the returned Releaser handle (KeyedSemaphoreStripe.cs:78). There is no add or remove lifecycle at all: nothing is inserted on a miss, nothing is evicted on release, and the stripes are never disposed because the holder lives for the process. - Why it's built this way: the remarks (
CachingQueryDecorator.cs:231-237) name the defect this shape avoids. One semaphore per key in aConcurrentDictionaryforces a choice between two bugs: removing the entry when the last holder releases opens a window in which one caller waits on a semaphore that is no longer in the table while a second caller creates a fresh one (both then run concurrently, defeating the lock), and never removing it lets a cache key that embeds a user id or a filter value grow the table without bound. A fixed-width stripe set has neither problem, and the collision it introduces is harmless for a double-check-locking caller, which re-reads its own key's cache entry after acquiring (CachingQueryDecorator.cs:99-104). It is the same primitive and the same double-check pattern that IdempotencyFilter falls back on; the caching strategy as a whole is ADR-026. - Where it's used: only by CachingQueryDecorator<TQuery, TResult>, which reaches the stripe through its private
TryAcquirePopulateLockAsynchelper (CachingQueryDecorator.cs:184,:189) rather than callingAcquireAsyncinline, because the wait is now optionally time-boxed. The key passed in is the tenant-scoped one, so tenants do not queue behind each other by accident (CachingQueryDecorator.cs:68-70). - Caveats / not-in-source: the lock is per-process (
CachingQueryDecorator.cs:238-244). Across several app instances sharing one distributed cache, stampede protection is best-effort: at most one handler execution per instance, not one cluster-wide. The remarks call that duplication harmless (equal content, last write wins) and state that a cluster-wide guarantee would need a distributed lock and is deliberately not attempted here.
LoggingCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:16· Level 3 · class (sealed, partial)
- What it is: the observability decorator on the command side. It opens a correlated logging scope carrying the command name, its owning module, and the correlation id, times the inner pipeline, logs start / completion / failure / exception, and records the duration into CqrsMetrics. In the standard pipeline it is the third-outermost command decorator, just inside FeatureGate and Authorization.
- Depends on: CqrsMetrics; ICorrelationContext (supplies
CorrelationId,LoggingCommandDecorator.cs:18,:24); ModuleNameConventions (MMCA.Common.Shared.Conventions,:4,:76); ICommandHandler<in TCommand, TResult>; Result (pattern-matched to detect failure);Microsoft.Extensions.LoggingandSystem.Diagnostics(BCL). - Concept introduced, structured logging plus metrics as a single cross-cutting stage.
[Rubric §13, Observability & Operability]assesses whether every command emits correlated, structured logs and a latency/outcome metric with no per-handler boilerplate;[Rubric §12, Performance & Scalability]assesses factoring that out of business logic. Every command that reaches it is logged and measured uniformly whether or not it is cached, validated, budgeted, or transactional, because this decorator sits above all of those in the chain. Its placement inside the feature gate is deliberate, so it only measures enabled-feature executions (DependencyInjection.cs:98).[Rubric §12, Performance & Scalability]also applies: the hot path uses the[LoggerMessage]source generator, a source-generated scope, a rawStopwatchtimestamp instead of aStopwatchinstance, and a per-closed-type static for the module name, so it avoids message interpolation, per-call dictionary boxing, one allocation per command, and a namespace parse per execution. - Walkthrough
- Names and correlation (
LoggingCommandDecorator.cs:24-25):typeof(TCommand).NameandcorrelationContext.CorrelationId, both read once. - Scope (
LoggingCommandDecorator.cs:27):using (BeginCommandScope(logger, commandName, ModuleName, correlationId))opens a structured scope so inner decorators and the handler shareCommandName,ModuleName, andCorrelationId.BeginCommandScopeis astatic readonly Func<ILogger, string, string, string, IDisposable?>built fromLoggerMessage.DefineScope<string, string, string>(LoggingCommandDecorator.cs:68-70), the allocation-light alternative to an anonymous-dictionaryBeginScope(the comment at:63-66says so). ModuleName(LoggingCommandDecorator.cs:77):ModuleNameConventions.GetModuleName(typeof(TCommand)) ?? "unknown", aprivate static readonly string. Because the field lives on a generic type it is computed once per closedTCommand(the comment at:71-75states exactly that), which is what makes a per-module log filter free at execution time. The"unknown"fallback keeps a command outside the module namespace convention loggable rather than throwing.- Start (
LoggingCommandDecorator.cs:29):LogCommandStartedatDebug(LoggingCommandDecorator.cs:87-88), deliberately notInformation. The inline comment (:84-85) gives the reason: the completion line already carries name and duration, and twoInformationrows per command doubles ingestion cost for no diagnostic gain. - Timing (
LoggingCommandDecorator.cs:34):Stopwatch.GetTimestamp(), withStopwatch.GetElapsedTime(startTimestamp)computed in each branch (:37,:55). The comment (:30-32) records both the motive (one fewer allocation than aStopwatchinstance, same resolution) and the invariant it preserves: elapsed is captured before logging, so the recorded duration stays the handler's. - Failure branch (
LoggingCommandDecorator.cs:40-45): onResult { IsFailure: true }it joinsErrorsinto a"{Code}: {Message}"summary, logsLogCommandFailedatWarning(:92-93), and records the duration with outcome"failed". A business failure is aWarning, not anError: it is an expected outcome, not a defect. - Success branch (
LoggingCommandDecorator.cs:46-50):LogCommandCompletedatInformation(:89-90) and outcome"completed". - Exception branch (
LoggingCommandDecorator.cs:54-60): recomputes elapsed, logsLogCommandExceptionatError(:95-96), records outcome"exception", then rethrows so no decorator swallows a fault. RecordDuration(LoggingCommandDecorator.cs:79-83): the one place that touchesCqrsMetrics.CommandDuration, taggingcommandandoutcome.
- Names and correlation (
- Why it's built this way:
partial classplus[LoggerMessage](LoggingCommandDecorator.cs:87-97) is the .NET-recommended high-performance structured-logging shape, and the three-valuedoutcometag lets one histogram separate success from domain failure from a genuine exception on a dashboard. Carrying the module in the scope rather than in each message is what lets a modular-monolith operator slice every log line a handler emitted by the module that owns it, which is the property that survives extracting that module into its own service (ADR-007). - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:135), fifth of seven command registrations, which under Scrutor's reverse-order decoration makes it the third-outermost wrapper on every command handler (DependencyInjection.cs:60-61,:126-128). - Caveats / not-in-source: the outcome is recorded per branch, not in a
finally. A cancellation that surfaces as an exception therefore lands in the"exception"bucket like any other throw; there is no separate"cancelled"outcome. Note also what this decorator does not see: a command short-circuited by the feature gate or by AuthorizationCommandDecorator<TCommand, TResult> never reaches it, so those rejections appear only in their own counters, not incqrs.command.duration.
LoggingQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingQueryDecorator.cs:15· Level 3 · class (sealed, partial)
- What it is: the query-side twin of LoggingCommandDecorator<TCommand, TResult>: correlated scope (query name, module, correlation id), timestamp-based timing, completion / failure / exception logging, and a CqrsMetrics duration recording.
- Depends on: CqrsMetrics; ICorrelationContext; ModuleNameConventions; IQueryHandler<in TQuery, TResult>; Result;
Microsoft.Extensions.Logging,System.Diagnostics. - Concept reinforced: see LoggingCommandDecorator<TCommand, TResult>.
[Rubric §13, Observability & Operability]and[Rubric §13, Observability & Operability]. - Walkthrough:
HandleAsync(LoggingQueryDecorator.cs:21) opensBeginQueryScope(logger, queryName, ModuleName, correlationId)(:25), a source-generatedLoggerMessage.DefineScope<string, string, string>(:65-67) whoseModuleNameis the same per-closed-type static the command side uses (:74, rationale at:69-73). It then times the inner handler from aStopwatch.GetTimestamp()(:30,:34). The branch structure matches the command side exactly:Result { IsFailure: true }produces an error summary,LogQueryFailedatWarning(:85-86) and outcome"failed"(:36-41); success logsLogQueryCompletedand outcome"completed"(:42-46); an exception logsLogQueryExceptionatError(:88-89), records"exception", and rethrows (:50-56).RecordDuration(:76-80) writesCqrsMetrics.QueryDurationtaggedqueryplusoutcome. Two differences from the command side are worth noting: query completion logs atDebug(LoggingQueryDecorator.cs:83), notInformation, since reads are far more frequent than writes, and there is no "started" log line at all, so a query emits one row per execution. - Why it's built this way: the scope comment (
LoggingQueryDecorator.cs:61-65) records the reason it matches the command decorator rather than using a dictionary: the previousBeginScope(new Dictionary<string, object>)allocated a dictionary and boxed the scope state on every query, at every log level, including when logging was disabled entirely. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:143) to wrap every query handler, immediately inside AuthorizationQueryDecorator<TQuery, TResult> and the feature gate.
ResultFailureFactory
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ResultFailureFactory.cs:11· Level 3 · class (static, internal)
- What it is: an
internal statichelper that builds a delegate turning anIEnumerable<Error>into aTResultfailure, working for both non-generic Result and genericResult<T>, without constraining the caller'sTResult. - Depends on: Result and Error (
MMCA.Common.Shared.Abstractions,ResultFailureFactory.cs:2); BCLSystem.Linq.Expressions(:1) andSystem.Reflection(used fully qualified at:31). - Concept introduced, short-circuiting an unconstrained generic pipeline.
[Rubric §15, Best Practices & Code Quality]assesses how cleanly the codebase solves an awkward generic problem. The decorators need to fail a handler call without running the inner handler, but theirTResultis an open type parameter that is eitherResultorResult<T>; you cannot writereturn Result.Failure<T>(...)when you do not knowT. ConstrainingTResult : Resultwould change the handler interface contract for every handler in both apps, so instead this factory isolates the reflection to one place and, for the generic branch, compiles it into a delegate so the per-call cost is a plain invocation rather thanMethodInfo.Invoke(the doc comment states this,ResultFailureFactory.cs:16-17). - Walkthrough
Build<TResult>()(ResultFailureFactory.cs:20): the single entry point, returningFunc<IEnumerable<Error>, TResult>.- Non-generic branch (
ResultFailureFactory.cs:22-25): whentypeof(TResult) == typeof(Result), returnserrors => (TResult)(object)Result.Failure(errors). The double cast throughobjectis what the compiler requires with an unconstrainedTResult. - Generic branch (
ResultFailureFactory.cs:27-41): whenTResultis a closedResult<>, it takes the inner type (:29), reflects the public staticResult.Failureoverload that is a generic method definition with a singleIEnumerable<Error>parameter (:30-35), closes it over the inner type withMakeGenericMethod(:36), then builds and compilesExpression.Lambda<Func<IEnumerable<Error>, TResult>>(Expression.Call(failureMethod, errorsParam), errorsParam)(:38-40). The overload filter is specific on purpose: matching on name alone would be ambiguous acrossResult.Failure's overload set. - Guard (
ResultFailureFactory.cs:43-45): any otherTResultthrowsInvalidOperationExceptionnaming the unsupported type and the two it does support.
- Why it's built this way: compiling the expression once per closed type keeps the short-circuit path reflection-free after the first build, and centralizing the one piece of generic reflection keeps the decorators that need it readable.
- Where it's used: called lazily through a
CreateFailure()helper by eight decorators, all with the same two-line shape: FeatureGateCommandDecorator<TCommand, TResult> (FeatureGateCommandDecorator.cs:45), FeatureGateQueryDecorator<TQuery, TResult> (FeatureGateQueryDecorator.cs:45), ValidatingCommandDecorator<TCommand, TResult> (ValidatingCommandDecorator.cs:60), ValidatingQueryDecorator<TQuery, TResult> (ValidatingQueryDecorator.cs:64), TimeoutCommandDecorator<TCommand, TResult> (TimeoutCommandDecorator.cs:60) and its query twin (TimeoutQueryDecorator.cs:60), and AuthorizationCommandDecorator<TCommand, TResult> (AuthorizationCommandDecorator.cs:55) and its query twin (AuthorizationQueryDecorator.cs:50). - Caveats / not-in-source: the guard throw is the reason all eight callers build lazily rather than in a static initializer. A handler whose
TResultis neitherResultnorResult<T>is legal against ICommandHandler<in TCommand, TResult>, and an eager build would turn that into aTypeInitializationExceptionat DI resolve time (FeatureGateCommandDecorator.cs:29-37), because Scrutor'sTryDecoratewraps unconditionally.
CachingCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:33· Level 4 · class (sealed, partial)
- What it is: the caching decorator on the command side. It caches nothing; it invalidates cached read data after a successful mutation, and only when the command opts in via ICacheInvalidating with a non-blank prefix. It evicts twice: once immediately, once after a short delay.
- Depends on: ICacheInvalidating (the opt-in marker exposing
CachePrefix); ICacheService (the eviction mechanism); ITenantContext (optional, defaulted tonull) and TenantCacheKey (prefix scoping); ICommandHandler<in TCommand, TResult>; Result (pattern-matched to detect failure);Microsoft.Extensions.Logging. - Concept introduced, invalidate on success only, and best-effort at that.
[Rubric §12, Performance & Scalability]assesses whether write-side invalidation keeps read caches coherent without over-evicting;[Rubric §29, Resilience & Business Continuity]assesses what a dependency outage does to a request. Three invariants are stated in the class doc and enforced in code. (1) Eviction fires only when the command implements ICacheInvalidating and returned a non-failure result (CachingCommandDecorator.cs:13-16): a business failure rolls the transaction back (see TransactionalCommandDecorator<TCommand, TResult>), so evicting valid entries on failure would cost concurrent readers a needless miss for nothing. (2) Invalidation is non-cancellable and never propagates a fault (CachingCommandDecorator.cs:17-23): the command has already committed by the time this runs, so a cache outage must not turn a committed command into a failure. (3) Under multi-tenancy the prefix is scoped with the same transformation the query decorator applies to its keys (CachingCommandDecorator.cs:24-29), which is the property that makes eviction hit exactly the entries this tenant's queries wrote. - Walkthrough
- Primary constructor (
CachingCommandDecorator.cs:33-37): inner handler, ICacheService, a typed logger, andITenantContext? tenantContext = null. The optional tenant parameter is what keeps the type resolvable in a host that never registered tenancy. ReInvalidationDelay(CachingCommandDecorator.cs:45): aninternal TimeSpanproperty, defaultTimeSpan.FromSeconds(5), settable so a test does not have to wait out the production delay (:37-42).InvalidationFollowUp(CachingCommandDecorator.cs:51): aninternal Taskwith a private setter, initialized toTask.CompletedTask, holding the most recent delayed eviction. It is exposed so the fire-and-forget task is observed rather than dropped, and so a test can await it deterministically (:45-48).HandleAsync(CachingCommandDecorator.cs:54): awaitsinner.HandleAsync(...)first (:54), so the mutation always runs before any cache decision.- Guard (
CachingCommandDecorator.cs:61-63): three conditions,command is ICacheInvalidating cacheInvalidating,!string.IsNullOrWhiteSpace(cacheInvalidating.CachePrefix), and!IsFailure(result). The blank-prefix test is load-bearing and the comment says why (:56-58):RemoveByPrefixAsync("")would evict the entire cache, so an empty prefix is both an opt-out and a foot-gun guard. - Scoping (
CachingCommandDecorator.cs:67):TenantCacheKey.Scope(tenantContext, cacheInvalidating.CachePrefix). - First eviction (
CachingCommandDecorator.cs:73-74):RemoveByPrefixAsync(cachePrefix, CancellationToken.None). PassingNonerather than the request token is deliberate (:69-70): the cleanup must outlive a caller that has already walked away. - Delayed re-eviction (
CachingCommandDecorator.cs:81, implemented at:96-109):ReInvalidateAfterDelayAsyncwaitsReInvalidationDelay(:100) then evicts the same prefix again (:101). The comment (:74-78) names the race it closes: a read that missed the cache before this command committed can still be running its handler against pre-write state and populate the entry after the first eviction, so a single eviction can leave a stale entry behind. - Fault handling (
CachingCommandDecorator.cs:83-88and:103-108): both evictions catchException(with an explicitCA1031suppression and a justification comment) and logLogCacheInvalidationFailedatWarning(:111-118), whose message ends with the operational consequence: stale entries expire on their own TTL. IsFailure(CachingCommandDecorator.cs:126-127):result is Shared.Abstractions.Result { IsFailure: true }, pattern matching becauseTResultis not constrained to aResulttype, so one test covers bothResultandResult<T>.
- Primary constructor (
- Why it's built this way: the command names what to evict (its
CachePrefix); the decorator plus ICacheService own how and when. Handlers stay free of cache-infrastructure knowledge, and the pipeline position (outside validation and outside the transaction, per ADR-014 and argued atDependencyInjection.cs:104-105) is what makes "after a committed mutation" true rather than aspirational. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:134) around every command handler; it engages only for commands whoseTCommandimplements ICacheInvalidating, which is broadly adopted across ADC and Store write commands. - Caveats / not-in-source: the delayed re-eviction narrows the repopulate window but does not close it. A read whose handler runs longer than
ReInvalidationDelay(5 seconds) can still repopulate a stale entry after the second eviction; the code's own answer to that residue is the entry's TTL (CachingCommandDecorator.cs:21-22). Note also thatInvalidationFollowUpis only ever assigned inside thetry(:79), so nothing in production code awaits the delayed eviction: it is observed by tests, and a host shutting down mid-delay simply loses it.
CachingQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:43· Level 4 · class (sealed, partial)
- What it is: the read-caching decorator. On a cache hit it returns the stored result without touching the inner handler; on a miss it runs the handler once (under a per-key lock, with an optional bound on how long a waiter waits for that lock) and caches the non-failure result. It engages only for queries that opt in via IQueryCacheable, and it never lets a cache fault fail a query.
- Depends on: IQueryCacheable (exposes
CacheKeyplusCacheDuration); ICacheService; IQueryHandler<in TQuery, TResult>; QueryCacheKeyLocks (the shared stripe table) and KeyedSemaphoreStripe; TenantCacheKey and ITenantContext (optional); QueryCachePipelineSettings viaIOptions<T>(optional); CqrsMetrics; Result;Microsoft.Extensions.LoggingandMicrosoft.Extensions.Options. - Concept introduced, stampede protection by per-key double-check locking, and a fail-open cache.
[Rubric §12, Performance & Scalability]assesses read-side caching and, critically, what happens when a hot key expires under load: a naive cache lets every concurrent request miss and all run the expensive query at once.[Rubric §29, Resilience & Business Continuity]assesses degradation under a dependency outage. The class doc states the fail-open principle plainly (CachingQueryDecorator.cs:18-26): the cache is an optimization, never the system of record, so a cache outage must degrade the application to uncached reads rather than turn every cacheable query into a 500. Both the read and the populate log and swallow the fault, andOperationCanceledExceptionis deliberately excluded from each guard so a genuinely cancelled request still surfaces exactly as the inner handler would. The doc extends the same principle to the lock itself (:32-37): the wait is bounded byCache:PopulateLockTimeout, and a waiter that gives up runs the query rather than failing. - Walkthrough
- Constructor (
CachingQueryDecorator.cs:43-48): the inner handler,ICacheService, a typed logger,ITenantContext? tenantContext = null, andIOptions<QueryCachePipelineSettings>? pipelineSettings = null. Both optional parameters default tonull, which is what keeps the decorator resolvable in a host that registered neither tenancy nor the cache options. EffectiveKey(CachingQueryDecorator.cs:57-58):TenantCacheKey.Scope(tenantContext, cacheable.CacheKey). With no tenant resolved the key is byte-identical to the query's own (:48-52).- Opt-out (
CachingQueryDecorator.cs:63-64):query is not IQueryCacheableshort-circuits straight to the inner handler, so non-cacheable queries pay one type test. - One key for three operations (
CachingQueryDecorator.cs:68-70): the comment states the invariant, read, stampede lock, and populate must all use the same tenant-scoped key, or one tenant would wait on another's lock and read another's entry. - Fast path (
CachingQueryDecorator.cs:72-78): a lock-freeTryReadAsync; a non-null hit recordsCqrsMetrics.RecordCacheHit(queryName)(:74) and returns with no lock taken. - Lock budget (
CachingQueryDecorator.cs:83-84):pipelineSettings?.Value.PopulateLockTimeout ?? QueryCachePipelineSettings.DefaultPopulateLockTimeout. The default isTimeout.InfiniteTimeSpan(MMCA.Common/Source/Core/MMCA.Common.Application/Settings/QueryCachePipelineSettings.cs:29), so out of the box the wait is unbounded and exactly one request per key populates the entry. - Acquire or degrade (
CachingQueryDecorator.cs:86-95):TryAcquirePopulateLockAsyncreturnsnullwhen the budget elapsed first. That branch is fail-open by design (:87-89): it logsLogPopulateLockTimedOutatDebug(:153-160), records a miss (:91), runs the inner handler, and deliberately does not cache the result, because the request holding the lock is the one that populates the entry. - The lock itself (
CachingQueryDecorator.cs:178-197): with a non-positive timeout it awaitsQueryCacheKeyLocks.Locks.AcquireAsync(cacheKey, cancellationToken)and allocates nothing (:181-182); otherwise it arms a linked source (:184-185) and waits onbudget.Token(:189). The catch filterwhen (budget.IsCancellationRequested && !cancellationToken.IsCancellationRequested)(:191) is what separates an elapsed budget (returnnull, degrade) from a genuinely cancelled request (rethrow), the same split TimeoutQueryDecorator<TQuery, TResult> makes and the remarks name (:165-171). - Double-check (
CachingQueryDecorator.cs:97-104): insideusing (stripe)it re-reads; a waiter that arrived while the leader populated records a hit (:100) and returns the fresh entry without re-running the query. - Miss accounting (
CachingQueryDecorator.cs:112): the comment (:104-109) explains why the miss is counted here rather than at either read: a request that misses the fast path, takes the lock and misses the double-check has read the cache twice but executed once, so counting at the reads would double-count it. A read that failed also lands here and counts as a miss, which is correct because the query went uncached either way. - Populate (
CachingQueryDecorator.cs:114-133): the leader runsinner.HandleAsync(...)(:112), then stores viaSetAsync(cacheKey, result, cacheable.CacheDuration, cancellationToken)(:119-120) only when the result is notResult { IsFailure: true }(:115), so failures are never cached. TheSetAsyncis wrapped intry/catch (Exception ex) when (ex is not OperationCanceledException)(:122) and a failure logsLogCachePopulateFailedatWarning(:127, message at:135-142) while still returning the handler's answer. TryReadAsync(CachingQueryDecorator.cs:207-222): the fail-open read. A cache fault logsLogCacheReadFailedatWarning(:217, message at:144-151) and returnsdefault, which the caller treats as a miss; cancellation is again excluded from the catch filter (:215).
- Constructor (
- Why it's built this way: per-query
CacheKeyandCacheDurationowned by the query, plus stampede-safe locking and fail-open cache calls, gives a correct application-layer read cache that cannot become a new single point of failure. Keeping the stripe table in the non-generic QueryCacheKeyLocks is what lets all closed decorators share one lock per key. The lock timeout lives in QueryCachePipelineSettings rather than in Infrastructure's own cache settings for a layering reason the settings doc states (QueryCachePipelineSettings.cs:3-7,:9-14): this decorator is an Application-layer type and Application cannot reference Infrastructure, so the Application layer binds its own view of the sameCacheconfiguration section and the two cannot drift. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:142), directly outside ValidatingQueryDecorator<TQuery, TResult> and therefore third-innermost on the read side. That position is what makes a cache hit skip validation and the budget entirely (DependencyInjection.cs:99-109). In the two production apps it engages for exactly one query: ADC'sGetNowNextQuery(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23), described there as a hot, public, non-user-specific read (:14). A search ofMMCA.Store/Sourcefinds no IQueryCacheable implementor at all, so for Store this decorator is a pass-through today; the framework's ownExportUserDataHandlerBaseand the sample apps (HelpdeskGetTicketByIdQuery, ECommerceGetProductByIdQueryandGetOrderByIdQuery) are the other adopters. Production read caching is otherwise done at the HTTP edge with output caching (the two-tier model of ADR-026). - Caveats / not-in-source: the same file also declares the QueryCacheKeyLocks holder below the class (
CachingQueryDecorator.cs:246), documented as its own type in this group. The stampede lock protects one process only; see the caveat under QueryCacheKeyLocks. And note the accounting consequence of the timeout branch: a query whose waiters give up records one miss per waiter (:91) plus one for the leader (:110), so under a configured finitePopulateLockTimeoutthe hit ratio understates how well the cache is working.
FeatureGateCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/FeatureGateCommandDecorator.cs:20· Level 4 · class (sealed)
- What it is: the outermost standard command decorator. When a command implements IFeatureGated and its feature flag is off, it short-circuits with a
NotFoundfailure before any authorization, logging, caching, validation, budget, or transaction work happens. - Depends on: IFeatureGated (the opt-in marker exposing
FeatureName); Error and ErrorType; ResultFailureFactory; ICommandHandler<in TCommand, TResult>;Microsoft.FeatureManagement.IFeatureManager(NuGet,FeatureGateCommandDecorator.cs:1,:20). - Concept introduced, feature-flag gating as a pipeline stage.
[Rubric §12, Performance & Scalability]assesses centralized feature-flag enforcement rather than a flag check duplicated in every handler. Registered last among the command decorators (DependencyInjection.cs:137) and therefore applied outermost, a disabled feature is rejected first, so no downstream work happens (the class doc says exactly this,FeatureGateCommandDecorator.cs:13-16). Commands that do not implement IFeatureGated pass through on a single type test. - Walkthrough
_createFailure(FeatureGateCommandDecorator.cs:38): a nullablestatic Func<IEnumerable<Error>, TResult>?, one per closed generic type.CreateFailure()(FeatureGateCommandDecorator.cs:44-45):_createFailure ??= ResultFailureFactory.Build<TResult>(), built on the first short-circuit, not eagerly. The remarks (:27-35) record the bug that forced this: an eager static initializer turned an unsupportedTResultinto aTypeInitializationExceptionat resolve time (Scrutor'sTryDecorateis unconditional) for a handler that never short-circuits at all. A benign duplicate build under a race produces an equivalent delegate, so no lock is needed, and the happy path never touches the field. Keeping the helperstatic(:38-41) also avoids a write to a static field from an instance member.HandleAsync(FeatureGateCommandDecorator.cs:48):command is not IFeatureGatedpasses straight through (:48-49); otherwiseawait featureManager.IsEnabledAsync(featureGated.FeatureName)(:51, async so a remote or config-backed flag store works). Enabled runs the inner handler (:52); disabled returnscreateFailure([Error.NotFoundError("Feature.Disabled", ...)])(:54-57).
- Why it's built this way: putting the gate in a decorator means handlers never inject
IFeatureManager; a command opts in simply by implementing IFeatureGated. ReturningNotFoundrather thanForbiddentells the client the operation "does not currently exist" instead of leaking that it exists but is withheld, which is also why the gate deliberately sits outside AuthorizationCommandDecorator<TCommand, TResult>: a feature that is off must answer the same way for every caller rather than leaking which permission guards it (DependencyInjection.cs:91-94). - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:137) as the outermost decorator on every command handler; it engages only for commands implementing IFeatureGated.
FeatureGateQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/FeatureGateQueryDecorator.cs:20· Level 4 · class (sealed)
- What it is: the query-side twin of FeatureGateCommandDecorator<TCommand, TResult>: the outermost standard query decorator, rejecting a gated query with a
NotFoundfailure when its feature flag is off. - Depends on: IFeatureGated; Error and ErrorType; ResultFailureFactory; IQueryHandler<in TQuery, TResult>;
Microsoft.FeatureManagement(FeatureGateQueryDecorator.cs:1,:20). - Concept reinforced: an identical pattern to FeatureGateCommandDecorator<TCommand, TResult>, including the same lazy-build remarks (
FeatureGateQueryDecorator.cs:29-37).[Rubric §12, Performance & Scalability]. The class doc (FeatureGateQueryDecorator.cs:13-16) says "before logging or caching work", a shorter list than the command side's because the two files were written against their own chains, even though the query chain does now also carry a validation stage. - Walkthrough:
_createFailure(FeatureGateQueryDecorator.cs:38) plusCreateFailure()(:42-43) build the failure delegate on first short-circuit.HandleAsync(:46) type-tests IFeatureGated (:48-49), callsIsEnabledAsync(:51), and returns the sameError.NotFoundError("Feature.Disabled", ...)failure when disabled (:54-57). - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:145) as the outermost decorator on every query handler.
TimeoutCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TimeoutCommandDecorator.cs:35· Level 4 · class (sealed)
- What it is: the decorator that enforces a command's own execution budget. When the command implements IHasTimeout with a positive
Timeout, it runs the inner handler under a linked cancellation source that self-cancels after the budget, and converts the resulting cancellation into a failure result codedRequest.TimedOutrather than letting it escape as an exception. - Depends on: IHasTimeout (the opt-in marker); ResultFailureFactory (the short-circuit delegate); Error and ErrorType; CqrsMetrics (
RecordTimeout); ICommandHandler<in TCommand, TResult>; BCLCancellationTokenSourceandSystem.Globalization(TimeoutCommandDecorator.cs:1). - Concept introduced, converting a cancellation into a value on the Result channel.
[Rubric §29, Resilience, Reliability & Business Continuity]assesses bounded failure;[Rubric §9, API & Contract Design]assesses how an outcome is classified for callers. Two decisions in this file are worth reading closely. First, the error taxonomy compromise (TimeoutCommandDecorator.cs:14-19): the framework's ErrorType maps to HTTP status codes and has no member corresponding to 408 or 504, so an expired budget is reported as the generalFailureclassification and the machine-readable codeRequest.TimedOut, not the type, is what callers branch on. Second, whose cancellation it is matters (:23-29): a cancellation raised by the caller's token is rethrown unchanged, so a genuinely aborted request surfaces exactly as the inner handler would; only the decorator's own budget becomes a failure result. - Walkthrough
_createFailure(TimeoutCommandDecorator.cs:53) andCreateFailure()(:57-58): the same lazily-built ResultFailureFactory delegate as the feature gate, with the same remarks about the eager-initializer bug it avoids (:41-50).- Opt-out guard (
TimeoutCommandDecorator.cs:65-66):command is not IHasTimeout hasTimeout || hasTimeout.Timeout <= TimeSpan.Zeropasses straight through with the caller's token untouched. The non-positive branch is the misconfiguration guard the class doc documents (:23-25): a bad value must not fail every request instantly. - Budget (
TimeoutCommandDecorator.cs:68-69):using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)thenbudget.CancelAfter(hasTimeout.Timeout). Linking is what preserves caller cancellation;usingis what disposes the timer. - Execution (
TimeoutCommandDecorator.cs:73): the inner handler is invoked withbudget.Token, not the caller's, so everything downstream (the transaction and the handler's database work) observes the budget. - Catch filter (
TimeoutCommandDecorator.cs:75):catch (OperationCanceledException) when (budget.IsCancellationRequested && !cancellationToken.IsCancellationRequested). Both halves are load-bearing: the first confirms the budget fired, the second confirms the caller did not cancel, so caller cancellation falls through the filter and propagates. - Failure (
TimeoutCommandDecorator.cs:77-86): recordsCqrsMetrics.RecordTimeout(commandName)(:76) then returnsError.Failure("Request.TimedOut", ...)with the budget rendered invariantly to three decimal places of seconds (:79-84) andsourceset to the command type name.
- Why it's built this way: the placement is argued in the registration doc (
DependencyInjection.cs:106-109) and in ADR-014's 2026-08-18 revision: the budget sits inside validation and outside the transaction, so it covers the database work that actually hangs, does not charge the caller for validation, and cancels the transaction instead of leaving it open. Returning a value rather than throwing keeps a timeout on the same error channel as every other expected outcome, so an endpoint maps it without acatch. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:132) as the second-innermost command decorator, directly outside TransactionalCommandDecorator<TCommand, TResult>. - Caveats / not-in-source: no production command implements IHasTimeout today (see that type's caveat), so in every deployed host this decorator is a one-type-test pass-through. Note also what the budget cannot do: cancellation is cooperative, so a handler that ignores its token (a blocking call, a provider that does not honour cancellation) runs to completion regardless, and the decorator only converts the cancellation it observes on the way out.
TimeoutQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TimeoutQueryDecorator.cs:35· Level 4 · class (sealed)
- What it is: the query-side twin of TimeoutCommandDecorator<TCommand, TResult>, enforcing a per-query execution budget declared by IHasTimeout.
- Depends on: IHasTimeout; ResultFailureFactory; Error and ErrorType; CqrsMetrics; IQueryHandler<in TQuery, TResult>.
- Concept reinforced: see TimeoutCommandDecorator<TCommand, TResult> for the taxonomy compromise and the caller-cancellation rule; the two files are line-for-line equivalents apart from the type names and the message wording.
[Rubric §29, Resilience, Reliability & Business Continuity]and[Rubric §12, Performance & Scalability]. The one substantive difference is position: this is the innermost query decorator (DependencyInjection.cs:140), so a cache hit is served by CachingQueryDecorator<TQuery, TResult> before the budget is even started, and a timed-out execution returns a failure that the caching decorator then refuses to cache (TimeoutQueryDecorator.cs:20-24). - Walkthrough:
_createFailure(TimeoutQueryDecorator.cs:53) andCreateFailure()(:57-58) match the command side.HandleAsync(:61) passes through onquery is not IHasTimeout hasTimeout || hasTimeout.Timeout <= TimeSpan.Zero(:63-64), otherwise links and arms a budget (:66-67), invokes the inner handler withbudget.Token(:71), and converts only its own expiry through the same two-part catch filter (:73). On expiry it recordsCqrsMetrics.RecordTimeout(queryName)(:76) and returnsError.Failure("Request.TimedOut", ...)naming the query and its budget in seconds (:79-84). - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:140) as the innermost query decorator, wrapping the concrete handler directly. Its budget-versus-caller-cancellation split is also reused verbatim inside CachingQueryDecorator<TQuery, TResult>'s populate-lock wait, which cites this type as the precedent (CachingQueryDecorator.cs:168-171). - Caveats / not-in-source: as with the command twin, no production query implements IHasTimeout today, so this is a pass-through in every deployed host.
ValidatingCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:32· Level 4 · class (sealed, partial)
- What it is: the decorator that runs FluentValidation against a command before the handler executes, turning validation failures into a Result failure so the handler is never called with invalid input. It runs every registered validator for the command, not just one, and unions their errors. It sits between Caching (outer) and Timeout (inner).
- Depends on:
FluentValidation.IValidator<TCommand>(NuGet, injected asIEnumerable<IValidator<TCommand>>from DI,ValidatingCommandDecorator.cs:34); Error; ResultFailureFactory; ICommandHandler<in TCommand, TResult>; theToErrorsextension fromMMCA.Common.Application.Extensions(:3,:82, declared atMMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:19);Microsoft.Extensions.Logging. - Concept introduced, automatic validation as a pipeline stage.
[Rubric §24, Forms, Validation & UX Safety]assesses whether server-side validation is applied consistently rather than hand-called per handler;[Rubric §1, SOLID](SRP) assesses that validation is this decorator's single job and not the handler's. Placing it before the budget and the transaction (stated in the class doc,ValidatingCommandDecorator.cs:24-28, and argued atDependencyInjection.cs:99-100and:104-107) means an invalid command never opens a database transaction and never consumes execution budget. Commands with no registered validator pass straight through, so validation is present-when-registered rather than a hard requirement. - Walkthrough
_validators(ValidatingCommandDecorator.cs:37):[.. validators], the injected enumerable materialized once into an array at construction. Injecting the enumerable rather than the validator itself is what lets a command have none without a DI resolution failure; materializing it once means the DI enumerable is not re-enumerated per execution._createFailureandCreateFailure()(ValidatingCommandDecorator.cs:53,:58-59): the same lazily-built ResultFailureFactory delegate, with the same remarks about the eager-initializer bug it avoids (:43-51).- Fast path (
ValidatingCommandDecorator.cs:65-68):_validators.Length == 0passes straight through to the inner handler. - The loop (
ValidatingCommandDecorator.cs:73-84): aList<Error>? errorsstartsnulland is allocated only on the first failure (:81), so a valid command allocates nothing. Each validator is awaited in turn (:75); a valid resultcontinues (:76-79); a failing one appendsvalidationResult.ToErrors(typeof(TCommand).Name)(:82). The loop is sequential on purpose and the comment says why (:69-71): a validator is free to reach the database through a scoped repository and aDbContextis not thread-safe, so running the set concurrently would trade a correctness guarantee for a saving measured in microseconds. - Outcome (
ValidatingCommandDecorator.cs:86-94):errors is nullruns the inner handler; otherwise it logs and returnscreateFailure(errors), so the caller sees every broken rule from every validator in one response. - Logging (
ValidatingCommandDecorator.cs:97-106):partialplus[LoggerMessage]generates the low-allocationLogValidationFailure(logger, commandName, errorCount)atDebug, with a small private instance overload (:104-105) that supplies the logger and the command name so the call site stays one argument.
- Why it's built this way: it removes the "inject
IValidator<T>and callValidateAsyncby hand" boilerplate from every handler; short-circuiting before the budget and transaction stages spares the database work on invalid input; and failing to aResultrather than throwing keeps validation on the same error channel as domain rules. Running every registered validator instead of the first is the 2026-08-31 correction in ADR-014, and the class doc gives the argument (ValidatingCommandDecorator.cs:18-23): a command commonly carries a module-authored validator beside a framework or cross-cutting one, and honoring only the first registration turns the others into dead code whose rules are silently unenforced. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:133). Every command with a matchingIValidator<TCommand>in DI is validated here. Two registration paths feed it:AddValidatorsFromAssembly(moduleAssembly)picks up every hand-written validator in a module (DependencyInjection.cs:252), and the auto-wired CommandRequestValidator<TCommand, TRequest> is added for each command implementing ICommandWithRequest<out TRequest> (DependencyInjection.cs:255-270). - Caveats / not-in-source: the auto-wiring uses
TryAddTransient(DependencyInjection.cs:269), so it is skipped entirely when anyIValidator<TCommand>is already registered. That is a registration-time interaction, not a runtime one: a module that hand-writes a validator for a request-carrying command gets only the hand-written one, even though the decorator itself would happily run both. There is also no ordering guarantee across validators beyond DI registration order, so error order in the response is a function of registration.
ValidatingQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingQueryDecorator.cs:35· Level 4 · class (sealed, partial)
- What it is: the query-side twin of ValidatingCommandDecorator<TCommand, TResult>. It runs every registered
IValidator<TQuery>before the handler and returns the union of their failures as a Result failure, so a query carrying paging, filter, or sort input rejects a malformed request instead of pushing bad values into the data source (ValidatingQueryDecorator.cs:14-20). - Depends on:
FluentValidation.IValidator<TQuery>(NuGet, injected asIEnumerable<IValidator<TQuery>>,ValidatingQueryDecorator.cs:37); Error; ResultFailureFactory; IQueryHandler<in TQuery, TResult>; theToErrorsextension (:3,:85);Microsoft.Extensions.Logging. - Concept reinforced, and one placement argument that is specific to the read side. The mechanism is ValidatingCommandDecorator<TCommand, TResult>'s, member for member.
[Rubric §24, Forms, Validation & UX Safety]and[Rubric §1, SOLID]. What is new here is where it sits: registered between CachingQueryDecorator<TQuery, TResult> and TimeoutQueryDecorator<TQuery, TResult> (DependencyInjection.cs:141), so the read chain is FeatureGate, Authorization, Logging, Caching, Validating, Timeout, handler (DependencyInjection.cs:79-85). Validation sits inside caching deliberately (ValidatingQueryDecorator.cs:26-28, restated atDependencyInjection.cs:101-103): a cached entry can only exist because the same query already passed validation when the entry was first produced, so re-validating on a cache hit spends work to reach a conclusion already reached. It sits outside the timeout for the mirror of the command-side reason (:27-29): the caller is not charged a slice of its own execution budget for validating its own bad input. - Walkthrough
_validators(ValidatingQueryDecorator.cs:40):[.. validators], materialized once at construction, exactly as on the command side._createFailureandCreateFailure()(ValidatingQueryDecorator.cs:57,:62-63): the same lazily-built ResultFailureFactory delegate, with the remarks naming the command decorator as the precedent (:46-55).- Fast path (
ValidatingQueryDecorator.cs:69-72):_validators.Length == 0goes straight to the inner handler, which is the path every production query takes today. - The loop (
ValidatingQueryDecorator.cs:76-87): a lazily allocatedList<Error>? errors, each validator awaited in turn (:78), valid results skipped (:79-82), failures appended viaToErrors(typeof(TQuery).Name)(:85). Sequential for the sameDbContextthread-safety reason as the command side, and the comment says so (:73-74). - Outcome (
ValidatingQueryDecorator.cs:89-97):errors is nullruns the handler; otherwise it logsLogValidationFailureatDebug(:99-105, instance overload at:107-108) and returnscreateFailure(errors).
- Why it's built this way: validation was command-only until the 2026-08-26 revision of ADR-014, which added this decorator so a query with structurally invalid input is rejected on the same Result channel and by the same mechanism as a command, rather than each query handler hand-guarding its own inputs.
[Rubric §15, Best Practices & Code Quality]: the symmetry means a reader who has understood one chain has understood both, and the two files diverge only in the placement paragraph. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:141) around every query handler.AddValidatorsFromAssembly(moduleAssembly)(DependencyInjection.cs:252) is what would feed it, since there is no query-side equivalent of the CommandRequestValidator<TCommand, TRequest> auto-wiring. - Caveats / not-in-source: no query validator exists anywhere in the workspace today. A repo-wide search for a FluentValidation validator closed over a query type finds only the framework's own unit tests (
MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/ValidatingQueryDecoratorTests.cs), so in every deployed host this decorator constructs an empty_validatorsarray and is a length-check pass-through. It is a live extension point with full test coverage and zero production adopters, which is worth knowing before assuming a query's inputs are checked for you.
TransactionalCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:20· Level 8 · class (sealed)
- What it is: the innermost standard command decorator, the one closest to the concrete handler. It wraps the handler in a database transaction only when the command opts in via ITransactional; every other command passes straight through.
- Depends on: ITransactional (the opt-in marker); IUnitOfWork (supplies
ExecuteInTransactionAsync,TransactionalCommandDecorator.cs:1,:20); ICommandHandler<in TCommand, TResult>. No BCL or NuGet dependency beyondTaskandCancellationToken. - Concept introduced, declarative transaction boundaries via a marker.
[Rubric §2, Design Patterns]assesses whether patterns are chosen deliberately rather than decoratively; this is the Decorator pattern in its plainest form, a type that is anICommandHandlerand holds an innerICommandHandler, so the pipeline composes without any handler knowing it exists.[Rubric §12, Performance & Scalability]assesses whether concerns like transactions are handled once, centrally: no handler in either app callsBeginTransaction.[Rubric §8, Data Architecture]assesses whether transaction boundaries are deliberate: here the boundary is declared on the command type, a domain-adjacent artifact reviewable in a pull request, rather than buried in handler code. The class itself is tiny; nearly all of the behavior lives one layer down in DbContextFactory, reached through UnitOfWork. - Walkthrough
- Primary constructor (
TransactionalCommandDecorator.cs:20-22): takes the innerICommandHandler<TCommand, TResult>and an IUnitOfWork. There is no state and no_fieldcapture; both parameters are used directly in the single method. HandleAsync(TransactionalCommandDecorator.cs:25), opt-out path (:26-27):if (command is not ITransactional) return await inner.HandleAsync(command, cancellationToken). A command that has not opted in pays exactly one type test and never touches the unit of work, which is why the decorator can be registered unconditionally on every command handler in the host.- Transactional path (
TransactionalCommandDecorator.cs:31-33):unitOfWork.ExecuteInTransactionAsync(ct => inner.HandleAsync(command, ct), cancellationToken). The handler call is passed as a delegate rather than awaited inline, which is what lets the layer below own begin, commit, rollback, and (crucially) re-run the delegate under an EF Core execution strategy. Note that the lambda forwards the strategy's ownct, not the captured outer token.
- Primary constructor (
- Why it's built this way: opt-in via a marker means only handlers that mutate multiple aggregates, or save twice against one database, pay for a transaction; everything else keeps the single-
SaveChangesAsyncatomicity EF Core already gives. Because transactions are per physical data source and there is no two-phase commit, cross-source consistency is the outbox's job rather than this decorator's (ADR-006). The composition itself, a Scrutor decorator chain over thin handlers with a load-bearing registration order, is ADR-014. - Where it's used: registered by
AddApplicationDecorators()atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131, the first of the seven command registrations (:129-135). Scrutor'sTryDecorateapplies decorators in reverse registration order (DependencyInjection.cs:60-61), so registered-first means applied-innermost: the transaction opens closest to the handler and inside the timeout budget and cache invalidation. That is what makes "invalidate only after a committed mutation" true (DependencyInjection.cs:104-105) and what lets an expired budget cancel the transaction rather than leave it open (:104-107). - Adoption, verified against source: opt-in is deliberately narrow. Seven production commands implement the marker today:
SendPushNotificationCommandin Common itself (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationCommand.cs:23); four in ADC, LinkUserToSpeakerCommand (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13), UnlinkUserFromSpeakerCommand (.../Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerCommand.cs:12), RefreshFromSessionizeCommand (.../Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13), and BatchAddSessionQuestionAnswersCommand (.../Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersCommand.cs:24); and two in Store,UploadProductImageCommand(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/UseCases/UploadImage/UploadProductImageCommand.cs:27) andReorderProductImagesCommand(.../Products/UseCases/ReorderImages/ReorderProductImagesCommand.cs:22). Four Store commands carry a doc comment stating they are deliberately not transactional because each performs a single save:CheckOutCommand.cs:9,VerifyPaymentCommand.cs:11,ProcessPaymentWebhookCommand.cs:9, andBulkSetInventoryCommand.cs:10, the last of which is asserted in a test (MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.Application.Tests/Inventory/BulkSetInventoryTests.cs:264). The full inventory and the reasoning per command live under ITransactional. - Caveats / not-in-source: four behaviors a reader will attribute to this file actually live in DbContextFactory, and the class doc here (
TransactionalCommandDecorator.cs:11-16, which mentions only exceptions) is narrower than what the code below does. (1) A returned failed Result rolls the transaction back exactly like an exception, atomicity over partial persistence (DbContextFactory.cs:462-466,:563-570). (2) The call is re-entrant: a nestedExecuteInTransactionAsyncjoins the ambient transaction instead of opening a second one, so anITransactionalcommand whose handler also opens a transaction no longer throws from EF (DbContextFactory.cs:457-461,:503-511). (3) The whole delegate runs under an EF execution strategy and may be re-executed on a transient failure, with the change tracker reset between attempts so a retry does not insert duplicates (DbContextFactory.cs:467-479,:526-534). (4) A failure of the commit itself is never retried and surfaces as TransactionCommitAmbiguousException (DbContextFactory.cs:480-487,:536-541). In-process domain event dispatch is deferred until after a successful commit and dropped on rollback (DbContextFactory.cs:577-584), via DomainEventSaveChangesInterceptor.
AuthorizationCommandDecorator<TCommand, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/AuthorizationCommandDecorator.cs:28· Level 9 · class (sealed)
- What it is: the second-outermost command decorator. When a command implements IRequiresPermission and none of the caller's roles grants that permission, it short-circuits with a
Forbiddenfailure before logging, cache invalidation, validation, the timeout budget, or the transaction. - Depends on: IRequiresPermission (the opt-in marker exposing
Permission); ICurrentUserService (AuthorizationCommandDecorator.cs:1,:28, suppliesRoles); IPermissionRegistry (:3,:29, the role-to-permission map); ResultFailureFactory; Error and ErrorType; CqrsMetrics (RecordAuthorizationDenied); ICommandHandler<in TCommand, TResult>. - Concept introduced, authorization as a use-case concern rather than a transport concern.
[Rubric §11, Security]assesses where the capability check lives and whether it survives a change of transport. The class doc frames this decorator as defense in depth, not a replacement for the endpoint's[Authorize]policy (AuthorizationCommandDecorator.cs:20-24): moving the check next to the use case is what makes a command reached through gRPC, a scheduled job, or another module get checked the same way it is over HTTP.[Rubric §13, Observability & Operability]assesses the factoring: no handler injects a permission service.[Rubric §13, Observability & Operability]: every denial increments a counter, so a permission denying far more traffic than expected is visible as a metric rather than as a support ticket. - Walkthrough
_createFailure(AuthorizationCommandDecorator.cs:48) andCreateFailure()(:52-53): the lazily-built ResultFailureFactory delegate,_createFailure ??= ResultFailureFactory.Build<TResult>(). The remarks (:36-45) explain why the build is deferred to the first short-circuit rather than done in a static initializer:ResultFailureFactorysupports only Result andResult<T>and throws otherwise (ResultFailureFactory.cs:43-45), and because Scrutor'sTryDecorateis unconditional, an eager static initializer would turn an unsupportedTResultinto aTypeInitializationExceptionat resolve time for a handler that never denies anything. One assignment per closed generic type, and a benign duplicate build under a race produces an equivalent delegate.- Opt-out (
AuthorizationCommandDecorator.cs:60-61):command is not IRequiresPermissionreturns the inner handler's result directly, so an un-opted command pays one type test. - The check (
AuthorizationCommandDecorator.cs:63-64):permissionRegistry.HasPermission(currentUser.Roles, requiresPermission.Permission), and on true the inner handler runs.HasPermissionis true when any of the supplied roles grants the permission (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:22-28), role lookups are case-insensitive and permission values compared ordinally (:9-12), and an unknown permission is granted by no role (IRequiresPermission.cs:20-21), so the default is deny. - Denial (
AuthorizationCommandDecorator.cs:66-73): readstypeof(TCommand).Name(:64), recordsCqrsMetrics.RecordAuthorizationDenied(commandName)(:65), then returnsError.Forbidden("Authorization.PermissionDenied", ...)naming the missing permission, withsourceset to the command type name.Error.Forbiddenis theErrorType.Forbiddenfactory (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Error.cs:82-83), and the counter iscqrs.authorization.denied.counton theMMCA.Common.Cqrsmeter (CqrsMetrics.cs:24,:53-56,:74-76).
- Why it's built this way: the placement is the argued part. It sits directly inside the feature gate and outside logging, cache invalidation, validation and the transaction (
DependencyInjection.cs:95-97, class doc:12-17), so a denied command never starts a transaction, never invalidates the cache and never runs validation, while a feature that is off is still rejected first, because a disabled feature must not leak the existence of the permission that guards it (DependencyInjection.cs:91-94). ADR-020 supplies the permission model and the registry; the 2026-08-18 revision of ADR-014 inserted this stage into both chains. - Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:136) as the sixth of seven command registrations (:129-135), therefore the second-outermost wrapper on every command handler in the host. Because the two authorization decorators are registered unconditionally and take anIPermissionRegistry, the same method first doesservices.TryAddSingleton<IPermissionRegistry, UnconfiguredPermissionRegistry>()(DependencyInjection.cs:126, comment:119-123): a host that declared its grants viaAddAuthorizationPolicies()orAddPermissions(...)keeps its own registry, and a host with no permission model still resolves every handler instead of failing activation. UnconfiguredPermissionRegistry grants nothing (UnconfiguredPermissionRegistry.cs:35-42) and logs one warning the first time a permission is actually checked (:49-60), so the fallback fails closed and says so. Behavior is covered by AuthorizationCommandDecoratorTests and, for the ordering, CommandDecoratorPipelineTests. - Caveats / not-in-source: no production command implements IRequiresPermission today (the only implementers in the workspace are test fakes such as
GuardedCommandatMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs:148), so this decorator is a pass-through in every deployed host. One behavioral note for whoever adopts it first: because the decorator sits outside LoggingCommandDecorator<TCommand, TResult>, a denial is not recorded in thecqrs.command.durationhistogram and produces no correlated command log line, only the denial counter. The failure travels on the Result channel rather than as an exception, and the framework's transport mappers already translate it: ErrorHttpMapping mapsErrorType.Forbiddento HTTP 403 (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:27) and ResultGrpcExtensions maps it toStatusCode.PermissionDenied(MMCA.Common/Source/Presentation/MMCA.Common.Grpc/ResultGrpcExtensions.cs:43).
AuthorizationQueryDecorator<TQuery, TResult>
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Decorators·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/AuthorizationQueryDecorator.cs:23· Level 9 · class (sealed)
- What it is: the query-side twin of AuthorizationCommandDecorator<TCommand, TResult>. It is the second-outermost query decorator, denying a query with a
Forbiddenfailure when none of the caller's roles grants its IRequiresPermission permission. - Depends on: IRequiresPermission; ICurrentUserService and IPermissionRegistry (
AuthorizationQueryDecorator.cs:23-26); ResultFailureFactory; Error and ErrorType; CqrsMetrics; IQueryHandler<in TQuery, TResult>. - Concept reinforced, plus one property specific to the read side. See AuthorizationCommandDecorator<TCommand, TResult> for the pattern and for
[Rubric §11, Security]. The read-side-specific argument is stated in this file's own doc (AuthorizationQueryDecorator.cs:14-19) and repeated in the registration rationale (DependencyInjection.cs:95-97): the decorator is registered outside CachingQueryDecorator<TQuery, TResult>, so a denied query neither reads nor populates the cache. That ordering is load-bearing rather than tidy: a cache lookup placed ahead of the permission check would serve another caller's cached rows to a principal who is not allowed to run the query at all. - Walkthrough:
_createFailure(AuthorizationQueryDecorator.cs:43) andCreateFailure()(:47-48) match the command side, including the deferred-build rationale (:31-39).HandleAsync(:51) passes through onquery is not IRequiresPermission(:53-54), runs the inner handler whenpermissionRegistry.HasPermission(currentUser.Roles, requiresPermission.Permission)is true (:56-57), and otherwise readstypeof(TQuery).Name(:59), recordsCqrsMetrics.RecordAuthorizationDenied(queryName)(:60), and returnsError.Forbidden("Authorization.PermissionDenied", ...)withsourceset to the query type name (:63-66). - Why it's built this way: identical reasoning to the command twin, with the cache-poisoning argument above as the extra constraint that fixes its position relative to caching. Same ADRs: ADR-020 for the permission model, ADR-014 for the pipeline and its 2026-08-18 revision.
- Where it's used: registered by
AddApplicationDecorators()(DependencyInjection.cs:144) as the fifth of six query registrations (:138-143), therefore the second-outermost wrapper on every query handler. Covered by AuthorizationQueryDecoratorTests. - Caveats / not-in-source: as with the command twin, no production query implements IRequiresPermission today (the only implementer is the
GuardedQuerytest fake atMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationQueryDecoratorTests.cs:101), so this is a pass-through in every deployed host. A denial is likewise invisible tocqrs.query.duration, since the decorator sits outside LoggingQueryDecorator<TQuery, TResult>.
ICacheInvalidating
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Markers·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/ICacheInvalidating.cs:8· Level 0 · interface
- What it is: an opt-in interface for commands that should evict cached entries after a successful mutation. Implementing it exposes a
CachePrefixstring; CachingCommandDecorator<TCommand, TResult> callsICacheService.RemoveByPrefixAsyncwith that prefix once the inner handler returns a non-failure result. - Depends on: nothing first-party at the interface level. Works in concert with ICacheService (the eviction mechanism), is tenant-scoped through ITenantContext, and shares the opt-in-by-implementing pattern with ITransactional.
- Concept introduced, prefix-based cache invalidation as an opt-in pipeline concern.
[Rubric §12, Performance & Scalability]assesses caching strategy and how stale reads are avoided; prefix-scoped eviction keeps read caches coherent after writes without the command site knowing individual cache keys. Naming the prefix (for example the aggregate's full type name plus:) scopes eviction to only the affected segment. This is also[Rubric §2, Design Patterns]: Decorator plus a presence-as-signal interface, the same shape ITransactional introduces. Note where the interface sits: it is implemented by the command record, not by the handler, because the decorator's type check iscommand is ICacheInvalidating(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:61). - Walkthrough: line 8 declares
public interface ICacheInvalidating; line 14 declares its only member,string CachePrefix { get; }. Three behaviors of the consuming decorator are worth carrying here because they are the contract in practice. (1) Empty means opt out. The decorator's guard is a three-part condition,command is ICacheInvalidatingplus!string.IsNullOrWhiteSpace(cacheInvalidating.CachePrefix)plus!IsFailure(result)(CachingCommandDecorator.cs:61-63), and the comment above it (:56-58) says why the blank-prefix half is load-bearing:RemoveByPrefixAsync("")would evict the entire cache. (2) Tenant scoping is applied for you. The prefix is run throughTenantCacheKey.Scope(tenantContext, ...)(CachingCommandDecorator.cs:67, helper atMMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:37), so a resolved tenant evicts only its own keyspace. (3) Eviction happens twice. The firstRemoveByPrefixAsyncruns withCancellationToken.Noneso cleanup outlives a caller that walked away (CachingCommandDecorator.cs:71-74), and a delayed second eviction is scheduled at:79and performed byReInvalidateAfterDelayAsync(:96-108) afterReInvalidationDelay, five seconds (:43), which removes an entry that an in-flight read repopulated with pre-write state. Both evictions are best-effort: a failure is caught and logged, never surfaced to a command that already committed (:81-86and:103-108). - Why it's built this way: decoupling what to invalidate (the command's concern, via
CachePrefix) from how to invalidate (the decorator plusICacheService) keeps handlers free of cache-infrastructure knowledge and makes invalidation testable in isolation. A businessResult.Failurestill returns through the pipeline but skips invalidation, because of the!IsFailure(result)half of the guard (CachingCommandDecorator.cs:63): only a genuine success evicts. - Where it's used: broadly adopted on the write side. Verified by source search, 40 files under
MMCA.ADC/Sourcereference it (Conference category, event, session, speaker and sponsor mutations, plus Identity user mutations) and 27 underMMCA.Store/Source(catalog, sales, cart and identity mutations). The framework's own DeleteEntityCommand<TEntity, TIdentifierType> implements it with a defaulted prefix. Consumed exclusively by CachingCommandDecorator<TCommand, TResult>.
IFeatureGated
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Markers·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/IFeatureGated.cs:10· Level 0 · interface
- What it is: an opt-in interface that puts both commands and queries behind a Microsoft.FeatureManagement flag. When the named feature is disabled, the gate short-circuits and returns a failure result without calling the handler.
- Depends on: nothing first-party at the interface level. Enforcement lives in FeatureGateCommandDecorator<TCommand, TResult> and FeatureGateQueryDecorator<TQuery, TResult>, which depend on
IFeatureManager(Microsoft.FeatureManagement) and callIsEnabledAsync(FeatureName)(IFeatureGated.cs:5-8). - Concept introduced, feature flags as a cross-cutting pipeline concern.
[Rubric §12, Performance & Scalability]assesses whether concerns like flags, logging, and caching are factored out of business logic; the gate sits at the handler boundary as the outermost decorator on both sides (see ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult>), so no caller needs a flag check and the handler has no flag knowledge. As with ITransactional, the interface goes on the command or query record, not on the handler: the decorators testcommand is not IFeatureGatedandquery is not IFeatureGatedand pass everything else straight through (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/FeatureGateCommandDecorator.cs:50,.../FeatureGateQueryDecorator.cs:48). Its position relative to IRequiresPermission is deliberate and argued in the registration doc (DependencyInjection.cs:91-94): the flag is checked before the permission, so a feature that is off answers the same way for every caller rather than leaking which permission guards it. - Walkthrough: line 10 declares
public interface IFeatureGated; line 16 declaresstring FeatureName { get; }, which must match a key in theFeatureManagementconfiguration section (documented on lines 12-15). The consuming decorator is three branches long: no marker means pass through (FeatureGateCommandDecorator.cs:50-51), an enabled flag means pass through (:51-52), and a disabled flag returns a constructed failure carryingError.NotFoundErrorwith the codeFeature.Disabledand a message naming the feature (:54-57). Answering not found rather than forbidden is the same non-disclosure choice as the ordering: a disabled feature looks like a route that does not exist. - Why it's built this way: putting the gate in the pipeline applies it uniformly to every gated command and query with zero per-handler boilerplate, and one interface serves both sides because a feature-gate decorator is registered against both handler interfaces (
DependencyInjection.cs:137and:143). - Where it's used: sparingly and deliberately, on the two operations that genuinely need a runtime kill switch. Verified by source search, the production implementers are
MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Orders/UseCases/VerifyPayment/VerifyPaymentCommand.cs:20, which combines it with ICacheInvalidating, andMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13(RefreshFromSessionizeCommand), which combines it with ICacheInvalidating and ITransactional. Feature-name constants live in per-module*Featuresclasses to avoid magic strings (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/SalesFeatures.cs:6,MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/CatalogFeatures.cs:6, ConferenceFeatures, EngagementFeatures), and each doc comment notes that the same constant serves both the[FeatureGate]attribute on a controller and this interface on a command. Feature state is read from configuration at runtime.
IHasTimeout
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Markers·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/IHasTimeout.cs:14· Level 0 · interface
- What it is: an opt-in interface by which a command or query declares its own execution budget as a
TimeSpan. TimeoutCommandDecorator<TCommand, TResult> and TimeoutQueryDecorator<TQuery, TResult> enforce it by linking a cancellation source to the caller's token, cancelling it after the budget, and converting the resulting cancellation into a failure result instead of letting it surface as an exception. - Depends on: BCL only (
TimeSpan). Enforced by the two timeout decorators; the expiry is counted by CqrsMetrics throughRecordTimeout(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CqrsMetrics.cs:81, instrumentcqrs.timeout.countat:60). Same presence-as-signal shape as IFeatureGated, ICacheInvalidating and IRequiresPermission, except that this marker carries data rather than only a name. - Concept introduced, a per-request execution budget owned by the request type.
[Rubric §29, Resilience, Reliability & Business Continuity]assesses whether a slow or wedged dependency can consume a caller indefinitely; a budget bounds the blast radius of one hung handler without a global timeout that would have to be tuned for the slowest operation in the system.[Rubric §12, Performance & Scalability]assesses load-shedding behavior: a request that has already blown its budget is work nobody is waiting for. Two properties make the contract safe to adopt. First, the budget is aTimeSpan, not a seconds int, so a caller cannot silently mean milliseconds. Second, a non-positive value is "no budget" (IHasTimeout.cs:16-21), which is the guard against a misconfigured or defaulted value failing every request instantly; the decorators implement it ashasTimeout.Timeout <= TimeSpan.Zeroin the same test that checks the interface (TimeoutCommandDecorator.cs:65,TimeoutQueryDecorator.cs:65). Opting in is per request type: anything that does not implement the interface passes through the decorator untouched and keeps the caller's token unchanged (IHasTimeout.cs:9-12). - Walkthrough: line 14 declares
public interface IHasTimeout; line 21 declares its only member,TimeSpan Timeout { get; }. The doc comment on lines 3-13 names both decorators and states the conversion contract, which is the part a reader has to know before adding the interface to a command. In the decorator the mechanism is five lines: a linked source over the caller's token (TimeoutCommandDecorator.cs:68),CancelAfter(hasTimeout.Timeout)(:67), the inner call onbudget.Token(:71), and an exception filter that only catches the cancellation this budget caused,when (budget.IsCancellationRequested && !cancellationToken.IsCancellationRequested)(:73), so a caller who genuinely walked away still sees a cancellation rather than a fabricated timeout. The failure it builds isError.Failurewith the codeRequest.TimedOutand a message quoting the budget in seconds (:79-84), so an expired budget arrives at the caller on the same Result channel as a validation failure. - Why it's built this way: ADR-014, revised 2026-08-18, records both the marker and its placement in the two chains. Putting the value on the request type rather than in configuration keeps the budget next to the operation it bounds and reviewable in the same pull request, exactly as IQueryCacheable keeps
CacheDurationon the query. - Where it's used: read by the two timeout decorators only.
- Caveats / not-in-source: verified by source search, no production command or query in MMCA.ADC, MMCA.Store or MMCA.Helpdesk implements
IHasTimeouttoday. The only implementers in the workspace are the framework's own test doubles inMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/TimeoutCommandDecoratorTests.csandTimeoutQueryDecoratorTests.cs. It is a shipped, tested extension point with no adopter, so both timeout decorators are pass-throughs in every deployed host.
IQueryCacheable
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Markers·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/IQueryCacheable.cs:8· Level 0 · interface
- What it is: the query-side opt-in for caching. A query implements this to declare a
CacheKey(the exact lookup key for this query's result) and aCacheDuration(per-query TTL); CachingQueryDecorator<TQuery, TResult> checks the cache on the way in and stores the result on a miss. - Depends on: nothing first-party. Query-side companion to ICacheInvalidating; implemented through ICacheService and tenant-scoped through ITenantContext exactly as the write side is.
- Concept introduced, per-query cache keys and staleness budgets.
[Rubric §12, Performance & Scalability].CacheKeymust encode every query parameter that affects the result, and the XML doc gives the shape ("Catalog:Products:page=1&size=10",IQueryCacheable.cs:10-13); omit a parameter and the cache answers one query shape with another's result.CacheDurationgives per-query TTL control so a hot, stable list can cache longer than volatile data, and putting it on the interface rather than in configuration keeps each query the owner of its own staleness budget. - Walkthrough: line 8 declares
public interface IQueryCacheable; line 14 declaresstring CacheKey { get; }, computed from the query's own properties; line 19 declaresTimeSpan CacheDuration { get; }. - Why it's built this way: opt-in means only queries that genuinely benefit (frequently called, expensive, not user-specific) pay the serialization and staleness cost. A query that does not implement it hits the decorator's early return and goes straight to the handler.
- Where it's used: exactly one production query today. Verified by source search across ADC and Store, the only production implementer is ADC's GetNowNextQuery (
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23): a hot, public, non-user-specific home-screen "now / next" read. ItsCacheKey(lines 26-35) is built as theSessionfull type name plus:NowNext:plus the event id or"current", deliberately under the sameSessionaggregate prefix the session write commands use as theirCachePrefix, and itsCacheDurationis 30 seconds (line 38).MMCA.Store/Sourcehas no implementer at all. The framework's ownExportUserDataHandlerBaseand the sample apps (HelpdeskGetTicketByIdQuery, ECommerceGetProductByIdQueryandGetOrderByIdQuery) are the other adopters, and the unit tests in CachingQueryDecoratorTests exercise the decorator directly. - Caveats / not-in-source: the read-cache mechanism is fully functional and unit-tested, but adoption is one production query, so do not assume an arbitrary existing query is cached through this path. Production read-side caching is mostly done a layer up, at HTTP, through ASP.NET Core OutputCache policies on the Conference read controllers. The query's own doc comment (
GetNowNextQuery.cs:14-19) is candid that prefix eviction only engages once anIConnectionMultiplexeris registered, so the 30-second TTL, not prefix invalidation, is the real staleness backstop on the deployed services.
IRequiresPermission
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Markers·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/IRequiresPermission.cs:16· Level 0 · interface
- What it is: an opt-in interface by which a command or query names the fine-grained permission its caller must hold. AuthorizationCommandDecorator<TCommand, TResult> and AuthorizationQueryDecorator<TQuery, TResult> resolve the caller's roles, ask the permission registry whether any of them grants it, and short-circuit with a
Forbiddenfailure when none does. - Depends on: nothing first-party at the interface level. Enforcement pulls in ICurrentUserService (the caller's roles) and IPermissionRegistry (the role-to-permission map), and the failure is an Error of ErrorType
Forbidden. - Concept introduced, capability checks moved from the transport to the use case.
[Rubric §11, Security]assesses whether authorization is enforced where the operation lives rather than only where it happens to be exposed; the interface's own doc makes the claim precise (IRequiresPermission.cs:10-14): opting in is per request type, so a command or query that does not implement it passes through untouched and endpoint-level[Authorize]policies remain the only gate for everything that has not opted in. That is defense in depth, not a replacement (AuthorizationCommandDecorator.cs:21-24): the value of moving the check inward is that a command reached through a new transport (gRPC, a scheduled job, another module) is checked exactly the way it is over HTTP.[Rubric §1, SOLID]: adding a permission requirement to a use case is a one-interface change with no decorator, endpoint or registry code touched. The fail-closed default is stated on the member itself (IRequiresPermission.cs:18-22): a permission string the host's registry does not know about is granted by no role and therefore denies every caller, so a typo locks the operation rather than opening it. The same fail-closed posture is why the decorators are registered unconditionally over aTryAddSingletonfallback registry, so a host with no permission model still resolves every handler (DependencyInjection.cs:121-126). - Walkthrough: line 16 declares
public interface IRequiresPermission; line 23 declares its only member,string Permission { get; }, documented with the dotted-capability convention ("catalog.products.write"). The decorators consume it with one type test and one registry call: the pass-through atAuthorizationCommandDecorator.cs:60/AuthorizationQueryDecorator.cs:55, thenpermissionRegistry.HasPermission(currentUser.Roles, requiresPermission.Permission)(AuthorizationCommandDecorator.cs:63,AuthorizationQueryDecorator.cs:58), thenError.Forbiddenon the way out (:68and:63).HasPermissionreturns true if any supplied role grants the permission (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:28, implemented as a short-circuiting loop over a role-keyed dictionary atMMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistry.cs:38-54). - Why it's built this way: ADR-020 established permissions (capabilities) as the authorization currency and the registry as the single place that knows which roles confer which permissions; ADR-014, revised 2026-08-18, is what put the check into the CQRS pipeline and fixed its position: directly inside the feature gate and outside logging and caching, so a denied request neither reads nor populates the cache (
DependencyInjection.cs:95-97). That ordering is load-bearing: a cache lookup ahead of the permission check would serve another caller's cached rows to a principal not allowed to run the query at all. - Where it's used: read by the two authorization decorators only.
- Caveats / not-in-source: verified by source search, no production command or query in MMCA.ADC, MMCA.Store or MMCA.Helpdesk implements
IRequiresPermissiontoday; the only implementers are the framework's test doubles inMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.csandAuthorizationQueryDecoratorTests.cs. Both deployed apps still authorize at the endpoint with[Authorize]policies, so this is a shipped capability awaiting adoption, and both authorization decorators are pass-throughs in production.
ITransactional
MMCA.Common.Application ·
MMCA.Common.Application.UseCases.Markers·MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/ITransactional.cs:6· Level 0 · interface (marker)
- What it is: a C# empty-body interface (
public interface ITransactional;) that a command implements to opt in to database-transaction wrapping by TransactionalCommandDecorator<TCommand, TResult>. - Depends on: nothing (BCL only).
- Concept introduced, marker interfaces as opt-in decorator switches.
[Rubric §2, Design Patterns]assesses the deliberate, idiomatic use of patterns; a marker interface carries no members, so its mere presence on a type is the signal. The decorator's whole dispatch isif (command is not ITransactional)followed by a pass-through (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TransactionalCommandDecorator.cs:27-29). Read that line carefully: the marker goes on the command record, not on the handler class, which is the same convention IFeatureGated, ICacheInvalidating, IHasTimeout and IRequiresPermission follow. This is also[Rubric §1, SOLID](open for extension): the pipeline gains new transactional commands with no change to any existing decorator. The semicolon-body syntax (interface ITransactional;, line 6) is the idiomatic zero-member declaration and is used consistently across the framework's markers, including ICommand<TResult> and IQuery<TResult>. - Walkthrough: the entire file body is
public interface ITransactional;at line 6, under the doc comment on lines 3-5. No members; the type is the message. - Why it's built this way: keeping transaction scope opt-in spares single-statement commands the cost of an explicit begin and commit around a save that is already atomic. Commands that mutate multiple aggregates, or that save twice against one database, opt in. The behavior that comes with opting in is documented in ADR-014 and
MMCA.Common/CLAUDE.md, and is worth knowing before you add the marker: exceptions and business failures (Result.Failure) both roll back, and in-process domain event dispatch is deferred until after a successful commit, so handlers never act on state that could still roll back. - Where it's used: sparingly, and the restraint is deliberate. Verified by source search, exactly 4 implementers in
MMCA.ADC/Source(RefreshFromSessionizeCommand.cs:13,BatchAddSessionQuestionAnswersCommand.cs:24,LinkUserToSpeakerCommand.cs:13,UnlinkUserFromSpeakerCommand.cs:12) and exactly 2 inMMCA.Store/Source(UploadProductImageCommand.cs:27andReorderProductImagesCommand.cs:22, both of which save twice against the same database and document why on lines 10 and 11 respectively). Several Store commands a reader would expect to see here carry a doc comment saying the opposite:CheckOutCommand.cs:9,BulkSetInventoryCommand.cs:10,VerifyPaymentCommand.cs:11andProcessPaymentWebhookCommand.cs:9are each explicitly annotated "Deliberately NOTITransactional", because their handlers reach the database in a single save that is already atomic. Inspected at execution time by TransactionalCommandDecorator<TCommand, TResult>, the innermost command decorator, so it sits closest to the handler (see the registration note under ICommandHandler<in TCommand, TResult>).
IEventUpcaster
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Events·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Events/IEventUpcaster.cs:28· Level 2 · interface (plus its typed generic sibling in the same file)
- What it is: the contract for converting one retired integration-event contract into its successor, so handlers are written once against the newest shape while older messages (queued at the broker, or sitting unprocessed in an outbox written before the upgrade) keep being delivered. The file declares two interfaces: the non-generic
IEventUpcasterthe framework resolves and indexes by (:28), and the typedIEventUpcaster<in TSource, out TTarget>application code actually implements (:67). - Depends on: IIntegrationEvent (
usingat:2) as the type of both ends of the conversion, andSystem.Diagnostics.CodeAnalysis.SuppressMessage(BCL). Composed by IEventUpcasterRegistry and registered throughAddEventUpcaster<TSource, TTarget, TUpcaster>(). - Concept introduced, event-schema evolution by additive versioning instead of in-place reshaping.
[Rubric §6, CQRS & Event-Driven]assesses whether published event contracts can change without breaking subscribers, and[Rubric §9, API & Contract Design]assesses versioning of the contracts a system publishes. The policy behind this type is that a breaking event-shape change (a renamed, removed or retyped field) is a new event type plus a consumer-side upcaster, never a silent edit of an existing type (ADR-010); this interface plus its registration extension point is how that policy is actually expressed in code (ADR-090, cited at:15-19). The teaching point for a reader new to the pattern: upcasting is a read-side concern. Producers are never asked to publish two shapes, and handlers are never asked to accept two shapes. The conversion happens once, at the boundary between "what arrived" and "what the handlers are written for".[Rubric §7, Microservices Readiness]also applies, because a broker in front of independently-deployed services is exactly the environment where producer and consumer versions diverge for a while.[Rubric §15, Best Practices & Code Quality]: a chain of small pure functions is deletable in the order it was added, which is why the registration docs describe the retirement path (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:545-551). - Walkthrough, taking the two interfaces in the order the framework sees them.
- The non-generic
IEventUpcaster(:28) declares three members:Type SourceType { get; }(:31), the retired contract it reads;Type TargetType { get; }(:34), the successor it produces; andIIntegrationEvent Upcast(IIntegrationEvent integrationEvent)(:41). This is the shape the registry indexes by and walks chains with, so nothing in the framework needs to know the closed generic types. - The typed
IEventUpcaster<in TSource, out TTarget> : IEventUpcaster(:67) is what an application implements. Both parameters are constrainedclass, IIntegrationEvent(:68-69), and the variance annotations (in/out) are the natural ones for a converter. Its whole trick is default interface implementations:SourceType => typeof(TSource)(:72),TargetType => typeof(TTarget)(:75), and an explicitIIntegrationEvent IEventUpcaster.Upcast(...)that downcasts and forwards to the typed overload (:85-86). An implementer therefore writes exactly one method,TTarget Upcast(TSource integrationEvent)(:82), and gets the non-generic surface for free. - The
[SuppressMessage]on CA1033 (:63-66) documents why: a default interface implementation of an inherited member can only be written as an explicit implementation, so there is no non-explicit form to offer child types. - Map payload fields only (
:21-26and:58-61). The framework preserves the envelope: after every hop the registry stampsMessageIdandDateOccurredfrom the pre-hop instance onto the upcasted one, so consumer-side inbox deduplication keeps working on the id the producer published. An upcaster that copies them itself is harmless (the stamp is idempotent) and one that forgets is still correct.
- The non-generic
- Why it's built this way: splitting the non-generic index surface from the typed authoring surface is what lets one registry hold heterogeneous upcasters in a single
Dictionary<Type, IEventUpcaster>while implementers still write strongly typed code with no casts. Registration names both contracts explicitly,services.AddEventUpcaster<TOld, TNew, TUpcaster>()(.../Application/DependencyInjection.cs:551-558), so the compiler checks the shape at the registration site rather than leaving a mismatch to fail on the first message (:534-536); implementations are registered singleton throughTryAddEnumerablebecause they are pure functions (:556). Chains compose: registering V1 to V2 and V2 to V3 delivers a V1 message to the V3 handler (:539, andIEventUpcaster.cs:56). - Where it's used: composed by IEventUpcasterRegistry and thereby reached from both delivery paths, the in-process DomainEventDispatcher and the broker-side UpcastingIntegrationEventConsumer<TEvent>. Two architecture fitness rules police the shape across every repo, living once in the shared rules package:
EventUpcastersHaveUniqueSourceTypes(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Contracts/ArchitectureRules.Upcasters.cs:12), because with two upcasters reading one type the contract a handler receives would depend on DI registration order, andEventUpcastersIncreaseSchemaVersion(:28), which reads theSchemaVersionoff both contracts and fails when the target is not strictly higher (:34-47, with a missing or non-int version deliberately left to a different rule,:37-38). The rules match the interface by name and arity (an ordinal comparison against the runtime name ofIEventUpcasterwith generic arity 2,:82-83) so the rule library keeps its no-compile-dependency idiom.SchemaVersionitself is thevirtual inton BaseIntegrationEvent that defaults to 1 (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32). - Caveats / not-in-source: verified by source search, no
Source/tree in the workspace contains anIEventUpcasterimplementation today, in the framework or in MMCA.ADC, MMCA.Store or MMCA.Helpdesk; the only two mentions outside the interface file are the generic constraint onAddEventUpcaster(.../Application/DependencyInjection.cs:554) and the interface's own declaration. Every implementation is a test fixture. The mechanism is fully built and tested and has not yet had to be used on a real contract; the doc comment on the framework's own OutputCacheEvictionRequested records this as the shape a future V2 would take (MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:16-26).
IEventUpcasterRegistry
MMCA.Common.Application ·
MMCA.Common.Application.Interfaces.Events·MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Events/IEventUpcasterRegistry.cs:24· Level 2 · interface
- What it is: the composed view of every registered IEventUpcaster. It answers three questions: does anything upcast this type, what is the newest contract this type ends up as, and give me this instance converted to that contract, walking the whole chain.
- Depends on: IIntegrationEvent (
usingat:1) and IEventUpcaster. Implemented by EventUpcasterRegistry (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30). - Concept introduced, the empty-registry identity default.
[Rubric §12, Performance & Scalability]assesses how an optional capability is threaded through a pipeline without every call site having to test for its presence. The registry is registered unconditionally byAddApplication()(.../Application/DependencyInjection.cs:41, comment at:37-40): with no upcasters registered it is an empty registry whose operations are the identity function, so both delivery paths can depend on it without a null check or a feature flag (ADR-090).[Rubric §15, Best Practices & Code Quality]and[Rubric §13, Observability & Operability]both apply to the failure model: a misregistration is a programming error, so it throws at composition time naming the offenders rather than returning aResult(EventUpcasterRegistry.cs:14-20), and a dedicated hosted service makes that happen at host start rather than on the first message. - Walkthrough: three members on the interface, and the implementation behind each is worth reading.
bool HasUpcasterFor(Type eventType)(:31) is a dictionary probe (EventUpcasterRegistry.cs:85-90).Type ResolveTerminalType(Type eventType)(:39) returns the newest contract the type upcasts to, or the type itself when nothing claims it (EventUpcasterRegistry.cs:93-98). It is a precomputed lookup, not a walk:BuildTerminalTypesresolves every chain once in the constructor (:81, method at:133-161), because the chain graph is static once DI is built.IIntegrationEvent UpcastToTerminal(IIntegrationEvent integrationEvent)(:48) applies every hop and preserves the envelope at each one (EventUpcasterRegistry.cs:101-123). Two details in the loop repay attention: it advances by the upcaster's declaredTargetTyperather than the runtime type of what was returned, which the constructor's acyclicity check is what bounds, and anullreturn from an upcaster throws with the offender named (:112-114).- Validation is constructor-time (
EventUpcasterRegistry.cs:50-82). A self-mapping upcaster (:61) and two upcasters claiming one source (:67) are collected into anoffenderslist (:55), so a misconfigured host sees all the problems at once rather than one per restart, then a singleInvalidOperationExceptionis thrown (:74-79). Cycles are caught separately inBuildTerminalTypesby walking each chain with avisitedset and reporting the chain in order (:139-155); a repeated type is definitely a cycle and not a diamond, because the duplicate-source check already made the graph functional. - Envelope preservation (
EventUpcasterRegistry.cs:169-179) readsMessageIdandDateOccurredoff the pre-hop instance and writes them onto the upcasted one through cachedPropertyInfohandles, held in a staticConcurrentDictionarykeyed by the produced type (:36) so a chain pays the reflection lookup once per contract. Both properties areinit-only on the domain-event base, which reflection can still set, and a non-writable property is simply skipped (Writable,:181-182).
- Why it's built this way: ADR-090. Making envelope preservation the registry's job rather than the author's is the load-bearing choice: it means consumer-side inbox deduplication stays keyed on the id the producer published by construction, so no upcaster author can break deduplication by forgetting to copy a field they were never asked to think about (
EventUpcasterRegistry.cs:22-27). Precomputing terminal types and caching envelope reflection keeps the per-message cost to dictionary lookups and delegate calls. - Where it's used: registered singleton by
AddApplication()(.../Application/DependencyInjection.cs:41) and consumed by both delivery paths.- In-process: DomainEventDispatcher holds it as a
Lazy<IEventUpcasterRegistry?>resolved withGetService(MMCA.Common/Source/Core/MMCA.Common.Application/Services/DomainEventDispatcher.cs:32-33) and runs the integration branch through it before resolving handlers, so the handlers invoked are the ones written against the newest type (:58-62). - Broker-side: UpcastingIntegrationEventConsumer<TEvent> takes it as a constructor dependency (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/UpcastingIntegrationEventConsumer.cs:32-33) and dedups on the original message id before any upcasting (:56-70), then probesHasUpcasterForand converts (:72-79). It is registered per retired type withRegisterUpcastedIntegrationEventConsumer<TEvent>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumerExtensions.cs:78), whose doc is explicit that you must not also register the plain IntegrationEventConsumer<TEvent> for the same type: two consumers on one event compete for the same queue and run the handlers twice (:61-63). - Startup: EventUpcasterStartupValidator is an
IHostedServicewhose entire job is to resolve the registry and touch one member, turning the constructor validation into a host-start failure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/EventUpcasterStartupValidator.cs:20,23-30). It is registered byAddInfrastructurethroughTryAddEnumerable(.../Infrastructure/DependencyInjection.cs:175-176) so several modules calling it do not run the validation several times (:171-174).
- In-process: DomainEventDispatcher holds it as a
- Caveats / not-in-source: because no host registers an upcaster today (see IEventUpcaster), every production resolution of this type is the empty-registry identity path;
UpcastToTerminalreturns its argument andResolveTerminalTypereturns its argument. The chain-walking, cycle detection and envelope preservation described above are exercised only by tests at present.
⬅ Domain & Integration Events + Outbox Dual-Dispatch • Index • Validation ➡