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.
Accepted
2026-05-24
Architecture
BeyondNetCode.Shell.Aop is consumedUMS command handlers need structured cross-cutting concerns: entry/exit loggingwith duration, distributed tracingwith tenant tags, metrics (RED signals), andexception capture. These concerns must be:
Task object is returned.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.
| 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:
CreateTenantCommand would need type-specific conditions or separate behavior registrations per request type.if request is X then log, else skip) is an anti-pattern that defeats the purpose of the pipeline abstraction.Resolution:MediatR behaviors remain the canonical mechanism for uniform pipeline concerns. BeyondNetCode.Shell.Aop is the canonical mechanism for selective, per-method decoration.
BeyondNetCode.Shell.Aop was chosen over a new NuGet dependencyBeyondNetCode.Shell.Aop is anownedshell library — no external package management, no upstream breaking changes, no additional CVE surface.DispatchProxy, AspectExecutor, PointCut, IAspect chain, OnMethodBoundaryAspect, LoggerAspect, RetryAspect, and AdviceAspect.AddAop() + AddAopProxy<TService, TImpl>() is already built and tested.BeyondNetCode.Shell.Aop with System.Reflection.DispatchProxy as the mechanism for selective, per-method cross-cutting concerns in UMS command handlers.| 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.
[LoggerAspect(Type = typeof(IMelLogger), LogDuration = true, LogException = true)] on CreateTenantCommandHandler.HandleMelLogger registered as keyed ILogger (AOP) under typeof(IMelLogger) in Infrastructure DIIMelLogger marker interface in Ums.Application.Common.Aop — decouples Application layer from Infrastructure concrete typeAddAopProxy<IRequestHandler<CreateTenantCommand, Result<CreateTenantResponse>>, CreateTenantCommandHandler>() in Infrastructure.DependencyInjectionTracingAspect implementing ActivitySource.StartActivity() with tenant_id tag (aligns with ADR-0053)MetricsAspect implementing Histogram<long> via IMeterFactoryAddAopProxy<> to all command handlers in Identity and Authorization bounded contextsRetryAspect on Infrastructure services that call external IdP endpointsAdviceAspect for domain-specific audit hooks| 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. |
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.
[LoggerAspect] + [Tracing] attributes make the concern decoration visible and searchable in code review.MelLogger bridges AOP’s custom ILogger interface to Microsoft.Extensions.Logging — no custom Serilog sink required in Application.System.Reflection.DispatchProxy requires the service to be aninterface (or abstract class). Concrete class proxying is not supported — this is enforced by AddAopProxy<TService, TImpl>().DispatchProxy.Invoke is intrinsically synchronous; the async continuation wrapper adds minor overhead (~1 allocation per async method call).PointCut caches (MethodInfo, Type) → bool per proxy type — cache grows proportionally with the number of proxied methods; negligible in practice.RegisterServicesFromAssembly registers handlers before AddAopProxy<> — callers must ensure AddAopProxy<> is calledafterMediatR registration so the proxy wins the last-registration-wins resolution.AddAopProxy throws ArgumentException for ServiceLifetime.Singleton) because aspects may resolve scoped services (e.g., IUserContext).DispatchProxy’s interface constraint becomes limiting.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 |