Skip to content

Zio-Net/TypedRequestContext

Repository files navigation

TypedRequestContext

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 ID
  • TypedRequestContext.Propagation — optional propagation (serialize/deserialize, header provider, non-HTTP context restoration)

Why

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

Requirements

  • TypedRequestContext: ASP.NET Core on .NET 8+
  • TypedRequestContext.Propagation: .NET 8+

Install

dotnet add package TypedRequestContext

Optional propagation support:

dotnet add package TypedRequestContext.Propagation

Quick start (Minimal APIs)

1) Define one (or more) context types

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.

2) Register services

using TypedRequestContext;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddTypedRequestContext()
    .AddCorrelationId() // optional
    .AddTypedRequestContext<CustomerRequestContext>()
    .AddTypedRequestContext<InternalServiceRequestContext>();

var app = builder.Build();

3) Add middleware

using TypedRequestContext;

app.UseAuthentication();
app.UseAuthorization();

app.UseTypedRequestContext();

Middleware order matters: it must run after auth so claims are available.

4) Declare the context required by an endpoint (or group)

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.

5) Consume the context

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:

  1. 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;
}
  1. Use 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...
    }
}

How extraction works

  • 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.Current for the request lifetime

The default extractor uses cached reflection and the attributes on your context properties.

Missing required values

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." }

Validation

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)]);
    }
}

Validation error response

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"]
  }
}

Custom validators

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>());

Correlation ID (optional)

Register once:

builder.Services.AddCorrelationId();

When enabled:

  • Reads x-correlation-id from 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;
}

Propagation (optional package)

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-id so it naturally flows to downstream services.

Mark propagated values

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; }
}

Outbound HTTP calls

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.

Non-HTTP flows: queues, events, background jobs

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:

  1. Deserialize the metadata into the typed context using IRequestContextDeserializer<T>
  2. Validate the context (if validation is enabled for this type)
  3. Set it on IRequestContextAccessor
  4. 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);

Customization

Custom extractor per context type

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);
}

Custom serializer / deserializer (propagation)

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);
}

Troubleshooting

  • My handler can't resolve CustomerRequestContext from DI

    • Ensure AddTypedRequestContext<CustomerRequestContext>() was called.
    • Ensure the endpoint/group has .WithRequestContext<CustomerRequestContext>().
  • Claims are missing

    • Ensure app.UseTypedRequestContext() runs after auth middleware.
  • I get No extractor registered for context type ...

    • You attached .WithRequestContext<T>() but didn't register AddTypedRequestContext<T>().
  • 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.

Contributing

Issues and PRs are welcome.

License

MIT. See LICENSE.

About

A lightweight, attribute-driven framework for strongly-typed request context with built-in propagation in ASP.NET Core.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages