Architecture Decision Record
ADR-014: CQRS Handlers with a Decorator Pipeline
Status
Accepted. Revised 2026-07-19 (Transactional semantics: rollback on business failure + post-commit event dispatch; see Revision below). Revised 2026-08-18 (the pipeline order changed: an Authorization decorator was inserted between FeatureGate and Logging, and a Timeout decorator between Validating and Transactional, on both the command and the query chain; the order is now pinned by a shipped conformance test. The order stated in the Decision below is the pre-2026-08-18 one: read the Revision (2026-08-26) at the end for the current chain). Revised 2026-08-26 (a Validating decorator joins the query chain, and the registration sequence gains a sealed composition path plus a fitness hook). Revised 2026-08-31 (correction: both Validating decorators run every registered validator and union the failures, they do not stop at the first registration; the sealed composition path is the shipped idiom in all seven production service hosts; citations refreshed).
Context
Commands and queries share cross-cutting concerns: validation, transactions, cache invalidation, logging / timing, and feature gating. Putting that logic inside each handler scatters it, makes the ordering between concerns implicit, and couples use-case logic to infrastructure. We wanted each handler to hold only its use-case logic, with the cross-cutting concerns applied uniformly and in a known, intentional order, regardless of whether the handler is invoked from REST, gRPC, or an integration-event consumer.
Decision
Use single-responsibility handlers behind a Scrutor-composed decorator pipeline.
ICommandHandler<TCommand, TResult>andIQueryHandler<TQuery, TResult>(MMCA.Common.Application) are one handler per use case, each returningResult/Result<T>(ADR-013).- Cross-cutting concerns are decorators registered with Scrutor
TryDecorateinAddApplicationDecorators(). BecauseTryDecorateapplies in reverse registration order (last registered is outermost), the execution order (outermost to innermost) is (superseded by the Revision (2026-08-18), which inserts Authorization and Timeout into both chains):- Commands: FeatureGate -> Logging -> Caching -> Validating -> Transactional -> Handler
- Queries: FeatureGate -> Logging -> Caching -> Handler
- plus an optional pair of
Profilingdecorators (ProfilingCommandDecorator/ProfilingQueryDecorator) registered by a separate opt-inAddApplicationProfiling()call, not byAddApplicationDecorators(). No consumer host wires it today.
- The order is load-bearing and hard-coded (not config-driven): validation runs before the transaction opens (an invalid command never touches the DB), cache invalidation runs outside the transaction boundary (a rolled-back command does not evict valid cache), logging wraps the whole pipeline to time it, and the feature gate short-circuits first.
- Decoration is opt-in per concern via marker interfaces:
ITransactional,ICacheInvalidating(withCachePrefix),IQueryCacheable(withCacheKey+CacheDuration). A handler that implements none simply skips that decorator's work, so messages pay only for the concerns they declare. - Handlers, validators, and mappers are auto-registered by convention (module handler scanning, driven
by
ModuleLoader.DiscoverAndRegisterin the service hosts, orScanModuleApplicationServices<TMarker>()directly);AddApplicationDecorators()MUST be called last, after every module's concrete handlers exist, soTryDecoratecan find them. The hand-written form of that sequence isAddApplication->AddInfrastructure->AddAPI-> module handler scan viaModuleLoader.DiscoverAndRegister->AddApplicationDecorators, andMMCA.Helpdesk.Webstill ends its host wiring that way (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:120). The seven production service hosts compose the same sequence throughAddMmcaApplicationPipeline(pipeline => ...)instead, which runs it in order and seals it (see the Revision (2026-08-26) below): ADC Identity / Conference / Engagement / Notification (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:288,...Conference.Service/Program.cs:348,...Engagement.Service/Program.cs:279,...Notification.Service/Program.cs:216) and Store Identity / Catalog / Sales (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:211,...Catalog.Service/Program.cs:234,...Sales.Service/Program.cs:235). Only that decorators-last ordering is load-bearing; the relative position ofAddInfrastructure/AddAPIis not.
Rationale
- Thin, testable handlers. A handler has no transaction, logging, or caching plumbing, so it is unit-tested in isolation.
- One place to read and change the pipeline. The order is explicit and intentional, documented inline at the registration site, not emergent from scattered code.
- Transport-agnostic. The pipeline runs around the handler itself, so REST, gRPC, and event consumers all get the same behavior; HTTP middleware would only cover the REST path.
Trade-offs
- Registration order is the reverse of execution order (a Scrutor foot-gun), mitigated by the
inline ordering comments in
AddApplicationDecorators(). - Decorators must be registered after handlers, so the DI sequence is a constraint consumers cannot reorder freely.
- A new cross-cutting concern means a new decorator inserted at the correct depth; placing it wrong can silently change semantics (for example, validating inside the transaction).
Revision (2026-07-19)
Two Transactional-decorator semantics changed with the 2026-07-19 full review:
- A returned business failure now rolls the transaction back. Previously a handler returning
Result.Failurecommitted whatever it had already saved (only exceptions rolled back), so a handler that saved and then failed a later invariant left the partial mutation committed. In a framework that mandates Result-over-exceptions (ADR-013), failure values must get the same atomicity as thrown exceptions:DbContextFactory.ExecuteInTransactionAsyncinspects the returnedResultand callsRollbackTransaction()whenIsFailureis true. Cache invalidation was already skipped on failure; that is unchanged. - In-process domain event dispatch is deferred until after commit. Post-save dispatch captured
during an active transaction is queued (
DomainEventSaveChangesInterceptordeferred table) and flushed only after a successful commit; rollback (including the new business-failure rollback) drops it. Handlers therefore never act on state that could still roll back, and a retrying execution strategy cannot dispatch the same events once per attempt. The events' outbox rows roll back with the data, so nothing is delivered on either failure path.
The pipeline order and the "cache invalidation outside the transaction" rule are unchanged.
Revision (2026-08-18)
Two decorators were added to both chains, so the order recorded in the Decision above is no longer
the shipped one. The registration site is unchanged in kind: AddApplicationDecorators()
(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:115) still uses Scrutor
TryDecorate and still documents the reverse-registration rule inline (:57-60), now with ASCII
nesting diagrams of both chains beside it (:61-85, command chain at :61-73, query chain at
:75-85). The literal registration sequence is :129-135 for commands and :138-143 for queries, so
the execution order (outermost to innermost) is now:
- Commands: FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler
- Queries: FeatureGate -> Authorization -> Logging -> Caching -> Timeout -> Handler
Both new decorators are opt-in by marker, consistent with the existing ITransactional /
IQueryCacheable / ICacheInvalidating model, so a use case that declares neither pays nothing.
- Authorization, keyed on
IRequiresPermission. The marker is a single member,string Permission { get; }(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/IRequiresPermission.cs:16,23).AuthorizationCommandDecorator<TCommand, TResult>(.../UseCases/Decorators/AuthorizationCommandDecorator.cs:28-31) and its query twin (AuthorizationQueryDecorator.cs:23-26) takeICurrentUserServiceandIPermissionRegistry, and resolve the check aspermissionRegistry.HasPermission(currentUser.Roles, requiresPermission.Permission)(AuthorizationCommandDecorator.cs:63,AuthorizationQueryDecorator.cs:58), againstbool HasPermission(IEnumerable<string> roles, string permission)(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/IPermissionRegistry.cs:28) and theIEnumerable<string> Rolesdefault interface member onICurrentUserService(.../Interfaces/Infrastructure/ICurrentUserService.cs:45). A denial returnsError.Forbidden("Authorization.PermissionDenied", ...)(:70-73/:65-68) rather than throwing, so it short-circuits as an ordinary ADR-013 failure value; a request that does not implement the marker passes straight through (:60-61/:55-56). Denials are counted oncqrs.authorization.denied.count(counterAuthorizationDenied, unit{request}, tagrequest_type,.../Decorators/CqrsMetrics.cs:53-56,76-77) on the existingMMCA.Common.Cqrsmeter (CqrsMetrics.cs:24, ADR-041). This is the pipeline-side surface of ADR-020's permission registry, which previously had only the[HasPermission]controller attribute. - Timeout, keyed on
IHasTimeout. The marker isTimeSpan Timeout { get; }(.../UseCases/Markers/IHasTimeout.cs:14,21), aTimeSpanrather than a seconds int, and a value<= TimeSpan.Zeromeans "no budget, pass through" (:17-20, guard atTimeoutCommandDecorator.cs:65). The decorator links a fresh source to the caller's token (CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)plusbudget.CancelAfter(hasTimeout.Timeout),TimeoutCommandDecorator.cs:68-69) and invokes the inner handler withbudget.Token(:73). On expiry it returnsError.Failure("Request.TimedOut", ...)(:81-86);Request.TimedOutis the error code and theErrorTypeisFailure, because the ADR-013 taxonomy has no timeout member (rationale atTimeoutCommandDecorator.cs:14-19). Caller cancellation still propagates unchanged: the catch is filtered ascatch (OperationCanceledException) when (budget.IsCancellationRequested && !cancellationToken.IsCancellationRequested)(:75), so a client that aborted fails the filter and the exception keeps travelling rather than being reported as a timeout. Expiries are counted oncqrs.timeout.count(counterTimeoutExpired, unit{request}, tagrequest_type,CqrsMetrics.cs:59-62,81-82, recorded atTimeoutCommandDecorator.cs:78). The query twin is identical (TimeoutQueryDecorator.cs:65-86).
Two placements are load-bearing and are argued in code, not only here. Authorization sits
outside caching deliberately: a cache lookup ahead of the permission check would serve another
caller's rows to a principal not allowed to run the query, so a denied request must neither read nor
populate the cache (DependencyInjection.cs:93-95, restated at
AuthorizationCommandDecorator.cs:13-16, which also notes that a denied command never starts a
transaction and never runs validation). FeatureGate stays outside Authorization so that a disabled
feature does not leak which permission guards it (DependencyInjection.cs:89-92), which preserves
ADR-031's "disabled is indistinguishable from nonexistent" property. Timeout sits inside
validation and outside the transaction, so an invalid command never consumes budget and an expired
budget still unwinds through the transactional decorator's rollback path.
The order is now pinned by a test rather than by comments alone. DecoratorPipelineOrderTestsBase
(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/DecoratorPipelineOrderTestsBase.cs:38) resolves the
handlers from a real ServiceCollection and unwraps the constructed object graph by reflection
(:105-125), asserting both sequences outermost-first (:49-58 commands, :61-69 queries) and that
the innermost element is not itself a decorator (:96-97). Both expected sequences are
protected virtual, so a consumer with a different chain can override them. MMCA.Common subclasses it
against its own registration sequence
(MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/Conformance/DecoratorPipelineOrderTests.cs:23-41) without
overriding either list, so the base order is pinned in Common's default test pass. ADC, Store and
Helpdesk each subclass the same base
(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:29,
MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:29,
MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/DecoratorPipelineOrderTests.cs:35),
and none of the three overrides either list: the only definitions of ExpectedCommandDecorators and
ExpectedQueryDecorators workspace-wide are the base's own
(DecoratorPipelineOrderTestsBase.cs:49,61). All four repos therefore pin the same chain against
their own genuine registration sequence. This closes the "one place to read the pipeline" claim in the Rationale, which until now rested
entirely on the inline comments the Trade-offs cite as the mitigation for the Scrutor foot-gun.
The trade-off list above gains one entry by construction: the chain is now seven decorators deep for a command that declares every marker, and two of the seven were inserted between existing neighbours, so the "placing it wrong can silently change semantics" warning is no longer hypothetical. The caching and transactional placements from the original record are unchanged.
Revision (2026-08-26)
One decorator joins the query chain, and the registration sequence stops being a rule consumers have to remember. The query line in the Revision (2026-08-18) above is superseded; the shipped execution order (outermost to innermost) is now:
- Commands: FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler
- Queries: FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Handler
- Validation is no longer command-only.
ValidatingQueryDecorator<TQuery, TResult>(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingQueryDecorator.cs:35-38) is the twin of the command decorator: the same validator resolution, in which every registeredIValidator<TQuery>runs, in registration order. The constructor takesIEnumerable<IValidator<TQuery>> validators(:37), materialized once into an array (:40) and iterated sequentially (:77-87, errors appended at:86), so every broken rule from every validator is unioned into oneResultfailure. Running the whole set rather than the first registration is deliberate on both sides: a request commonly carries a module-authored validator beside a framework or cross-cutting one, and honoring only the first turns the others into dead code whose rules go silently unenforced, while running them all lets the caller see every broken rule in one response instead of one per round trip (ValidatingCommandDecorator.cs:18-21, command loop at:73-83). The sequential walk is also deliberate: a validator may read through a scoped repository, and aDbContextis not thread-safe (ValidatingQueryDecorator.cs:74-75). The pass-through when a query has no validator is an array-length check, so a query type that registers none pays_validators.Length == 0(:69-72, twin note at:14-20). Queries carrying paging, filter or sort input therefore reject a malformed request the way commands do instead of pushing the bad values into the data source (:17-19). The failure is built through the same reflection-built factory as the command side (:63-64), lazily on the first short-circuit rather than in a static initializer, because Scrutor decorates unconditionally and an eager initializer would turn an unsupportedTResultinto aTypeInitializationExceptionat resolve time for a query that never fails validation (:47-56). - Its placement is the interesting part: inside Caching, outside Timeout. The registration sits
between the caching and timeout decorators
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:141, chain diagram at:75-85). Validation sits inside caching deliberately, which is the opposite of the command side's outside-the-transaction placement and follows from what a cache hit means: an entry can only exist because the same query already passed validation when that entry was first produced, so re-validating on a hit spends work to reach a conclusion already reached (:97-101, restated atValidatingQueryDecorator.cs:26-28). It sits outside the timeout for the mirror of the command-side reason, so a caller is not charged a slice of its execution budget for validating its own bad input (ValidatingQueryDecorator.cs:28-30,DependencyInjection.cs:104-107). AddMmcaApplicationPipeline(pipeline => ...)is the composition path, and it seals. (DependencyInjection.cs:612-621.) It runsAddApplication(), then the caller's handler registrations through a small builder (module scans, aModuleLoaderrun, cross-service client registrations that replace a handler's dependencies:.../MMCA.Common.Application/MmcaApplicationPipelineBuilder.cs, argument contract atDependencyInjection.cs:578-585), thenAddApplicationDecorators(), in that order (:616-620, equivalence spelled out at:591-598). Closing the pipeline registers a private marker type on the service collection (:145, marker at:699, registered at:712-713, probe at:701-710), and any laterAddApplicationDecorators,ScanModuleApplicationServicesor secondAddMmcaApplicationPipelinethrows naming the mistake (:715-725, guards at:117,:182,:614). The same guard now also fronts the three generic-CRUD registration helpers, which close handler types over an entity and are therefore just as order-sensitive:AddEntityCrud(:335),AddEntityUpdateVerb(:397) andAddEntityUpdate(:448). That converts the one load-bearing ordering rule of this record (decorators last, becauseTryDecorateonly wraps registrations that already exist) from a documented convention whose violation is silent (a handler registered afterwards runs with no feature gate, no authorization, no validation, no timeout and no transaction, and nothing fails at startup to say so::594-597) into a startup exception. Registrations that are not handlers stay outside the call: their order relative to the decorators does not matter (:599-602).VerifyDecoratorPipeline()is the fitness hook. (DependencyInjection.cs:649-691.) Never called automatically, it asserts that the pipeline was closed at all (:651-656) and that every registeredICommandHandler<,>/IQueryHandler<,>entry is wrapped, throwing with each unwrapped registration named (:682-690, formatting at:727-738). The check is registration-shape only: it readsServiceDescriptorentries and never builds a provider, so a fitness test does not have to register a double for every decorator dependency (:634-638). What it can see is a consequence of how Scrutor works: decoration rewrites a handler's descriptor into a factory over a keyed copy of the original, so a surviving implementation type on the effective (last non-keyed) registration is proof nothing wrapped it (:639-647, the effective-descriptor pass at:658-674, the predicate at:677). The outermost decorator's type cannot be read back at all after decoration, since it exists only inside a closure (:645-646), which is why this complements rather than replacesDecoratorPipelineOrderTestsBase: that base resolves the real object graph to assert the order, this one asserts coverage without a provider.
The trade-off list gains nothing new in kind: a command that declares every marker is now seven decorators deep and a query six, and the "registration order is the reverse of execution order" foot-gun is unchanged, but its worst outcome (a handler that quietly runs undecorated) is now a throw rather than a silence.
Related
ADR-013 (Result, the short-circuit currency of the pipeline, and the Failure error type the timeout
decorator reuses because the taxonomy has no timeout member), ADR-003 (handlers raise domain events
that the outbox drains after SaveChanges; its 2026-07-19 revision pairs with this one), ADR-020 (the
IPermissionRegistry and role-to-permission model the Authorization decorator consumes: this is its
pipeline-side surface beside the [HasPermission] controller attribute), ADR-041 (the
MMCA.Common.Cqrs meter the two new counters join), ADR-031 (the feature gate that stays outermost so
a disabled feature does not reveal which permission guards it), ADR-026 (the caching substrate the
Authorization decorator is deliberately placed outside of), ADR-058 (the runtime conformance suites a
consumer subclasses; the decorator-order base is one of them).