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

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). Revised 2026-09-07 (the caching decorator's key carries the calling user for a query that declares itself caller-scoped, with an explicit opt-out marker). Revised 2026-09-11 (an unhandled exception is logged once, at the boundary: both logging decorators record the exception outcome at Warning and without the exception object, and the single Error row with the full stack belongs to the handler that actually handles it; the duration histograms and their outcome=exception tag are unchanged; see the Revision (2026-09-11) at the end). Revised 2026-09-19 (the authorization decorators no longer hold the check themselves: both delegate to a shared AuthorizationGate.Evaluate(...), the capability test now grants on a permission claim as well as on a registry role, and a second IRequiresMfa step-up gate runs after it. The description in the Revision (2026-08-18) below is the pre-2026-09-19 one: read the Revision (2026-09-19) at the end. The chain itself is unchanged, seven decorators on commands and six on queries). Revised 2026-09-25 (ADC's decorator-order fitness test composes through AddMmcaApplicationPipeline and runs VerifyDecoratorPipeline, as Store's already did; see the Revision (2026-09-25) at the end. Citations refreshed: the Common DependencyInjection registration helpers now span DependencyInjection.cs, DependencyInjection.ModuleScanning.cs and DependencyInjection.Crud.cs). Revised 2026-10-01 (the record states where each kind of validation lives: UI form models, the Validating decorator with its coverage fitness gate, and domain factories; see Revision below). Revised 2026-10-06: the Decision states the shipped seven-decorator command chain and six-decorator query chain instead of the pre-2026-08-18 order. Revised 2026-10-07: anchors refreshed after the v1.233.0 release. Revised 2026-10-19: the Helpdesk reference host composes through AddMmcaApplicationPipeline too, so all eight hosts take the sealed path; see the Revision (2026-10-19) at the end.

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> and IQueryHandler<TQuery, TResult> (MMCA.Common.Application) are one handler per use case, each returning Result / Result<T> (ADR-013).
  • Cross-cutting concerns are decorators registered with Scrutor TryDecorate in AddApplicationDecorators(). Because TryDecorate applies in reverse registration order (last registered is outermost), the execution order (outermost to innermost) is (registration sequence at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:134-140 for commands and :143-148 for queries; how the chain reached this shape is in the Revisions (2026-08-18) and (2026-08-26) below):
    • Commands: FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler
    • Queries: FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Handler
    • plus an optional pair of Profiling decorators (ProfilingCommandDecorator / ProfilingQueryDecorator) registered by a separate opt-in AddApplicationProfiling() call, not by AddApplicationDecorators(). 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 (with CachePrefix), IQueryCacheable (with CacheKey + 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.DiscoverAndRegister in the service hosts, or ScanModuleApplicationServices<TMarker>() directly); AddApplicationDecorators() MUST be called last, after every module's concrete handlers exist, so TryDecorate can find them. The hand-written form of that sequence is AddApplication -> AddInfrastructure -> AddAPI -> module handler scan via ModuleLoader.DiscoverAndRegister -> AddApplicationDecorators. All eight hosts compose it through AddMmcaApplicationPipeline(pipeline => ...) instead, which runs AddApplication first, the callback's registration steps, and AddApplicationDecorators last, and seals the collection (see the Revision (2026-08-26) below): the Helpdesk reference host (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:121) and the seven production service hosts, ADC Identity / Conference / Engagement / Notification (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:324, ...Conference.Service/Program.cs:416, ...Engagement.Service/Program.cs:286, ...Notification.Service/Program.cs:227) and Store Identity / Catalog / Sales (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:236, ...Catalog.Service/Program.cs:256, ...Sales.Service/Program.cs:236). Only that decorators-last ordering is load-bearing; the relative position of AddInfrastructure/AddAPI is not.
  • Each kind of validation has one home. UI form models declare DataAnnotations rules once and bridge them onto MudForm through ModelValidation.For(model, validator) (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:26,43) over a pluggable IModelValidator (.../Validation/IModelValidator.cs:13), so a FluentValidation adapter can supply the rules instead; that is UX feedback, not enforcement. Server-side FluentValidation is the Validating decorator step of this pipeline (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:32), and CommandValidatorCoverageTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/CommandValidatorCoverageTestsBase.cs:22) fails the architecture test run when a data-carrying command has no validator, either its own or through the CommandRequestValidator bridge (:9-15, facts at :40-48). Domain invariants stay in the entity factories and value objects, returning Result (ADR-013, ADR-068).

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.Failure committed 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.ExecuteInTransactionAsync inspects the returned Result and calls RollbackTransaction() when IsFailure is 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 (DomainEventSaveChangesInterceptor deferred 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:114) still uses Scrutor TryDecorate and still documents the reverse-registration rule inline (:57-58), now with nesting diagrams of both chains beside it (:61-83, command chain at :61-71, query chain at :74-83). The literal registration sequence is :134-140 for commands and :143-148 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.

  1. 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) take ICurrentUserService and IPermissionRegistry, and resolve the check as permissionRegistry.HasPermission(currentUser.Roles, requiresPermission.Permission) (AuthorizationCommandDecorator.cs:63, AuthorizationQueryDecorator.cs:58), against bool HasPermission(IEnumerable<string> roles, string permission) (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/IPermissionRegistry.cs:28) and the IEnumerable<string> Roles default interface member on ICurrentUserService (.../Interfaces/Infrastructure/ICurrentUserService.cs:45). A denial returns Error.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 on cqrs.authorization.denied.count (counter AuthorizationDenied, unit {request}, tag request_type, .../Decorators/CqrsMetrics.cs:53-56,76-77) on the existing MMCA.Common.Cqrs meter (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.
  2. Timeout, keyed on IHasTimeout. The marker is TimeSpan Timeout { get; } (.../UseCases/Markers/IHasTimeout.cs:14,21), a TimeSpan rather than a seconds int, and a value <= TimeSpan.Zero means "no budget, pass through" (:17-20, guard at TimeoutCommandDecorator.cs:65). The decorator links a fresh source to the caller's token (CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) plus budget.CancelAfter(hasTimeout.Timeout), TimeoutCommandDecorator.cs:68-69) and invokes the inner handler with budget.Token (:73). On expiry it returns Error.Failure("Request.TimedOut", ...) (:81-86); Request.TimedOut is the error code and the ErrorType is Failure, because the ADR-013 taxonomy has no timeout member (rationale at TimeoutCommandDecorator.cs:14-19). Caller cancellation still propagates unchanged: the catch is filtered as catch (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 on cqrs.timeout.count (counter TimeoutExpired, unit {request}, tag request_type, CqrsMetrics.cs:59-62,81-82, recorded at TimeoutCommandDecorator.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:92-94, 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:88-91), 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. ADC and Store build that sequence through the same AddMmcaApplicationPipeline(pipeline => pipeline.ScanModule<...Identity.Application.ClassReference>()) call their hosts use (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:50-51, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:52), and each adds a ComposedPipeline_LeavesNoHandlerUndecorated fact that runs services.VerifyDecoratorPipeline() over the Identity module's whole handler set (ADC :55, call at :66; Store :57, call at :68); Common and Helpdesk hand-sequence AddApplication, the module scan and AddApplicationDecorators (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/Conformance/DecoratorPipelineOrderTests.cs:38-40, MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/DecoratorPipelineOrderTests.cs:59-61). 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
  1. 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 registered IValidator<TQuery> runs, in registration order. The constructor takes IEnumerable<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 one Result failure. 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 a DbContext is 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 unsupported TResult into a TypeInitializationException at resolve time for a query that never fails validation (:47-56).
  2. 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:144, chain diagram at :74-83). 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-100, restated at ValidatingQueryDecorator.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:103-106).
  3. AddMmcaApplicationPipeline(pipeline => ...) is the composition path, and it seals. (DependencyInjection.cs:207-216.) It runs AddApplication(), then the caller's handler registrations through a small builder (module scans, a ModuleLoader run, cross-service client registrations that replace a handler's dependencies: .../MMCA.Common.Application/MmcaApplicationPipelineBuilder.cs, argument contract at DependencyInjection.cs:173-180), then AddApplicationDecorators(), in that order (:211-215, equivalence spelled out at :187-192). Closing the pipeline registers a private marker type on the service collection (:150, marker at :294, registered at :307-308, probe at :296-305), and any later AddApplicationDecorators, ScanModuleApplicationServices or second AddMmcaApplicationPipeline throws naming the mistake (:310-320, guards at :116, DependencyInjection.ModuleScanning.cs:49, :209). 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 (DependencyInjection.Crud.cs:74, guard at :80), AddEntityUpdateVerb (:136, guard at :142) and AddEntityUpdate (:187, guard at :193). That converts the one load-bearing ordering rule of this record (decorators last, because TryDecorate only 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: DependencyInjection.cs:189-192) into a startup exception. Registrations that are not handlers stay outside the call: their order relative to the decorators does not matter (:195-196).
  4. VerifyDecoratorPipeline() is the fitness hook. (DependencyInjection.cs:244-286.) Never called automatically, it asserts that the pipeline was closed at all (:246-251) and that every registered ICommandHandler<,> / IQueryHandler<,> entry is wrapped, throwing with each unwrapped registration named (:277-285, formatting at :322-333). The check is registration-shape only: it reads ServiceDescriptor entries and never builds a provider, so a fitness test does not have to register a double for every decorator dependency (:230-232). 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 (DependencyInjection.cs:235-241, the effective-descriptor pass at :253-269, the predicate at :272). The outermost decorator's type cannot be read back at all after decoration, since it exists only inside a closure (:240-241), which is why this complements rather than replaces DecoratorPipelineOrderTestsBase: 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.

