Tipo: Patrón Canónico
Estado: Aceptado
Disposición Evolith: Propuesto para Evolith — sin dependencias específicas del producto
ADR relacionado: ADR-0061: Execution Context Accessor
Los command handlers, aspectos AOP y servicios de background necesitan acceso a las señales de observabilidad con scope de request (CorrelationId, SessionTrackingId, TraceId, SpanId) sin acoplarse a IHttpContextAccessor ni a Activity.Current.
Un RequestContextAccessor con scope es escrito una vez por el middleware y leído por cualquier componente en el mismo scope de request a través de un puerto de solo lectura IRequestContext (Aplicación) o un puerto de escritura IExecutionContextAccessor (Infraestructura/AOP).
HTTP Request
│
▼
CorrelationIdMiddleware escribe baggage Activity + scope ILogger
│
▼
SessionTrackingMiddleware escribe baggage Activity + llama RequestContextAccessor.Set()
│
▼
RequestContextAccessor (scoped) ← fuente única de verdad para el scope del request
│
├── IRequestContext solo lectura (capa de Aplicación)
└── IExecutionContextAccessor lectura + escritura (Infraestructura / AOP)
BeyondNetCode.Shell.Logger.Serilog)// Snapshot inmutable — escrito una vez por request
public sealed record ExecutionContextSnapshot(
string CorrelationId,
string SessionTrackingId,
string TraceId,
string SpanId)
{
public static readonly ExecutionContextSnapshot Empty = new("", "", "", "");
}
// Puerto de escritura (solo Infraestructura / middleware)
public interface IExecutionContextAccessor
{
ExecutionContextSnapshot Current { get; }
void Set(ExecutionContextSnapshot snapshot);
}
// Constantes de nombres de headers HTTP
public static class ObservabilityHeaders
{
public const string CorrelationId = "X-Correlation-Id";
public const string SessionTrackingId = "X-Session-Tracking-Id";
}
// Constantes de claves baggage / tag OTel
public static class ObservabilityKeys
{
public const string CorrelationId = "correlation.id";
public const string SessionTrackingId = "session.tracking_id";
}
// Puerto de solo lectura — sin dependencia HTTP
public interface IRequestContext
{
string? CorrelationId { get; }
string? SessionTrackingId { get; }
string? TraceId { get; }
string? SpanId { get; }
}
// Una clase implementa ambos puertos — registrada como ambos en DI
public sealed class RequestContextAccessor : IRequestContext, IExecutionContextAccessor
{
private ExecutionContextSnapshot _current = ExecutionContextSnapshot.Empty;
public string? CorrelationId => _current.CorrelationId.NullIfEmpty();
public string? SessionTrackingId => _current.SessionTrackingId.NullIfEmpty();
public string? TraceId => _current.TraceId.NullIfEmpty();
public string? SpanId => _current.SpanId.NullIfEmpty();
public ExecutionContextSnapshot Current => _current;
public void Set(ExecutionContextSnapshot snapshot) =>
_current = snapshot ?? ExecutionContextSnapshot.Empty;
}
services.AddScoped<RequestContextAccessor>();
services.AddScoped<IRequestContext>(sp =>
sp.GetRequiredService<RequestContextAccessor>());
services.AddScoped<IExecutionContextAccessor>(sp =>
sp.GetRequiredService<RequestContextAccessor>());
public async Task InvokeAsync(HttpContext context, RequestContextAccessor accessor)
{
var sessionTrackingId = GetOrGenerate(context, ObservabilityHeaders.SessionTrackingId);
Activity.Current?.SetBaggage(ObservabilityKeys.SessionTrackingId, sessionTrackingId);
Activity.Current?.SetTag(ObservabilityKeys.SessionTrackingId, sessionTrackingId);
accessor.Set(new ExecutionContextSnapshot(
CorrelationId: Activity.Current?.GetBaggageItem(ObservabilityKeys.CorrelationId)
?? context.TraceIdentifier ?? string.Empty,
SessionTrackingId: sessionTrackingId,
TraceId: Activity.Current?.TraceId.ToString() ?? string.Empty,
SpanId: Activity.Current?.SpanId.ToString() ?? string.Empty));
context.Response.Headers[ObservabilityHeaders.SessionTrackingId] = sessionTrackingId;
using (_logger.BeginScope(new Dictionary<string, object> { ["SessionTrackingId"] = sessionTrackingId }))
await _next(context);
}
protected ExecutionContextSnapshot ResolveExecutionContext(string requestId)
{
var current = _accessor.Current ?? ExecutionContextSnapshot.Empty;
var activity = Activity.Current;
return new ExecutionContextSnapshot(
CorrelationId: current.CorrelationId.FirstNonEmpty(
activity?.GetBaggageItem(ObservabilityKeys.CorrelationId),
requestId),
SessionTrackingId: current.SessionTrackingId.FirstNonEmpty(
activity?.GetBaggageItem(ObservabilityKeys.SessionTrackingId)),
TraceId: current.TraceId.FirstNonEmpty(activity?.TraceId.ToString()),
SpanId: current.SpanId.FirstNonEmpty(activity?.SpanId.ToString()));
}
| Capa | Interfaz | Operación |
|---|---|---|
Domain |
— | No necesita contexto |
Application |
IRequestContext |
Solo lectura |
Infrastructure / AOP |
IExecutionContextAccessor |
Lectura + fallback a Activity |
Presentation / Middleware |
RequestContextAccessor directamente |
Escritura (middleware), Lectura (endpoints) |
Cuando un comando lanza un job de background o hace handoff a un dispatcher de outbox, pasar el snapshot explícitamente:
// En el handler — capturar antes del handoff
var snapshot = _requestContext.ToSnapshot(); // o leer campos directamente
// En el constructor del servicio de background / factory
public OutboxDispatcherJob(ExecutionContextSnapshot originatingContext) { ... }