Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/thirteen-lines-are-the-whole-pipeline && read --section="top" 0%
.NET

Thirteen Lines Are the Whole Pipeline

Ntrada's request pipeline is a gate and a dictionary lookup - and four extension hooks around it, one of which lost the thing it existed to see when a disposal bug was fixed.

By Kumar Chandrachooda 14 Feb 2026 7 min read
A deliberately short pipeline of two gates with four small hooks branching off it

Ask a room of .NET developers to describe a gateway's architecture and most will draw a chain: authentication, then rate limiting, then transformation, then forwarding, each component holding a reference to next and deciding whether to call it. It is the shape ASP.NET Core middleware teaches, it is the shape Ocelot uses, and it invites a particular class of bug — a component that forgets to call next, or calls it after writing to the response, or runs in the wrong position because registration order is invisible at the point of use.

Part 3 left us at the moment a request matches an endpoint. What happens next in Ntrada is not a chain. It is thirteen lines.

Gate, then dispatch

private async Task Handle(HttpContext context, RouteConfig routeConfig)
{
    var skipAuth = _options.Auth is null ||
                   !_options.Auth.Global && routeConfig.Route.Auth is null ||
                   routeConfig.Route.Auth == false;
    if (!skipAuth &&
        !await _requestExecutionValidator.TryExecuteAsync(context, routeConfig))
    {
        return;
    }

    var handler = routeConfig.Route.Use;
    await _requestHandlerManager.HandleAsync(handler, context, routeConfig);
}

RouteProvider.cs:49-62

That is the whole thing. Two steps: decide whether this request is allowed, then look up a handler by the string the YAML author wrote in use: and call it.

There is no next. Ntrada contributes exactly one terminal node to ASP.NET Core's pipeline; everything that would be middleware in another gateway is either the framework's own middleware, an extension registering its own via IExtension.Use, or one of the four hook points below. That is a real architectural position and it has a real payoff: there is no ordering configuration to get wrong, no filter list, and no component that can accidentally swallow a request by not calling through.

routeConfig was captured at boot. The closure that reached this method already knows which route matched — no second lookup, no path re-parsing, no dictionary hit on the hot path.

The skipAuth expression leans on operator precedence. && binds tighter than ||, so it reads as: skip if there is no auth section at all; or if global auth is off and this route says nothing; or if the route explicitly says auth: false. It is correct, it is dense, and — as part 8 shows — it is one of three copies of the same idea that disagree about what auth.enabled means.

route.Use is a string, and the registry is a static dictionary. RequestHandlerManager holds a ConcurrentDictionary<string, IHandler> populated at startup with "downstream" and "return_value", plus whatever the extensions add — "rabbitmq" being the one that makes use: rabbitmq read as English. An unknown name throws at request time, but there is a boot-time guard too: RegisterRequestHandlers collects every distinct use: value across every route and throws Handler: 'x' was not defined before the route table is built. That check is one of only three boot-time validations in the entire product, and it is the right one to have.

There is a wrinkle in the registry worth naming. AddHandler uses TryAdd, and logs an error when it fails:

public void AddHandler(string name, IHandler handler)
{
    if (Handlers.TryAdd(name, handler))
    {
        _logger.LogInformation($"Added a request handler: '{name}'");
        return;
    }

    _logger.LogError($"Couldn't add a request handler: '{name}'");
}

RequestHandlerManager.cs:23-32

The public UseRequestHandler<T>(app, name) extension is the documented way to plug in your own handler, and it goes through this method. So a custom handler registered under "downstream" — the obvious way to try to replace the built-in forwarding behaviour — silently does not take effect. It logs an error and the request path is unchanged. Failing by logging is a recurring strategy in this codebase, and it is the strategy that makes the next three parts of this series possible.

Four hooks, and what each can see

Because the pipeline is a single node, extension points cannot be middleware; they have to be callbacks the handler invokes. Ntrada declares four, all in Ntrada.Hooks, all resolved as IEnumerable<T> in the handler's constructor:

# Hook Invoked at Sees
1 IRequestHook DownstreamHandler.cs:63-74 the inbound HttpRequest and a fully built ExecutionData
2 IHttpRequestHook DownstreamHandler.cs:180-191 the outbound HttpRequestMessage — URI, method, headers
3 IHttpResponseHook DownstreamHandler.cs:247-258 the raw downstream HttpResponseMessage
4 IResponseHook DownstreamHandler.cs:260-271 the outbound HttpResponse, before status and body are written

