Typed request context middleware for ASP.NET Core with AsyncLocal accessor support.
$ dotnet add package TypedRequestContextTyped request context middleware for ASP.NET Core.
Define per-endpoint (or per-route-group) strongly-typed “request context” objects, extract them from claims/headers, and access them anywhere in the request pipeline via an AsyncLocal accessor.
This repo produces two NuGet packages:
TypedRequestContext — core middleware, attributes, accessor, correlation IDTypedRequestContext.Propagation — optional propagation (serialize/deserialize + header provider)In many services you end up needing the same business identifiers everywhere (tenant, user, role, operation id, etc.). Passing them manually through every method is noisy, and extracting them ad-hoc in each endpoint is easy to get wrong.
TypedRequestContext gives you:
IRequestContextAccessor)x-correlation-id when the propagation package is enabledTypedRequestContext: ASP.NET Core on .NET 8+TypedRequestContext.Propagation: .NET 8+dotnet add package TypedRequestContext
Optional propagation support:
dotnet add package TypedRequestContext.Propagation
using TypedRequestContext;
public sealed class CustomerRequestContext : ITypedRequestContext
{
[FromClaim("sub"), RequiredContextValue]
public Guid UserId { get; init; }
[FromClaim("tenant_id"), RequiredContextValue]
public Guid TenantId { get; init; }
[FromClaim("role")]
public string? Role { get; init; }
[FromHeader("x-operation-id")]
public Guid? OperationId { get; init; }
}
public sealed class InternalServiceRequestContext : ITypedRequestContext
{
[FromHeader("x-service-name"), RequiredContextValue]
public string ServiceName { get; init; } = default!;
[FromHeader("x-operation-id")]
public Guid? OperationId { get; init; }
}
Notes:
[FromClaim] and [FromHeader] declare the source.[RequiredContextValue] makes the request fail early when missing.using TypedRequestContext;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddTypedRequestContext()
.AddCorrelationId() // optional
.AddTypedRequestContext<CustomerRequestContext>()
.AddTypedRequestContext<InternalServiceRequestContext>();
var app = builder.Build();
using TypedRequestContext;
app.UseAuthentication();
app.UseAuthorization();
app.UseTypedRequestContext();
Middleware order matters: it must run after auth so claims are available.
using TypedRequestContext;
var api = app.MapGroup("/api")
.WithRequestContext<CustomerRequestContext>();
api.MapGet("/orders", GetOrders);
// Endpoint override: same app, different context shape
api.MapPost("/internal/sync", SyncData)
.WithRequestContext<InternalServiceRequestContext>();
If both group and endpoint specify a context, last wins (endpoint overrides group).
Note: one context type is active per request. Different endpoints/groups can require different types.
In endpoint handlers, resolve the context from DI:
using Microsoft.AspNetCore.Mvc;
static IResult GetOrders([FromServices] CustomerRequestContext ctx)
{
return Results.Ok(new { ctx.TenantId, ctx.UserId, ctx.Role });
}
In deeper services you have two options:
AddTypedRequestContext<T>()):public sealed class BillingService(CustomerRequestContext ctx)
{
public Guid TenantId => ctx.TenantId;
}
IRequestContextAccessor when you need a looser coupling (e.g., interface-based access, optional access, libraries that shouldn’t depend on a specific context type):using TypedRequestContext;
public sealed class AuditService(IRequestContextAccessor accessor)
{
public void Write(string message)
{
var ctx = accessor.GetRequired<CustomerRequestContext>();
// Use ctx.TenantId / ctx.UserId...
}
}
AddTypedRequestContext<TContext>().WithRequestContext<TContext>().IRequestContextExtractor<TContext>)IRequestContextAccessor.Current for the request lifetimeThe default extractor uses cached reflection and the attributes on your context properties.
If a [RequiredContextValue] property is missing:
The response body is JSON:
{ "message": "Required context value 'TenantId' is missing." }
Register once:
builder.Services.AddCorrelationId();
When enabled:
x-correlation-id from the inbound request if presentActivity.Current?.TraceId (when available)x-correlation-idConsume it via ICorrelationContext:
using TypedRequestContext;
public sealed class LoggingService(ICorrelationContext correlation)
{
public string CurrentId => correlation.CorrelationId;
}
Install the package and register propagation services:
using TypedRequestContext;
using TypedRequestContext.Propagation;
builder.Services
.AddTypedRequestContext()
.AddTypedRequestContextPropagation();
What you get:
IPropagationHeadersProvider.GetCurrentHeaders() returns the current typed context serialized as key/value pairs.x-correlation-id so it naturally flows to downstream services.Propagation is opt-in per property using [PropagationKey].
PropagationKeyAttribute lives in the propagation package namespace:
using TypedRequestContext;
using TypedRequestContext.Propagation;
public sealed class CustomerRequestContext : ITypedRequestContext
{
[FromClaim("sub"), RequiredContextValue, PropagationKey("x-user-id")]
public Guid UserId { get; init; }
[FromClaim("tenant_id"), RequiredContextValue, PropagationKey("x-tenant-id")]
public Guid TenantId { get; init; }
[FromClaim("role"), PropagationKey("x-role")]
public string? Role { get; init; }
// No PropagationKey => stays local; not serialized
[FromClaim("email")]
public string? Email { get; init; }
}
At outbound call time, use IPropagationHeadersProvider to get the current headers:
using TypedRequestContext.Propagation;
public sealed class DownstreamClient(
HttpClient http,
IPropagationHeadersProvider headers)
{
public async Task<HttpResponseMessage> CallAsync(CancellationToken ct)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/data");
foreach (var (key, value) in headers.GetCurrentHeaders())
request.Headers.TryAddWithoutValidation(key, value);
return await http.SendAsync(request, ct);
}
}
Because GetCurrentHeaders() includes x-correlation-id (when correlation is enabled), a downstream ASP.NET Core service using AddCorrelationId() will automatically pick it up on inbound requests.
For queues/events/background jobs, you can serialize and carry the same headers dictionary as message metadata.
Producer side (create metadata):
using TypedRequestContext.Propagation;
public sealed class Producer(IPropagationHeadersProvider headers)
{
public IReadOnlyDictionary<string, string> CreateMetadata()
=> headers.GetCurrentHeaders();
}
Consumer side (restore context):
using TypedRequestContext;
using TypedRequestContext.Propagation;
public sealed class Handler(
IRequestContextDeserializer<CustomerRequestContext> deserializer,
IRequestContextAccessor accessor)
{
public async Task HandleAsync(IReadOnlyDictionary<string, string> metadata, CancellationToken ct)
{
var ctx = deserializer.Deserialize(metadata);
accessor.Current = ctx;
try
{
// Your handler/service code can now inject CustomerRequestContext
// or use IRequestContextAccessor.GetRequired<CustomerRequestContext>().
await Task.CompletedTask;
}
finally
{
accessor.Current = null;
}
}
}
The same metadata can include x-correlation-id (from GetCurrentHeaders()). How you apply correlation in a non-HTTP consumer depends on your hosting model and logging setup.
Why would you need a custom extractor?
builder.Services.AddTypedRequestContext<MyContext>(b =>
b.UseExtractor<MyCustomExtractor>());
Your extractor must implement:
using TypedRequestContext;
public interface IRequestContextExtractor<out T>
where T : class, ITypedRequestContext
{
T Extract(HttpContext httpContext);
}
You can override per-context propagation behavior during registration:
builder.Services.AddTypedRequestContext<MyContext>(b =>
b.UseSerializer<MySerializer>()
.UseDeserializer<MyDeserializer>());
My handler can’t resolve CustomerRequestContext from DI
AddTypedRequestContext<CustomerRequestContext>() was called..WithRequestContext<CustomerRequestContext>().Claims are missing
app.UseTypedRequestContext() runs after auth middleware.I get No extractor registered for context type ...
.WithRequestContext<T>() but didn’t register AddTypedRequestContext<T>().Issues and PRs are welcome.
MIT. See LICENSE.