Revision (2026-09-07)

One decorator's key composition changed (SEC-Common-19 / SEC-Common-37). CachingQueryDecorator builds its entry key as TenantCacheKey.Scope(tenantContext, UserCacheKey.Scope(cacheable, cacheable.CacheKey)) (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:59): the tenant scope that was always there now wraps a caller scope. UserCacheKey (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/UserCacheKey.cs:26) appends the target user when the query implements IUserScopedRequest (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedRequest.cs:8), and the new ISharedQueryCache marker (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/ISharedQueryCache.cs:21) opts a genuinely public result back out.

Two consequences for handler authors, which are the reason this sits in the pipeline record rather than only in the caching one. A query whose result depends on who is asking has to say so on the request type: the decorator cannot infer it, and a query that carries the target user in its own payload already varies its CacheKey by construction. And because the user segment is a suffix, the prefix-based invalidation the decorators already do still clears every caller's copy in one sweep.

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).

Revision (2026-09-11)

One decorator behaviour changed on both chains, and it is a logging decision rather than a pipeline one: the order, the markers, the sealing rule and the short-circuit currency are all unchanged.

Two Error rows per failure became one. LoggingCommandDecorator catches a thrown exception, records the duration against the exception outcome and rethrows untouched (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:59-:70). Its own line is now Warning and carries no exception object (:117-:118, the reason stated at :114-:116); the message names what happened and where the stack will be, rather than repeating the stack. LoggingQueryDecorator is identical (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingQueryDecorator.cs:56-:60, the line at :106). Every unhandled exception previously produced two Error rows and two stacks, which doubled the ingestion cost of the noisiest events in the system and made an operator counting Errors count each failure twice.

