ums

ADR-0060: AOP Cross-Cutting Concern Strategy — DispatchProxy over MediatR Behaviors

Promoted to Evolith: This ADR has been elevated to Evolith ADR-0072 — .NET AOP Cross-Cutting Concern Strategy. UMS retains this document as implementation reference.

Status

Accepted

Date

2026-05-24

Decision Owner

Architecture


Context

UMS command handlers need structured cross-cutting concerns: entry/exit loggingwith duration, distributed tracingwith tenant tags, metrics (RED signals), andexception capture. These concerns must be:

  1. Selective — applied per-handler or per-method, not uniformly to all requests.
  2. Non-invasive — zero changes to the handler’s business logic.
  3. Async-correct — fire hooks after the awaited result, not when the Task object is returned.
  4. Testable in isolation — handlers unit-tested without cross-cutting infrastructure.

UMS already uses MediatR IPipelineBehavior<TRequest, TResponse> foruniformpipeline concerns (ValidationBehavior). The question is whether to extend that mechanism for cross-cutting concerns or adopt a different model.

Alternatives considered

Option Mechanism Selective? Async-correct? External dep? Decision  
A MediatR IPipelineBehavior<,> all-or-nothing per type     Rejected for cross-cutting  
B Decorator classes per handler manual     Rejected — O(n) boilerplate  
C Castle.DynamicProxy / Autofac interceptors     new NuGet required Rejected — stack pollution  
D BeyondNetCode.Shell.Aop + System.Reflection.DispatchProxy attribute-driven (after fix) owned shell lib Adopted #### Why MediatR IPipelineBehavior was not sufficient

IPipelineBehavior<TRequest, TResponse> applies to every command that matches its type constraint. This is the right model foruniformconcerns (validation, idempotency) but creates unacceptable coupling forselectiveconcerns:

Resolution:MediatR behaviors remain the canonical mechanism for uniform pipeline concerns. BeyondNetCode.Shell.Aop is the canonical mechanism for selective, per-method decoration.

Why BeyondNetCode.Shell.Aop was chosen over a new NuGet dependency


DecisionAdopt BeyondNetCode.Shell.Aop with System.Reflection.DispatchProxy as the mechanism for selective, per-method cross-cutting concerns in UMS command handlers.

1. Separation of responsibilities

Concern Mechanism Applies to  
Input validation ValidationBehavior (MediatR) All commands uniformly  
Idempotency IdempotencyMiddleware (HTTP) All mutating endpoints  
Logging (selective) LoggerAspect via BeyondNetCode.Shell.Aop Per-handler, opt-in via [LoggerAspect]  
Tracing (Phase 2) TracingAspect via BeyondNetCode.Shell.Aop Per-handler, opt-in via [Tracing]  
Metrics (Phase 2) MetricsAspect via BeyondNetCode.Shell.Aop Per-handler, opt-in via [Metrics]  
Retry (selective) RetryAspect via BeyondNetCode.Shell.Aop Per-method, opt-in via [RetryAspect] ### 2. Async proxy fix — mandatory prerequisite

System.Reflection.DispatchProxy.Invoke is synchronous. Prior to this ADR, OnMethodBoundaryAspect.OnSuccess and OnExit fired when a Task was returned, not when it completed. This caused hooks to observe incomplete state.

Fix (implemented in BeyondNetCode.Shell.Aop/Impl/OnMethodBoundaryAspect.cs):After joinPoint.Proceed(), detect Task / Task<TResult> return types and wrap them in continuation tasks via ConfigureAwait(false). The original Task<TResult> path is handled via a cached generic MethodInfo (WrapAsyncOfT<TResult>) to preserve the result value. The synchronous finally { OnExit() } block is skipped for async paths to prevent double-firing.

3. Adoption scope

Phase 1 (implemented — 2026-05-24)

Phase 2 (planned)

Phase 3 (future consideration)

4. PII policy for logging aspects

Logger Argument values logged When to use  
MelLogger Never — names/types only Default; all handlers  
SerilogLogger Destructured (opt-in) Only after explicit PII review and approval [LoggerAspect(LogArguments = [])] (empty array) is the PII-safe default and must be set on all handlers unless a specific argument is reviewed and cleared.

5. Layer references introduced

Ums.Application.csproj
 └── BeyondNetCode.Shell.Aspects ← attribute contract only ([LoggerAspect], etc.)

Ums.Infrastructure.csproj
 ├── BeyondNetCode.Shell.DI ← AddAop(), AddAopProxy<>()
 └── BeyondNetCode.Shell.Logger.Serilog ← SerilogLogger adapter

Ums.Domain doesnotreference any BeyondNetCode.Shell.Aop project. Domain purity is preserved.


Consequences

Positive

Trade-offs

Non-decisions


Compliance

The following checks are mandatory after any change to an AOP aspect or AOP proxy registration:

# Build the full solution
dotnet build src/apps/ums.api/Ums.sln

# Run all test suites
dotnet test src/apps/ums.api/Ums.sln --verbosity minimal
dotnet test src/libs/shell/aop/src/BeyondNetCode.Shell.Aop.Tests/BeyondNetCode.Shell.Aop.Tests.csproj --verbosity minimal

# Verify no Domain purity violation
grep -r "BeyondNetCode.Shell.Aop" src/apps/ums.api/Ums.Domain/ --include="*.csproj"
# Expected: no output

ADR Registry AOP Developer Guide ADR-0053 OpenTelemetry ADR-0054 Shell Isolation