Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Middleware

Middleware has the same shape as ASP.NET Core’s: do work, call next or don’t, do more work.

app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
await next(ctx);
logger.LogInformation("{Path} -> {Status} in {Ms}ms",
ctx.Request.Path, ctx.Response.StatusCode, sw.ElapsedMilliseconds);
});

Not calling next short-circuits everything below it, including routing.

Once middleware has dependencies, its own tests, or more than a screenful of code, it wants to be a type:

public sealed class ApiKeyMiddleware(IKeyStore keys) : IHttpMiddleware
{
public async ValueTask InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!keys.IsValid(context.Request.Headers.GetFirst("X-Api-Key")))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
await next(context);
}
}

Two ways to add it:

builder.Services.AddSingleton<ApiKeyMiddleware>();
app.Use<ApiKeyMiddleware>(); // resolved per request, from the request's own scope
app.Use(new ApiKeyMiddleware(store)); // an instance you already have

Use<T>() resolves from the request scope, so a middleware registered Scoped gets the same instances as everything else handling that request, and one registered Singleton costs a dictionary lookup. Constructing it any other way would mean reflection, which is the one thing this server does not do.

Use(IHttpMiddleware) uses the same instance for every request, so it must be thread-safe and hold no per-request state.

Middleware runs in registration order, wrapping routing and the terminal handler:

Use(A) ─┐
Use(B) ─┐
routing ─┐
UseAfterRouting(C) ─┐
endpoint handler

The pipeline is composed once, the first time the server starts or serves a connection. Registering middleware after that throws — and RestartAsync does not recompose it. Routes, by contrast, can change at any time.

RequestDelegate returns ValueTask because most handlers complete synchronously and should not allocate a Task to say so.

UseAfterRouting runs after the router has chosen an endpoint, wrapping only the endpoint’s own invocation. The difference is ctx.Endpoint: there it is populated, so the middleware can read the endpoint’s metadata and decide accordingly.

app.UseAfterRouting(async (ctx, next) =>
{
if (ctx.Endpoint?.GetMetadata<AuditMetadata>() is { } audit)
await auditLog.RecordAsync(audit.Name, ctx.User);
await next(ctx);
});

That is what authorization needs: [Authorize] is a property of the endpoint, and there is no endpoint before routing has run. Requests that matched nothing skip this stage entirely and go straight to the 404 or 405.

For an app using most of the built-ins, this is the order that is actually correct:

app.Use<RequestTimingMiddleware>(); // outermost: sees everything, including rejections
app.UseCors(); // a preflight carries no credentials — authenticating it would 401 the
// browser's question and the real request would never be sent
app.UseRateLimiter(); // before routing, so a throttled request costs nothing beyond parsing
app.UseIpFilter();
app.UseResponseCompression();
app.UseAuthentication(); // before routing — identity does not depend on the endpoint
app.UseAuthorization(); // after routing — what is required is metadata on the endpoint
app.UseSessions();
app.UseStaticFiles(); // falls through to routing when no file matches

UseAuthorization() registers itself as after-routing middleware for you; the rest are ordinary Use.

Headers are flushed on the first body write, and mutating them afterwards throws. Middleware that wants to add a header around a handler registers a callback instead of setting it after next:

app.Use((ctx, next) =>
{
ctx.Response.OnStarting(() =>
{
ctx.Response.Headers["X-Served-By"] = "Shiny";
return ValueTask.CompletedTask;
});
return next(ctx);
});

OnStarting runs immediately before the status line and headers go to the wire — the last chance to change either. ctx.Response.HasStarted tells you whether that moment has already passed.

Middleware sits between the client and the handler, so it is the only place that can see both bodies of an exchange — which is what a traffic recorder, a request logger or an audit trail is made of. Each direction has its own seam.

Inbound, Request.Body is a forward-only stream off the connection, so reading it consumes it. Assigning a rewound copy back is what makes it readable twice — once by you, then again by the handler, which never knows the difference. Assigning also drops any BodyReader already handed out, so the stream and the reader cannot disagree about where the body starts.

app.Use(async (ctx, next) =>
{
if (ctx.Request.HasBody)
{
var buffered = new MemoryStream();
await ctx.Request.Body.CopyToAsync(buffered, ctx.RequestAborted);
buffered.Position = 0;
Log(buffered.ToArray());
ctx.Request.Body = buffered; // handed on rewound
}
await next(ctx);
});

Outbound, every write funnels through the response’s IResponseBodyControl, whichever of Body, BodyWriter or the convenience helpers the handler reached for. Wrap the control the response is currently bound to and bind the wrapper, and you see all of them:

sealed class TeeBodyControl(IResponseBodyControl inner, Stream capture) : IResponseBodyControl
{
Stream? stream;
PipeWriter? writer;
public bool HasStarted => inner.HasStarted;
public Stream Stream => this.stream ??= new TeeStream(inner.Stream, capture);
// built over Stream, not over inner.Writer, so both write paths meet in one place
public PipeWriter Writer => this.writer ??= PipeWriter.Create(
this.Stream,
new StreamPipeWriterOptions(leaveOpen: true)
);
public ValueTask StartAsync(CancellationToken ct) => inner.StartAsync(ct);
public ValueTask CompleteAsync(CancellationToken ct) => inner.CompleteAsync(ct);
public ValueTask FlushAsync() => this.writer?.FlushAsync().AsValueTask() ?? default;
}
app.Use(async (ctx, next) =>
{
var capture = new MemoryStream();
var tee = new TeeBodyControl(ctx.Response.BodyControl, capture);
ctx.Response.Bind(tee);
try
{
await next(ctx);
}
finally
{
await tee.FlushAsync();
Log(ctx.Response.StatusCode, capture.ToArray());
}
});

This is the same seam response compression inserts itself through.

ctx.Items is a scratch dictionary allocated on first use, for passing state between middleware in one request. Anything with a lifetime beyond the request belongs in a session or a singleton service.