The boundary keeps the single Error and the single stack. The exception travels on to whichever boundary actually handles it: GlobalExceptionHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/GlobalExceptionHandler.cs:67) or DbUpdateExceptionHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DbUpdateExceptionHandler.cs:31) for a request, and InternalCommandProcessor for a deferred command, whose dead-letter and cycle-failure lines are Error (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:759, :762, :765, :741, :744). That is the place that also decided what the caller sees, which is why it owns the record of the failure.

Nothing about the metrics moved. Both decorators still record their duration histogram through the per-path helper with the outcome tag intact (LoggingCommandDecorator.cs:97-:101, LoggingQueryDecorator.cs:91-:95), so the RED signal ADR-041 defines keeps the same shape and outcome=exception still counts the same events. The decorator line and the boundary line carry the same correlation id through the logging scope, so they still join.

The cost. Anything alerting on the decorator's Error level sees nothing now and has to alert on the boundary's instead. A caller that invokes a handler outside any of those boundaries and swallows the exception itself is left with a Warning and no stack, which is the trade this makes deliberately: the stack belongs to the code that handles the failure.

Revision (2026-09-19)

One decorator pair changed inside, and the pipeline around it did not: the order is still FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler for commands (seven decorators) and the same chain without Transactional for queries (six), registered at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:134-140 and :143-148.

