Part of: Shell Libraries
Projects:Ums.Shell.Aop·Ums.Shell.Aop.DispatchProxy·Ums.Shell.Aop.Aspects·Ums.Shell.Aop.Aspects.Logger.Serilog·Ums.Shell.Aop.Microsoft.Extensions.DependencyInjection.Aspects.Installer
Dependencies:Microsoft.Extensions.DependencyInjection·Serilog(optional) ·System.Linq.Dynamic.Core
Ums.Shell.Aop provides non-invasive aspect-oriented programming via System.Reflection.DispatchProxy. Cross-cutting concerns (logging, retry, advice) are applied as an ordered chain of IAspect objects around any interface-backed service — with no modification to the service implementation.
AddAop() + AddAopProxy()Caller
│
▼
AopProxy<TService, TImpl> ← DispatchProxy subclass
│ Invoke(MethodInfo, args[])
▼
AspectExecutor
│ for each matching aspect (ordered by GetOrder)
▼
IAspect chain → OnMethodBoundaryAspect<TAttribute>
│ OnEntry()
│ Proceed() ──────────────────────────► real TImpl.Method()
│ OnSuccess() (after Task completes)
│ OnExit()
│ OnException() (if throws)
▼
return value (Task or sync)
Key design decisions:
PointCut.CanApply checks for the attribute type in aspect.BaseType.GetGenericArguments(). An aspect only fires if the target method carries its corresponding attribute.GetOrder(joinPoint) — earlier order numbers run first, outer-most in the call stack.OnMethodBoundaryAspect.Apply detects Task/Task<T> returns and defers OnSuccess/OnExit/OnException to a continuation task.Ums.Shell.Aop/
├── Interface/
│ ├── IAspect.cs ← void Apply(IJoinPoint), SetNext/GetNext, GetOrder
│ ├── IAspectExecutor.cs ← void Execute(IJoinPoint)
│ ├── IJoinPoint.cs ← MethodInfo, Arguments, Return, TargetType, Proceed()
│ └── IPointCut.cs ← bool CanApply(IJoinPoint, Type aspectType)
└── Impl/
├── AbstractAspect.cs ← chain linkage + GetAttribute<TAttr>()
├── AbstractAspectAttribute.cs ← marker base for aspect attributes
├── AspectExecutor.cs ← filter + order + chain execution
├── OnMethodBoundaryAspect.cs ← template: OnEntry/OnSuccess/OnExit/OnException + async support
├── OnRetryAspect.cs ← retry-aware boundary
├── JoinPoint.cs ← IJoinPoint implementation
└── PointCut.cs ← attribute-based CanApply with cache
Ums.Shell.Aop.DispatchProxy/
├── AopProxy.cs ← System.Reflection.DispatchProxy subclass
└── AopProxyCreator.cs ← static Create<TService,TImpl>(target, executor)
Ums.Shell.Aop.Aspects/
├── Impl/
│ ├── LoggerAspect.cs ← OnMethodBoundaryAspect<LoggerAspectAttribute>
│ ├── LoggerAspectAttribute.cs ← Type, LogArguments[], LogReturn, LogDuration, LogException, Expression
│ ├── AdviceAspect.cs ← OnMethodBoundaryAspect<AdviceAspectAttribute>
│ ├── AdviceAspectAttribute.cs ← Type (IAdvice implementation)
│ ├── RetryAspect.cs ← OnRetryAspect<RetryAspectAttribute>
│ ├── RetryAspectAttribute.cs ← MaxRetries, ExceptionType
│ ├── Advice.cs ← IAdvice; called by AdviceAspect
│ ├── Evaluator.cs ← System.Linq.Dynamic expression evaluator
│ └── Factory.cs ← IFactory<T> wrapping Func<Type,T>
└── Interface/
├── IAdvice.cs ← void OnEntry/OnSuccess/OnException/OnExit(IJoinPoint)
├── IEvaluator.cs ← string Evaluate(IJoinPoint, expression, default)
├── IFactory.cs ← T Create(Type)
└── ILogger.cs ← AOP logger contract (not MEL ILogger)
Ums.Shell.Aop.Aspects.Logger.Serilog/
└── SerilogLogger.cs ← ILogger (AOP) backed by Serilog static Log.*
Ums.Shell.Aop.Microsoft.Extensions.DependencyInjection.Aspects.Installer/
├── AopAspectsBuilder.cs ← AddAspect<T>(), AddAdvice<T>(), AddLogger<T>()
└── ServiceCollectionExtension.cs ← AddAop(configure?), AddAopProxy<TService,TImpl>()
Use when writing unit tests or console tools without a full DI container.
using Ums.Shell.Aop;
using Ums.Shell.Aop.Aspects;
using Ums.Shell.Aop.DispatchProxy;
using Ums.Shell.Aop.Impl;
// ──── 1. Define service
public interface ICalculator { int Add(int a, int b); }
public class Calculator : ICalculator
{
public int Add(int a, int b) => a + b;
}
// ──── 2. Write a custom aspect (extend OnMethodBoundaryAspect)
public class TimingAttribute : AbstractAspectAttribute { }
public class TimingAspect : OnMethodBoundaryAspect<TimingAttribute>
{
private readonly Stopwatch _sw = new();
protected override void OnEntry(IJoinPoint jp)
=> _sw.Restart();
protected override void OnExit(IJoinPoint jp)
=> Console.WriteLine($"{jp.MethodInfo.Name} took {_sw.ElapsedMilliseconds}ms");
}
// ──── 3. Decorate the target method
public class TimedCalculator : ICalculator
{
[Timing]
public int Add(int a, int b) => a + b;
}
// ──── 4. Build the proxy manually
var target = new TimedCalculator();
var pointCut = new PointCut();
var executor = new AspectExecutor(
types: [typeof(TimingAspect)],
aspectFactory: type => type == typeof(TimingAspect)
? new TimingAspect()
: throw new InvalidOperationException(),
pointCut: pointCut);
ICalculator proxy = AopProxyCreator.Create<ICalculator, TimedCalculator>(target, executor);
// ──── 5. Call through the proxy
int result = proxy.Add(3, 4); // Console: "Add took 0ms"
// result == 7
AddAop() + AddAopProxy()AddAop() wires the built-in aspects (LoggerAspect, AdviceAspect, RetryAspect), the PointCut, AspectExecutor, and the keyed-service factories for ILogger (AOP) and IAdvice.
AddAopProxy<TService, TImpl>() registers:
TImpl as itself (concrete handler).TService as a factory that creates a DispatchProxy wrapping TImpl.Because DI returns the last registration, MediatR (or any caller) transparently resolves the proxy.
// In Ums.Infrastructure/DependencyInjection.cs
services.AddAop(builder =>
{
// Register additional loggers beyond the defaults
builder.AddLogger<SerilogLogger>(); // key = typeof(SerilogLogger)
builder.AddLogger<MelLogger>(); // key = typeof(MelLogger) / typeof(IMelLogger)
// Register additional advice implementations
builder.AddAdvice<AuditAdvice>();
});
// Register the keyed MelLogger under the IMelLogger key so handlers can use it
services.AddKeyedTransient<ILogger, MelLogger>(typeof(IMelLogger));
// Wrap a MediatR handler
services.AddAopProxy<
IRequestHandler<CreateTenantCommand, Result<CreateTenantResponse>>,
CreateTenantCommandHandler>();
// In Ums.Application (references Ums.Shell.Aop.Aspects only — no Infrastructure dep)
public sealed class CreateTenantCommandHandler
: ICommandHandler<CreateTenantCommand, CreateTenantResponse>
{
[LoggerAspect(
Type = typeof(IMelLogger), // resolved from DI as keyed service
LogDuration = true,
LogException = true,
LogArguments = [])] // PII-safe: no arg values
public async Task<Result<CreateTenantResponse>> Handle(
CreateTenantCommand request,
CancellationToken cancellationToken)
{
// ... handler logic
}
}
OnMethodBoundaryAspect<LoggerAspectAttribute> — fires log statements before and after the method.
| Property | Type | Description |
|---|---|---|
Type |
Type |
ILogger implementation to resolve (must be registered as keyed service) |
LogArguments |
string[] |
Parameter names whose values to log (PII-safe: leave empty to log names/types only) |
LogReturn |
bool |
Include the return value in the exit log |
LogDuration |
bool |
Include elapsed milliseconds in the exit log |
LogException |
bool |
Catch exceptions, log them, then re-throw |
Expression |
string |
Dynamic expression (System.Linq.Dynamic) to extract a request-ID from the JoinPoint arguments |
// Log entry, exit with duration, and exceptions; use request.TenantId as request-ID
[LoggerAspect(
Type = typeof(SerilogLogger),
LogDuration = true,
LogException = true,
Expression = "request.TenantId")]
public async Task<Result> Handle(ActivateTenantCommand request, CancellationToken ct) { ... }
OnMethodBoundaryAspect<AdviceAspectAttribute> — delegates to a registered IAdvice for flexible cross-cutting logic.
public class AuditAdvice : IAdvice
{
public void OnEntry(IJoinPoint jp) => /* pre-call action */ ;
public void OnSuccess(IJoinPoint jp) => /* post-success action */;
public void OnException(IJoinPoint jp, Exception ex) => /* error handling */;
public void OnExit(IJoinPoint jp) => /* always-runs action */;
}
// Register
services.AddAop(b => b.AddAdvice<AuditAdvice>());
// Use on method
[AdviceAspect(Type = typeof(AuditAdvice))]
public async Task<Result> Handle(SomeCommand cmd, CancellationToken ct) { ... }
OnRetryAspect<RetryAspectAttribute> — retries the method on transient failure.
[RetryAspect(MaxRetries = 3, ExceptionType = typeof(HttpRequestException))]
public async Task<Result> CallExternalServiceAsync(Request req, CancellationToken ct) { ... }
// 1. Define the attribute
public class MetricsAttribute : AbstractAspectAttribute
{
public string MetricName { get; set; } = string.Empty;
}
// 2. Implement the aspect
public class MetricsAspect(IMeterFactory meterFactory)
: OnMethodBoundaryAspect<MetricsAttribute>
{
private readonly Histogram<long> _duration =
meterFactory.Create("ums").CreateHistogram<long>("handler.duration.ms");
private Stopwatch _sw = new();
protected override void OnEntry(IJoinPoint jp)
=> _sw.Restart();
protected override void OnSuccess(IJoinPoint jp)
{
_sw.Stop();
_duration.Record(_sw.ElapsedMilliseconds,
new TagList { { "method", jp.MethodInfo.Name } });
}
// Return custom order so this aspect runs after Logging (50) but before Transaction (70)
public override int GetOrder(IJoinPoint jp) => 60;
}
// 3. Register
services.AddAop(b => b.AddAspect<MetricsAspect>());
// 4. Apply
[MetricsAspect(MetricName = "create_tenant")]
public async Task<Result<CreateTenantResponse>> Handle(...) { ... }
OnMethodBoundaryAspect.Apply is async-aware since the Phase 0-C fix. It:
joinPoint.Proceed() which stores the raw return value in joinPoint.Return.joinPoint.Return is a Task: wraps it in a new continuation task (WrapAsync / WrapAsyncOfT<T>).joinPoint.Return; OnSuccess/OnException/OnExit fire inside the continuation after ConfigureAwait(false).AopProxy.Invoke returns joinPoint.Return (the wrapper task) — the caller awaits it normally.Effect: OnSuccess fires when the Task actually completes, not when it is returned.
For Task<TResult> methods, the result value is preserved through the WrapAsyncOfT<TResult> path via reflection + cached MethodInfo.
Caller awaits proxy.Handle(cmd, ct)
→ AopProxy.Invoke returns Task<Result<...>> (wrapper)
→ wrapper awaits real Handle() task
→ OnSuccess fires
→ return result to caller
MelLogger (Ums.Infrastructure/Aop/MelLogger.cs) is the Microsoft.Extensions.Logging adapter:
ILogger from ILoggerFactory using jp.TargetType as the category name.typeof(IMelLogger).[LoggerAspect(LogArguments = [])] (empty array) to log only entry/exit metadata.[LoggerAspect(LogArguments = ["request"])] to include parameter name + type (not value) of request.For richer structured logging with value capture (after PII review), use SerilogLogger instead — it uses Log.ForContext("Arguments", arguments, true) with Serilog’s destructuring.
| Logger | Arg values | Category | Structured |
|---|---|---|---|
MelLogger |
❌ Never | jp.TargetType |
✅ via MEL templates |
SerilogLogger |
✅ Destructured | [ClassName, MethodName] |
✅ Serilog |
AddAop(Action<IAopAspectsBuilder>?)Registers into DI:
LoggerAspect, AdviceAspect, RetryAspect as keyed transient IAspect services.Advice as keyed transient IAdvice.IPointCut (singleton PointCut).IAspectExecutor (transient AspectExecutor).IFactory<IAdvice> and IFactory<ILogger> (transient factories backed by keyed-service resolution).IEvaluator (singleton Evaluator using System.Linq.Dynamic).AddAopProxy<TService, TImpl>(ServiceLifetime = Scoped)| Restriction | Detail |
|---|---|
| Singleton not supported | Aspects may depend on scoped services; Singleton throws ArgumentException |
TImpl must implement TService |
Compile-time constraint |
| Last-wins registration | Call after AddMediatR / any other registration of TService |
IAopAspectsBuilder| Method | Registers |
|---|---|
AddAspect<T>() |
Keyed IAspect with key typeof(T) + adds T to the aspect type list |
AddAdvice<T>() |
Keyed IAdvice with key typeof(T) |
AddLogger<T>() |
Keyed ILogger (AOP) with key typeof(T) |
OnMethodBoundaryAspect<TAttribute>| Virtual method | When called |
|---|---|
OnEntry(IJoinPoint) |
Before the method (always synchronous) |
OnSuccess(IJoinPoint) |
After method succeeds (after Task completes for async) |
OnExit(IJoinPoint) |
Always, after success or exception (after Task for async) |
OnException(IJoinPoint, Exception) |
When an exception is thrown; only if HandleException = true |
Continue(IJoinPoint) → bool |
If false, skips method invocation entirely |
| Handler | Aspect | Config |
|---|---|---|
CreateTenantCommandHandler.Handle |
LoggerAspect via MelLogger |
LogDuration=true, LogException=true |
// In Ums.Infrastructure/DependencyInjection.cs — add more AddAopProxy calls
services.AddAopProxy<
IRequestHandler<CreateUserAccountCommand, Result<Guid>>,
CreateUserAccountCommandHandler>();
services.AddAopProxy<
IRequestHandler<ActivateTenantCommand, Result>,
ActivateTenantCommandHandler>();
Decorate each handler with [LoggerAspect(Type = typeof(IMelLogger), LogDuration = true, LogException = true, LogArguments = [])].
public class TracingAttribute : AbstractAspectAttribute { }
public class TracingAspect(ActivitySource source) : OnMethodBoundaryAspect<TracingAttribute>
{
private Activity? _activity;
protected override void OnEntry(IJoinPoint jp)
=> _activity = source.StartActivity(jp.MethodInfo.Name);
protected override void OnSuccess(IJoinPoint jp)
=> _activity?.SetStatus(ActivityStatusCode.Ok);
protected override void OnException(IJoinPoint jp, Exception ex)
=> _activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
protected override void OnExit(IJoinPoint jp)
=> _activity?.Dispose();
public override int GetOrder(IJoinPoint jp) => 10; // first in chain
}
| Order | Aspect | Role |
|---|---|---|
| 10 | TracingAspect |
Outer span — captures full latency |
| 20 | AuthorizationAspect |
Reject early |
| 30 | ValidationAspect |
Domain pre-conditions |
| 40 | IdempotencyAspect |
Dedup before any side-effects |
| 50 | LoggerAspect |
Observe the real execution window |
| 60 | MetricsAspect |
Record duration/throughput |
| 70 | RetryAspect |
Outermost retry loop |
Implement GetOrder(IJoinPoint) in your aspect class to return the appropriate constant.
| Symptom | Cause | Fix |
|---|---|---|
| Aspect never fires | Method doesn’t have the attribute | Add [YourAttribute] to the concrete method (not the interface) |
InvalidCastException in proxy |
TService must be an interface or abstract class |
DispatchProxy requires interface/abstract target |
OnSuccess fires before task completes |
Old OnMethodBoundaryAspect (pre Phase 0-C) |
Update to latest Ums.Shell.Aop — fix already applied |
| Keyed service not found | Logger type not registered | Add builder.AddLogger<MyLogger>() in AddAop() callback |
ArgumentException: Singleton not supported |
Called AddAopProxy<,>(ServiceLifetime.Singleton) |
Use Scoped (default) or Transient |
LoggerAspect.Init: Type should not be null |
Missing Type property on attribute |
Always set Type = typeof(IMyLogger) in the attribute |
Ums.Shell.Aop.Aspects.Logger.Serilog ships five additional types beyond SerilogLogger that form the foundation for production observability-aware logging adapters.
| Type | Kind | Purpose |
|---|---|---|
ExecutionContextSnapshot |
sealed record |
Immutable snapshot of CorrelationId, SessionTrackingId, TraceId, SpanId |
IExecutionContextAccessor |
interface |
Writable port for middleware to set the snapshot; read back by loggers |
ObservabilityHeaders |
static class |
HTTP header name constants: X-Correlation-Id, X-Session-Tracking-Id |
ObservabilityKeys |
static class |
OTel baggage/tag key constants: correlation.id, session.tracking_id |
StructuredAopLoggerBase |
abstract class : ILogger |
Base class for satellite-specific AOP loggers; resolves execution context and infers bounded context from type namespace |
public abstract class StructuredAopLoggerBase : ILogger
{
// Inject IExecutionContextAccessor via constructor
protected StructuredAopLoggerBase(IExecutionContextAccessor accessor);
// Resolve full observability context for current request.
// Priority: IExecutionContextAccessor.Current → Activity.Current baggage → requestId → ""
protected ExecutionContextSnapshot ResolveExecutionContext(string requestId);
// Infer bounded context from type namespace.
// "Ums.Application.Identity.Tenant.Commands.*" → "Identity"
protected static string InferBoundedContext(Type targetType);
// Abstract — implement all six ILogger methods in your subclass
public abstract void OnEntry(IJoinPoint jp, Argument[] args, string requestId);
public abstract void OnExit(IJoinPoint jp, Return ret, string requestId, long duration);
public abstract void OnExit(IJoinPoint jp, string requestId, long duration);
public abstract void OnExit(IJoinPoint jp, Return ret, string requestId);
public abstract void OnExit(IJoinPoint jp, string requestId);
public abstract void OnException(IJoinPoint jp, string requestId, Exception ex);
}
// 1. Application layer — marker interface (no Infrastructure import)
public interface IMyServiceLogger : Ums.Shell.Aop.Aspects.ILogger;
// 2. Infrastructure layer — concrete adapter
public sealed class MyServiceLogger(
ILoggerFactory loggerFactory,
IUserContext userContext,
IExecutionContextAccessor accessor) : StructuredAopLoggerBase(accessor), IMyServiceLogger
{
public override void OnEntry(IJoinPoint jp, Argument[] args, string requestId)
{
var ctx = ResolveExecutionContext(requestId);
var bc = InferBoundedContext(jp.TargetType);
var logger = loggerFactory.CreateLogger(jp.TargetType);
logger.LogInformation(
"→ {BC} {Handler}.{Method} | tenant={Tenant} cid={CorrelationId} sid={SessionId}",
bc, jp.TargetType.Name, jp.MethodInfo.Name,
userContext.TenantId ?? "system",
ctx.CorrelationId, ctx.SessionTrackingId);
}
// ... implement remaining abstract methods
}
// 3. DI registration
services.AddKeyedTransient<Ums.Shell.Aop.Aspects.ILogger, MyServiceLogger>(
typeof(IMyServiceLogger));
// 4. Handler decoration
[LoggerAspect(Type = typeof(IMyServiceLogger), LogDuration = true, LogException = true, LogArguments = [])]
public async Task<Result<MyResponse>> Handle(MyCommand request, CancellationToken ct) { ... }
Use these constants instead of string literals in middleware and tests:
// HTTP header names
context.Response.Headers[ObservabilityHeaders.CorrelationId] = correlationId;
context.Response.Headers[ObservabilityHeaders.SessionTrackingId] = sessionId;
// OTel Activity baggage / tag keys
activity.SetBaggage(ObservabilityKeys.CorrelationId, correlationId);
activity.SetBaggage(ObservabilityKeys.SessionTrackingId, sessionId);
All five types have zero UMS-specific imports and are proposed for Evolith adoption. See CP-08: AOP Logging Decorator.
ObservabilityBootstrapper provides OpenTelemetry tracing infrastructure