Accepted
2026-05-24
Architecture
Ums.Shell.Aop is consumedUMS command handlers need structured cross-cutting concerns: entry/exit logging with duration, distributed tracing with tenant tags, metrics (RED signals), and exception capture. These concerns must be:
Task object is returned.UMS already uses MediatR IPipelineBehavior<TRequest, TResponse> for uniform pipeline 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 | Ums.Shell.Aop + System.Reflection.DispatchProxy |
✅ attribute-driven | ✅ (after fix) | ❌ owned shell lib | Adopted |
IPipelineBehavior was not sufficientIPipelineBehavior<TRequest, TResponse> applies to every command that matches its type constraint. This is the right model for uniform concerns (validation, idempotency) but creates unacceptable coupling for selective concerns:
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. Ums.Shell.Aop is the canonical mechanism for selective, per-method decoration.
Ums.Shell.Aop was chosen over a new NuGet dependencyUms.Shell.Aop is an owned shell 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.Adopt Ums.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 Ums.Shell.Aop |
Per-handler, opt-in via [LoggerAspect] |
| Tracing (Phase 2) | TracingAspect via Ums.Shell.Aop |
Per-handler, opt-in via [Tracing] |
| Metrics (Phase 2) | MetricsAspect via Ums.Shell.Aop |
Per-handler, opt-in via [Metrics] |
| Retry (selective) | RetryAspect via Ums.Shell.Aop |
Per-method, opt-in via [RetryAspect] |
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 Ums.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
└── Ums.Shell.Aop.Aspects ← attribute contract only ([LoggerAspect], etc.)
Ums.Infrastructure.csproj
├── Ums.Shell.Aop.Microsoft.Extensions.DependencyInjection.Aspects.Installer ← AddAop(), AddAopProxy<>()
└── Ums.Shell.Aop.Aspects.Logger.Serilog ← SerilogLogger adapter
Ums.Domain does not reference any Ums.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 an interface (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 called after MediatR 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/Ums.Shell.Aop.Tests/Ums.Shell.Aop.Tests.csproj --verbosity minimal
# Verify no Domain purity violation
grep -r "Ums.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 |