Part of: Shell Libraries Projects:
BeyondNetCode.Shell.Aop·BeyondNetCode.Shell.DispatchProxy·BeyondNetCode.Shell.Aspects·BeyondNetCode.Shell.Logger.Serilog·BeyondNetCode.Shell.DIDependencies:Microsoft.Extensions.DependencyInjection·Serilog(optional) ·System.Linq.Dynamic.Core
BeyondNetCode.Shell.Aop providesnon-invasive aspect-oriented programmingvia 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.
- Architecture Overview
- Project Structure
- Standalone Usage — no DI
- DI Usage with
AddAop()+AddAopProxy() - Built-in Aspects
- Writing a Custom Aspect
- Async Support
- PII-Safe Logging — MelLogger
- API Reference
- UMS Integration
- Aspect Ordering Convention
- Troubleshooting
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:
- Attribute-driven selection:
PointCut.CanApplychecks for the attribute type inaspect.BaseType.GetGenericArguments(). An aspect only fires if the target method carries its corresponding attribute. - Ordered chain: aspects are sorted by
GetOrder(joinPoint)— earlier order numbers run first, outer-most in the call stack. - Async-aware (since Phase 0-C fix):
OnMethodBoundaryAspect.ApplydetectsTask/Task<T>returns and defersOnSuccess/OnExit/OnExceptionto a continuation task.
BeyondNetCode.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
BeyondNetCode.Shell.DispatchProxy/
├── AopProxy.cs ← System.Reflection.DispatchProxy subclass
└── AopProxyCreator.cs ← static Create<TService,TImpl>(target, executor)
BeyondNetCode.Shell.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)
BeyondNetCode.Shell.Logger.Serilog/
└── SerilogLogger.cs ← ILogger (AOP) backed by Serilog static Log.*
BeyondNetCode.Shell.DI/
├── 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 BeyondNetCode.Shell.Aop;
using BeyondNetCode.Shell.Aspects;
using BeyondNetCode.Shell.DispatchProxy;
using BeyondNetCode.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 == 7AddAop() 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:
TImplas itself (concrete handler).TServiceas a factory that creates aDispatchProxywrappingTImpl.
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 BeyondNetCode.Shell.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 Handle(ActivateTenantCommand request, CancellationToken ct) { ... } |
### 5.2 AdviceAspect
`OnMethodBoundaryAspect<AdviceAspectAttribute>` — delegates to a registered `IAdvice` for flexible cross-cutting logic.
```csharp
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:
- Calls
joinPoint.Proceed()which stores the raw return value injoinPoint.Return. - If
joinPoint.Returnis aTask: wraps it in a new continuation task (WrapAsync/WrapAsyncOfT<T>). - Stores the wrapper task back in
joinPoint.Return;OnSuccess/OnException/OnExitfire inside the continuation afterConfigureAwait(false). AopProxy.InvokereturnsjoinPoint.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:
- Resolves a per-call
ILoggerfromILoggerFactoryusingjp.TargetTypeas the category name. - Never logs argument values — only parameter names and CLR types.
- Registered as a keyed service under
typeof(IMelLogger). - Use
[LoggerAspect(LogArguments = [])](empty array) to log only entry/exit metadata. - Use
[LoggerAspect(LogArguments = ["request"])]to include parameter name + type (not value) ofrequest.
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 |
Registers into DI:
LoggerAspect,AdviceAspect,RetryAspectas keyed transientIAspectservices.Adviceas keyed transientIAdvice.IPointCut(singletonPointCut).IAspectExecutor(transientAspectExecutor).IFactory<IAdvice>andIFactory<ILogger>(transient factories backed by keyed-service resolution).IEvaluator(singletonEvaluatorusing System.Linq.Dynamic).
| 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 |
| 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) |
| 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 |
| 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 BeyondNetCode.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 |
BeyondNetCode.Shell.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 : BeyondNetCode.Shell.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<BeyondNetCode.Shell.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 havezero UMS-specific importsand are proposed for Evolith adoption. See CP-08: AOP Logging Decorator.
- DDD — aggregates whose command handlers are wrapped with AOP
- Factory — factory-resolved services can also be wrapped
- Bootstrapper —
ObservabilityBootstrapperprovides OpenTelemetry tracing infrastructure - Combined Usage — all four libraries working together
- CP-05: Execution Context Propagation
- CP-08: AOP Logging Decorator
- ADR-0061: Execution Context Accessor