Typed request context middleware for ASP.NET Core.
Define per-endpoint (or per-route-group) strongly-typed "request context" objects, extract them from claims/headers, validate them, and access them anywhere in the request pipeline via an AsyncLocal accessor — including non-HTTP flows like queues and background jobs.
This repo produces two NuGet packages:
TypedRequestContext— core middleware, attributes, accessor, validation, correlation IDTypedRequestContext.Propagation— optional propagation (serialize/deserialize, header provider, non-HTTP context restoration)
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 solves this by letting you:
- Define once, use everywhere — declare a typed model for your request context and access it from any layer without manual plumbing
- Fail fast on bad input — required values are enforced at the edge; validation errors return structured responses before your handler runs
- Support multiple context shapes — different endpoints can require different context types in the same service
- Propagate context across boundaries — carry tenant, user, and correlation data to downstream HTTP calls, queues, and background jobs without ad-hoc serialization
TypedRequestContext: ASP.NET Core on .NET 8+TypedRequestContext.Propagation: .NET 8+
dotnet add package TypedRequestContextOptional propagation support:
dotnet add package TypedRequestContext.Propagationusing 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:
- Inject the typed context directly (it's registered as scoped when you call
AddTypedRequestContext<T>()):
public sealed class BillingService(CustomerRequestContext ctx)
{
public Guid TenantId => ctx.TenantId;
}- Use
IRequestContextAccessorwhen 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...
}
}- You register context types via
AddTypedRequestContext<TContext>(). - You attach the desired context type to endpoints/groups via
WithRequestContext<TContext>(). - For requests hitting such endpoints, the middleware:
- Creates the context via an extractor (
IRequestContextExtractor<TContext>) - Validates it (if validation is enabled)
- Stores it into
IRequestContextAccessor.Currentfor the request lifetime
- Creates the context via an extractor (
The default extractor uses cached reflection and the attributes on your context properties.
If a [RequiredContextValue] property is missing:
- For claims: the middleware returns 401
- For headers: the middleware returns 400
The response body is JSON:
{ "message": "Required context value 'TenantId' is missing." }Enable validation per context type during registration:
builder.Services.AddTypedRequestContext<CustomerRequestContext>(b =>
b.EnableValidation());By default, EnableValidation() uses DataAnnotations (System.ComponentModel.DataAnnotations). You can use any standard validation attributes:
using System.ComponentModel.DataAnnotations;
using TypedRequestContext;
public sealed class OrderContext : ITypedRequestContext
{
[FromHeader("x-order-code"), MaxLength(4)]
public string? Code { get; init; }
[FromHeader("x-quantity"), Range(1, 1000)]
public int Quantity { get; init; }
[FromClaim("email"), RegularExpression(@"^[^@]+@[^@]+\.[^@]+$")]
public string? Email { get; init; }
}The context type can also implement IValidatableObject for cross-property validation:
public sealed class OrderContext : ITypedRequestContext, IValidatableObject
{
[FromHeader("x-min"), RequiredContextValue]
public int Min { get; init; }
[FromHeader("x-max"), RequiredContextValue]
public int Max { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Min > Max)
yield return new ValidationResult("Min must be <= Max", [nameof(Min), nameof(Max)]);
}
}When validation fails, the middleware returns 400 with a structured body grouping errors by property:
{
"message": "Request context validation failed.",
"errors": {
"Code": ["The field Code must be a string with a maximum length of 4."],
"Quantity": ["The field Quantity must be between 1 and 1000."],
"$": ["Object-level validation error message"]
}
}For validation logic beyond DataAnnotations, implement IRequestContextValidator<T>:
using TypedRequestContext;
public sealed class OrderContextValidator : IRequestContextValidator<OrderContext>
{
public IReadOnlyList<RequestContextValidationError> Validate(OrderContext context)
{
var errors = new List<RequestContextValidationError>();
if (context.Code is not null && !IsValidCode(context.Code))
errors.Add(new RequestContextValidationError(nameof(context.Code), "Invalid order code format."));
return errors;
}
}Register it:
builder.Services.AddTypedRequestContext<OrderContext>(b =>
b.UseValidation<OrderContextValidator>());Register once:
builder.Services.AddCorrelationId();When enabled:
- Reads
x-correlation-idfrom the inbound request if present - Otherwise falls back to
Activity.Current?.TraceId(when available) - Otherwise generates a new ID
- Echoes the value back on the response header
x-correlation-id
Consume 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.- If correlation is enabled, the provider also includes
x-correlation-idso 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 non-HTTP consumers, IRequestContextPropagator<T> handles deserialization, validation, and context lifecycle in a single call.
Producer side — serialize current context into message metadata:
using TypedRequestContext.Propagation;
public sealed class OrderProducer(IPropagationHeadersProvider headers)
{
public void Publish(Order order)
{
var metadata = headers.GetCurrentHeaders();
// Attach metadata as message headers / properties
messageBus.Publish(order, metadata);
}
}Consumer side — restore context from metadata:
using TypedRequestContext;
using TypedRequestContext.Propagation;
public sealed class OrderHandler(
IRequestContextPropagator<CustomerRequestContext> propagator,
IRequestContextAccessor accessor)
{
public async Task HandleAsync(
IReadOnlyDictionary<string, string> metadata,
CancellationToken ct)
{
using var _ = propagator.Propagate(metadata);
// Context is now set — use it via DI or accessor
var ctx = accessor.GetRequired<CustomerRequestContext>();
await ProcessOrderAsync(ctx.TenantId, ctx.UserId, ct);
// Context is automatically cleared when the scope is disposed
}
}Propagate() will:
- Deserialize the metadata into the typed context using
IRequestContextDeserializer<T> - Validate the context (if validation is enabled for this type)
- Set it on
IRequestContextAccessor - Return a disposable scope that clears the context on disposal
If validation is enabled and the validator has scoped dependencies, pass a scoped IServiceProvider:
using var scope = serviceProvider.CreateScope();
using var _ = propagator.Propagate(metadata, scope.ServiceProvider);Why would you need a custom extractor?
- Your context values come from route values, query string, cookies, or a custom header format.
- You need custom validation / normalization (e.g., accept multiple claim names, parse composite identifiers).
- You want a different error behavior (still returning 401/403, but with different rules).
- You want to avoid reflection entirely for a hot path and build the context manually.
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>());Serializer interface:
public interface IRequestContextSerializer<in T> where T : class, ITypedRequestContext
{
IReadOnlyDictionary<string, string> Serialize(T context);
}Deserializer interface:
public interface IRequestContextDeserializer<out T> where T : class, ITypedRequestContext
{
T Deserialize(IReadOnlyDictionary<string, string> metadata);
}-
My handler can't resolve
CustomerRequestContextfrom DI- Ensure
AddTypedRequestContext<CustomerRequestContext>()was called. - Ensure the endpoint/group has
.WithRequestContext<CustomerRequestContext>().
- Ensure
-
Claims are missing
- Ensure
app.UseTypedRequestContext()runs after auth middleware.
- Ensure
-
I get
No extractor registered for context type ...- You attached
.WithRequestContext<T>()but didn't registerAddTypedRequestContext<T>().
- You attached
-
Validation errors return 400 but I expected a different status
- Validation failures always return 400. For missing required values (pre-validation), claims return 401 and headers return 400.
-
Non-HTTP consumer throws
RequestContextDeserializationException- A required metadata key is missing or has an invalid format. Check that the producer serialized all
[PropagationKey]properties.
- A required metadata key is missing or has an invalid format. Check that the producer serialized all
Issues and PRs are welcome.
MIT. See LICENSE.