The authorization check moved out of the two decorators into one shared helper. AuthorizationCommandDecorator now asks AuthorizationGate.Evaluate(command, currentUser, permissionRegistry, typeof(TCommand).Name) for a denial and runs the inner handler when the answer is null (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/AuthorizationCommandDecorator.cs:62-64), and AuthorizationQueryDecorator does the identical thing for a query (.../Decorators/AuthorizationQueryDecorator.cs:57-59). Neither file contains a permissionRegistry.HasPermission(...) call any more. The decorators keep only what actually differs between them (which handler interface they wrap and which generic parameter names the request) plus the lazily built failure factory (AuthorizationCommandDecorator.cs:50-57). The stated reason for the extraction is drift: a rule enforced on commands but not on queries reads as working until a use case moves from one side to the other (.../Decorators/AuthorizationGate.cs:14-18).

The capability test now grants on either source. AuthorizationGate.Evaluate (AuthorizationGate.cs:40) denies an IRequiresPermission request only when the registry does not grant the permission to any of the caller's roles and the principal carries no matching permission claim (:46-48, ClaimsPrincipalExtensions.HasPermissionClaim at MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:94-97, the claim type at .../Auth/AuthClaimTypes.cs:24). The claim is what carries a stored grant across a service boundary, since only the minting host reads the grant table, so a registry-only check would deny in one service what the endpoint policy allows in another (AuthorizationGate.cs:33-39). The denial itself is unchanged: Error.Forbidden("Authorization.PermissionDenied", ...) with the request type name as the source (:52-55), a failure value rather than a throw.

A second gate joins the same pass: IRequiresMfa. The marker is empty, a pure request-type declaration (.../UseCases/Markers/IRequiresMfa.cs:28), and it is orthogonal to IRequiresPermission: a use case may carry either, both, or neither, and both checks must pass (:16-19). A marked request reached by a principal with no mfa claim short-circuits with Error.Forbidden("Authorization.MultiFactorRequired", ...) (AuthorizationGate.cs:60-67, presence test at ClaimsPrincipalExtensions.cs:127-128). Two properties are deliberate. Absence denies: there is no "this account has no second factor, so let it through" branch, because a marked use case is one where step-up is the point, and the fix is enrolling rather than a fallback (IRequiresMfa.cs:22-25). And the step-up check runs second, so a caller who lacks the capability entirely is answered by the capability gate and never learns which use cases additionally demand a factor (AuthorizationGate.cs:58-60).

Both denials are counted on the one existing counter. The gate records CqrsMetrics.RecordAuthorizationDenied(requestTypeName) on either path (AuthorizationGate.cs:50 and :62), which is still cqrs.authorization.denied.count with the request_type tag on the MMCA.Common.Cqrs meter (.../Decorators/CqrsMetrics.cs:53-54,76-77, ADR-041). A dashboard therefore sees step-up denials and permission denials in one series, separable only by request_type. Recording inside the gate is also what lets each decorator shape a failure and nothing else.

Nothing else in this record moves. The marker-driven opt-in model, the outside-caching and inside-FeatureGate placements of Authorization and their arguments, the sealing rule of AddMmcaApplicationPipeline, and the order pinned by DecoratorPipelineOrderTestsBase all stand as written: the chain gained no member, so the conformance test's expected sequences are untouched.

Revision (2026-09-25)

The consumer fitness tests changed, not the pipeline. ADC's DecoratorPipelineOrderTests now builds its registration sequence through AddMmcaApplicationPipeline, the helper its four service hosts call, instead of hand-sequencing AddApplication, ScanModuleApplicationServices and AddApplicationDecorators, so a regression in the helper's own ordering surfaces as a wrong nesting in the test run (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:43-51). It also gains the ComposedPipeline_LeavesNoHandlerUndecorated fact, which calls VerifyDecoratorPipeline() over the whole Identity module (:54-67, the call at :66). That is the shape Store already had (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:52, :57, :68), so both production consumers now pin the order and the coverage of their pipeline. The order base asserts one command and one query; the coverage fact is what catches a handler anywhere in the module that escaped the decorators. The expected sequences are still the base's own, and the chain is unchanged.