ReturnValueHandler runs only #1 and #4 before writing its literal, which makes those two the portable hooks and the IHttp* pair downstream-only. That distinction is correct and undocumented.

Hook #1 is the interesting one for anyone writing an audit trail, because by the time it runs, ExecutionData is complete: generated ids, the caller's claims dictionary, the transformed payload, and the resolved downstream URL. It also runs after the request body has been read to the end by PayloadBuilder and before the payload-validity check at line 76 — so a request hook sees invalid payloads too, which is usually what you want from an audit hook and never what you want from an enrichment one.

The hook that a bug fix blinded

Hook #2 exists to let you modify the outbound HTTP request. Sign it, add a service-to-service token, rewrite a header. Here is the code around it at HEAD:

if (_httpRequestHooks is {})
{
    foreach (var hook in _httpRequestHooks)
    {
        if (hook is null)
        {
            continue;
        }

        await hook.InvokeAsync(request, executionData);
    }
}

if (!includeBody)
{
    return await httpClient.SendAsync(request);
}

using var content = GetHttpContent(executionData);
request.Content = content;
return await httpClient.SendAsync(request);

DownstreamHandler.cs:180-200

request.Content is assigned two statements after the last hook has run. An IHttpRequestHook on a POST route sees a request message with a null body. It cannot inspect the payload, cannot compute a content hash, cannot sign the body — which is most of the reason you would write one.

It did not always. Before commit 054b0d8 Fixed stream content dispose, the order was the other way round:

if (includeBody)
{
    request.Content = GetHttpContent(executionData);
}

if (_httpRequestHooks is {})
{
    // ... hooks ran here, with request.Content populated
}

The commit that reversed it was fixing a genuine and nasty defect. An earlier refactor (45fc7e7) had made SendRequestAsync return a Func<Task<HttpResponseMessage>> that the caller invoked on the very next line — a deferral that deferred nothing, but which separated content construction from content use, which in turn made a using var httpContent inside GetHttpContent look locally safe. It was not: it disposed the request body before the request was sent. 054b0d8 un-deferred the call and moved the using out to the caller, which is the correct fix for the lifetime bug, and it had to move the content assignment below the hooks to do it.

Nothing in the commit message, the interface signature or the README records that a public extension point lost its most important capability that day. This is the sharpest small lesson in the whole series: a hook's contract is not just its signature, it is its position, and position is not expressible in C#. A test that asserted request.Content is not null inside a fake IHttpRequestHook would have caught it. DownstreamHandler has no test class at all.

To be fair, the fix was correct and the regression was invisible: there are no IHttpRequestHook implementations in the repository — the RabbitMQ extension uses hook #1 — so nothing in the estate could have failed.

The same using has a second consequence. GetHttpContent returns a shared static for the empty case:

private static readonly HttpContent EmptyContent =
    new StringContent("{}", Encoding.UTF8, ContentTypeApplicationJson);

DownstreamHandler.cs:26-27

When a POST route has transformations enabled but a null payload or a non-JSON content type, GetHttpContent returns that shared instance, and using var content disposes it. The second request down that path gets an ObjectDisposedException. A using on a value you did not construct is a lifetime claim you are not entitled to make, and it is the kind of thing that only shows up on the second request — which is to say, never in a manual smoke test.

What has no protection at all

Three properties of the hook mechanism worth writing down before you build on it.

  • No isolation. There is no try/catch around any of the four loops. A throwing hook propagates out of the endpoint delegate; without the customErrors extension that is a bare 500. Fail-fast is defensible, but hook #4 runs before the body is written, so a hook that throws there leaves a response with headers set and no body.
  • Six copies of an impossible null check. if (hook is null) continue; appears at DownstreamHandler.cs:67, 185, 252, 265 and ReturnValueHandler.cs:33, 46. GetServices<T>() cannot yield nulls unless somebody registers one.
  • Hooks are resolved once, from the root provider. The handlers are singletons and take IServiceProvider in their constructors. A hook registered AddScoped is either silently promoted to a de-facto singleton or, under the development host's scope validation, crashes UseNtrada. Your hook cannot hold per-request state and must be thread-safe. Nothing says so — and part 11 is where that decision gets its full accounting.

Next, the payload template that could never load — a feature that fails by doing nothing, and the second bug it was hiding.