DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

One Endpoint Filter, Six Fewer Guard Clauses

I was adding a third route to a tenant-scoped group last week and caught myself typing six lines I'd already typed twice that afternoon. Read the X-Tenant-Id header, bail with a 400 if it's missing or obviously junk, otherwise carry on. Three handlers, three copies of the same guard. The fourth route I was about to add would've made four.

That's the kind of duplication that costs nothing until it costs a lot. The lines compile. The tests pass. Then some Tuesday a teammate adds a route to the group, doesn't notice the unwritten rule that every handler here checks the tenant first, and now one endpoint serves data with no tenant scope at all. Nothing throws. No red in the logs. It just quietly skips the check that every sibling route performs.

Here's roughly where my group had landed. I'll show two of the handlers; imagine the guard repeated across all of them.

var manual = app.MapGroup("/manual");

manual.MapGet("/orders", (HttpContext http) =>
{
    if (!TryGetTenant(http, out var tenant))
        return Results.Problem("Missing or malformed X-Tenant-Id header.", statusCode: 400);

    return Results.Ok(catalog.ForTenant(tenant));
});

manual.MapGet("/orders/count", (HttpContext http) =>
{
    if (!TryGetTenant(http, out var tenant))
        return Results.Problem("Missing or malformed X-Tenant-Id header.", statusCode: 400);

    return Results.Ok(new { tenant, count = catalog.ForTenant(tenant).Count });
});
Enter fullscreen mode Exit fullscreen mode

The actual work in each handler is one line. The guard is two, plus a helper method off to the side, plus the discipline to remember it. The signal-to-ceremony ratio is upside down, and the thing holding it together is me not forgetting.

Moving the check to where the group is defined

Minimal APIs have had endpoint filters since .NET 7, and this is exactly what they're for. A filter is a small piece of code that runs around the handler. It can look at the request, decide to short-circuit with its own result, or call the next thing in the chain and let the handler run. You attach it to a single endpoint or, more usefully here, to a whole group.

sealed class RequireTenantFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var http = context.HttpContext;
        if (!http.Request.Headers.TryGetValue("X-Tenant-Id", out var raw))
            return Results.Problem("Missing X-Tenant-Id header.", statusCode: 400);

        var tenant = raw.ToString();
        if (string.IsNullOrWhiteSpace(tenant) || tenant.Length > 40)
            return Results.Problem("Malformed X-Tenant-Id header.", statusCode: 400);

        http.Items["tenant"] = tenant;
        return await next(context);
    }
}
Enter fullscreen mode Exit fullscreen mode

Returning a result short-circuits; the handler never runs. Calling next(context) lets it through. And because the filter already parsed the header, it stashes the validated value in HttpContext.Items so the handlers don't have to pull it apart a second time.

Now the group. One line wires the filter to every route under it.

var orders = app.MapGroup("/orders")
                .AddEndpointFilter<RequireTenantFilter>();

orders.MapGet("/", (HttpContext http) =>
    Results.Ok(catalog.ForTenant(Tenant(http))));

orders.MapGet("/count", (HttpContext http) =>
    Results.Ok(new { tenant = Tenant(http), count = catalog.ForTenant(Tenant(http)).Count }));

static string Tenant(HttpContext http) => (string)http.Items["tenant"]!;
Enter fullscreen mode Exit fullscreen mode

The handlers are back to doing one thing. More to the point, the next person who adds a route to this group gets the tenant check for free. They can't forget it, because they never wrote it. It lives on the MapGroup call, in plain sight, instead of being re-implemented at every leaf.

Proving callers can't tell the difference

The part I wanted to be sure about: does moving the guard change anything a client sees? A filter runs earlier in the pipeline than the handler body, so I wanted to confirm the status codes and bodies came out the same. I kept both groups in the app and hit them in-process with an HttpClient, header present and absent, and printed what came back.

== manual group (guard copy-pasted into each handler) ==
GET /manual/orders        (no header)        -> 400 {"type":"https://tools.ietf.org/...
GET /manual/orders        (acme)             -> 200 [{"id":1,"tenant":"acme",...
GET /manual/orders/count  (acme)             -> 200 {"tenant":"acme","count":3}

== filtered group (one AddEndpointFilter) ==
GET /orders               (no header)        -> 400 {"type":"https://tools.ietf.org/...
GET /orders               (blank header)     -> 400 {"type":"https://tools.ietf.org/...
GET /orders               (acme)             -> 200 [{"id":1,"tenant":"acme",...
GET /orders/count         (acme)             -> 200 {"tenant":"acme","count":3}
GET /orders               (globex)           -> 200 [{"id":4,"tenant":"globex",...
Enter fullscreen mode Exit fullscreen mode

Same 400 for the missing header, same 400 for a blank one, same 200s with the same payloads. From the outside the two groups are indistinguishable. The only thing that changed is which code is responsible for the guard, and how many times it's written down.

When I'd leave it alone

If the check applied to every route in the whole app, I wouldn't reach for a filter — I'd write middleware and register it once. Middleware sits in front of routing and covers everything by default, which is the right default when the rule really is global. Endpoint filters earn their keep in the in-between case: a rule that applies to this group of routes but not the ones next door, or one that needs to see endpoint metadata or the already-bound arguments. Tenant scoping on one slice of the API is squarely that case.

One honest wrinkle: filters run in the order you add them, and that order matters once you have more than one. A filter that reads a value a later filter sets won't find it. It's not hard, but it's a thing to keep straight, and stacking five filters on a group can get as murky as the duplication you started with. I try to keep it to one or two with an obvious reason to exist.

My take, for what it's worth: the win here isn't lines saved. It's that the rule now has one home, sitting on the group definition where you'd look for it, instead of being an agreement spread across handlers that a newcomer has to infer. Deleting the duplication is nice. Making it impossible to forget is the actual point.

Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/115-endpoint-filter-tenant

Do you lean on endpoint filters for this kind of cross-cutting check, or reach for middleware every time? I'm curious where other people draw the line.

— still moving guard clauses to places nobody asked me to move them

Top comments (0)