Revision (2026-10-01)

The Decision gains one bullet stating where each kind of validation lives, so the Validating decorator's role is read against its neighbours rather than in isolation. The chain is unchanged.

  • UI form models. A page declares DataAnnotations rules once on its form model and bridges them onto MudForm through ModelValidation.For (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:26,43), whose rule engine is the pluggable IModelValidator (.../Validation/IModelValidator.cs:13). Adoption: 19 page code-behind call sites in the MMCA.ADC Conference UI module (for example MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionCreate.razor.cs), plus 2 Engagement UI feedback pages that use the single-field ModelValidation.ForProperty instead, which runs DataAnnotations only and does not go through IModelValidator (.../Validation/ModelValidation.cs:56-58,69-72), 4 page code-behind call sites across the MMCA.Store Catalog, Identity and Sales UI modules on origin/main (for example MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Products/ProductCreate.razor.cs), and none in MMCA.Helpdesk.
  • Server side. FluentValidation runs as the Validating decorator step recorded above, and CommandValidatorCoverageTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/CommandValidatorCoverageTestsBase.cs:22) is the fitness gate that keeps the step from running empty: every data-carrying command handled in a module Application assembly needs its own IValidator<TCommand> or the CommandRequestValidator bridge (:9-15), with an explicit allow-list and a non-vacuity floor (:31, :38, facts at :40-48). ADC, Store and Helpdesk each subclass it (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.cs:18, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.cs:17, MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:163).
  • Domain. Invariants stay in the entity factories and value objects that return Result (ADR-013, ADR-068), so neither the UI rules nor the decorator is the last line of defence.

Citations refreshed in the Decision: the AddMmcaApplicationPipeline call sites are now ADC Identity :311 and Notification :221, and Store Identity :234, Catalog :254 and Sales :234. Stale anchors inside the earlier Revision sections are left as historical records.

Revision (2026-10-06)

  • The Decision now states the shipped chain. Its order bullet carried the pre-2026-08-18 chain (five command decorators, three query decorators) behind a "superseded" pointer; it now reads FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> Handler for commands and the same chain without Transactional for queries, citing the registration sequence (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:134-140, :143-148). The chain itself is unchanged since the Revision (2026-08-26).
  • The AddMmcaApplicationPipeline call sites in the Decision moved again: ADC Identity :324, Conference :414, Engagement :285, Notification :227; Store Identity :236, Catalog :256, Sales :236. The anchors restated in the Revision (2026-10-01) above are left as recorded.
  • The IRequiresPermission marker cited in the Revision (2026-08-18) is now declared at MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Markers/IRequiresPermission.cs:34, with its single member string Permission { get; } at :41; behavior is unchanged.
  • Anchors in the live sections were re-verified against current source.

Revision (2026-10-07)

Re-verified against current source. The decision, both decorator chains (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:134-140, :143-148) and the validation homes are unchanged; only two AddMmcaApplicationPipeline call sites moved. Stale anchors inside the earlier Revision sections are left as historical records.

  1. Anchors re-verified against current source: in the Decision, ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:416) and Engagement (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:286) are re-pointed; ADC Identity :324 and Notification :227, Store Identity :236, Catalog :256 and Sales :236, and MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:132 still match.

Revision (2026-10-19)

The Helpdesk reference host joins the sealed composition path. The decision and both decorator chains are unchanged; what changed is that no host writes the decorators-last sequence by hand any more.

  1. MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs composes through AddMmcaApplicationPipeline at :121, with module discovery as a Register step (:125-133) and the broker wiring as a second Register step (:139); AddInfrastructure (:77), AddAPI (:99) and AddModuleHealthChecks (:141) stay outside the call. The host no longer calls AddApplication() or AddApplicationDecorators() directly, so the Decision now names eight composing hosts.
  2. The MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:132 anchor in the Decision and in the Revision (2026-10-07) above is dead (that line no longer holds AddApplicationDecorators()); the Decision cites :121, and the Revision (2026-10-07) is left as recorded.