Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-route-table-is-a-compiler-output && read --section="top" 0%
.NET

The Route Table Is a Compiler Output

Ntrada writes no middleware - it compiles YAML into ASP.NET Core endpoints and lets the framework's matcher do the work. Then you try to declare a PATCH route.

By Kumar Chandrachooda 13 Feb 2026 7 min read
Declarative rows on the left compiling into a branching decision tree on the right

Most hand-rolled gateways route with a loop. A middleware component walks a list of rules, tests each one against the incoming path with a regex or a StartsWith, and forwards on the first hit. It works, it is easy to reason about, and it is O(n) in the number of routes on every single request — which is fine at ten routes and embarrassing at four hundred. Worse, it means the gateway now owns a matcher, and matchers are where the CVEs live.

Part 1 claimed that Ntrada writes no middleware of its own. This part is the evidence. The gateway's entire routing contribution is a translation pass: it reads the YAML, emits one ASP.NET Core endpoint per route-and-verb pair, and then gets out of the way. Ntrada is not a router; it is a compiler whose target language is IEndpointRouteBuilder.

Two lines at the bottom of boot

Everything routing-related converges on the last two statements of a private extension method:

var routeProvider = app.ApplicationServices.GetRequiredService<IRouteProvider>();
app.UseRouting();
app.UseEndpoints(routeProvider.Build());

NtradaExtensions.cs:264-266

IRouteProvider.Build() returns an Action<IEndpointRouteBuilder>, which is precisely the delegate UseEndpoints wants. That is the whole integration surface. There is no app.Use(async (ctx, next) => ...) anywhere in the core library; there is no RouterMiddleware subclass; there is nothing between Kestrel and the endpoint delegate that Ntrada wrote.

The consequence is that Ntrada inherits, for free, the routing implementation that shipped with ASP.NET Core 3.0: templates compiled into a DfaMatcher, a state machine built once at startup that matches a path in time proportional to the path's length rather than the table's size. Route constraints, optional segments, catch-alls, precedence between literal and parameter segments — all of it works, because none of it is Ntrada's code. For a project whose stated goal is “no coding whatsoever”, delegating the hardest part of the job to the framework is exactly the right instinct, and it is worth saying plainly before the rest of this post gets critical.

The dispatch table, and its four keys

Build() walks modules and routes and, for each one, looks up a delegate in a dictionary built in the constructor:

_methods = new Dictionary<string, Action<IEndpointRouteBuilder, string, RouteConfig>>
{
    ["get"] = (builder, path, routeConfig) =>
        builder.MapGet(path, ctx => Handle(ctx, routeConfig)),
    ["post"] = (builder, path, routeConfig) =>
        builder.MapPost(path, ctx => Handle(ctx, routeConfig)),
    ["put"] = (builder, path, routeConfig) =>
        builder.MapPut(path, ctx => Handle(ctx, routeConfig)),
    ["delete"] = (builder, path, routeConfig) =>
        builder.MapDelete(path, ctx => Handle(ctx, routeConfig)),
};

RouteProvider.cs:36-46

The dictionary is Strategy in the form the language actually wants it. No IVerbMapper interface, no four classes, no registration ceremony — four closures keyed by a lowercase string, which is the same lowercase string the YAML author typed. A route with method: GET is normalised to "get" at NtradaExtensions.cs:258 and used as the key directly.

The RouteConfig is captured, not recomputed. Each closure captures the specific route's configuration, so the per-request delegate never has to find its own route back to the configuration that produced it. This is the one thing Ntrada genuinely compiles: the association between an endpoint and its config is fixed at boot.

And the table has exactly four entries. Get, post, put, delete.

Eight verbs out, four verbs in

Now look at the other end of the same request. DownstreamHandler translates the configured downstream method into an HttpMethod for the outbound call:

var includeBody = false;
switch (method)
{
    case "get":     request.Method = HttpMethod.Get;     break;
    case "post":    request.Method = HttpMethod.Post;    includeBody = true; break;
    case "put":     request.Method = HttpMethod.Put;     includeBody = true; break;
    case "patch":   request.Method = HttpMethod.Patch;   includeBody = true; break;
    case "delete":  request.Method = HttpMethod.Delete;  break;
    case "head":    request.Method = HttpMethod.Head;    break;
    case "options": request.Method = HttpMethod.Options; break;
    case "trace":   request.Method = HttpMethod.Trace;   break;
    default:
        return null;
}

DownstreamHandler.cs:146-178

Eight verbs downstream. Four upstream. The asymmetry is not theoretical: RequestProcessor.cs:13 declares SkipPayloadMethods = {"get", "delete", "head", "options", "trace"}, so the payload engine has been taught about three verbs that the routing table cannot deliver to it. Somebody thought about HEAD and OPTIONS in one file and not in the other.

Declare this route:

modules:
  orders:
    routes:
      - upstream: /orders/{id}
        methods:
          - PATCH
        use: downstream
        downstream: orders-service/orders/{id}

and the gateway does not start. Build() reaches this line:

foreach (var method in route.Methods)
{
    var methodType = method.ToLowerInvariant();
    _methods[methodType](routeBuilder, route.Upstream, routeConfig);
    AddEndpointDefinition(methodType, route.Upstream);
}

RouteProvider.cs:90-95

_methods["patch"] throws KeyNotFoundException: The given key 'patch' was not present in the dictionary. It surfaces from inside UseEndpoints, during Configure, wrapped in the host's startup failure. The message names neither the verb, nor the route, nor the module. In a product whose entire user interface is a YAML file, a boot failure that cannot say which line of that file caused it is a documentation defect as much as a code one.

The fix available in 3.1 was one line. MapMethods(path, methods, requestDelegate) was already part of the endpoint routing API, and it accepts an arbitrary verb collection:

// what the whole dictionary could have been
routeBuilder.MapMethods(route.Upstream, methods, ctx => Handle(ctx, routeConfig));

That single call would have collapsed the dispatch table and the capability gap, and it would have made methods: [PATCH, HEAD] work by construction. To be fair to the author: the four-entry dictionary is the shape you write when you are porting from the 2.x IRouteBuilder world, where MapVerb helpers were how you thought about it, and the 3.0 endpoint API had been out for about two months when this code was written. The gap is real; the reason for it is entirely legible.

The second table, written for a reader who barely exists

There is a companion output to the endpoint registration, emitted in the same loop:

private void AddEndpointDefinition(string method, string path)
{
    if (string.IsNullOrWhiteSpace(path))
    {
        path = "/";
    }

    _definitions.Add(new WebApiEndpointDefinition
    {
        Method = method,
        Path = path,
        Responses = new List<WebApiEndpointResponse>
        {
            new WebApiEndpointResponse
            {
                StatusCode = 200
            }
        }
    });
}

RouteProvider.cs:100-119

WebApiEndpointDefinitions is a singleton list registered at NtradaExtensions.cs:144 for exactly one consumer: the optional Swagger extension, which turns it into an OpenAPI document. So the compilation pass produces two artefacts — the endpoint table the framework will match against, and a parallel description of that table for documentation.

Every definition is hard-coded to a single 200 response and an empty parameter list. The generated OpenAPI document therefore describes paths and verbs and nothing else: no request schema, even for routes that have a JSON Schema on disk; no 400, even though validation exists; no 401, even on routes marked auth: true. All of that information is present in the configuration the loop is already iterating. It is honestly half-finished, and the commit that introduced it is literally titled 888b20f Swagger preview — but it is a good illustration of a specific trap in generated documentation: a schema that is present but wrong is worse than one that is absent, because a client generator will believe it.

Note also the if (string.IsNullOrWhiteSpace(path)) path = "/" on line 102. That guard exists because UpstreamBuilder strips a trailing slash and turns upstream: / into the empty string, which is a legal ASP.NET Core route template meaning “the root” but an unreadable one in a document. The same empty string, un-substituted, is half of a dictionary key in another file — and part 5 is the story of what happens when only one of the two places remembers to normalise it.

Normalisation happens before the table is built, in a different file

There is one more moving part, and it is the reason part 5 exists. Before RouteProvider runs, AddRoutes mutates the bound configuration objects in place:

foreach (var route in options.Modules.SelectMany(m => m.Value.Routes))
{
    if (route.Methods is {})
    {
        if (route.Methods.Any(m => m.Equals(route.Method, StringComparison.InvariantCultureIgnoreCase)))
        {
            throw new ArgumentException($"There's already a method {route.Method.ToUpperInvariant()} declared in route 'methods', as well as in 'method'.");
        }

        continue;
    }

    route.Method = (string.IsNullOrWhiteSpace(route.Method) ? "get" : route.Method).ToLowerInvariant();
    route.DownstreamMethod =
        (string.IsNullOrWhiteSpace(route.DownstreamMethod) ? route.Method : route.DownstreamMethod)
        .ToLowerInvariant();
}

NtradaExtensions.cs:246-262

Three observations, in ascending order of consequence.

  1. method: is genuinely optional and defaults to GET — a small, correct piece of DSL ergonomics, and the reason RouteProvider.cs:71-74 throwing “Both, route ‘method’ and ‘methods’ cannot be empty” is a dead guard for a state AddRoutes has already made unreachable.
  2. The duplicate check is one-directional. It compares method against methods, and says nothing about duplicates within methods. methods: [GET, GET] registers the same endpoint twice; ASP.NET Core will then report an ambiguous match at request time rather than at boot.
  3. The continue on line 255 means multi-method routes never reach the defaulting lines below it. route.DownstreamMethod stays null for every route that used methods: instead of method:. That is deliberate — a multi-verb route has no single downstream verb, and DownstreamHandler.cs:104-106 falls back to the incoming request's method precisely because of it. But it also means route.Method is null on those routes, and route.Method is half of a dictionary key that another component computes at startup. Hold that thought.

The wider point is that the route objects are still mutable when the route table is built, and RouteProvider.cs:76 writes back into them. route.Upstream = _upstreamBuilder.Build(module.Value, route) overwrites the authored value with the computed one, which makes Build() non-idempotent — call it twice and you get /orders/orders/{*url}/{*url} — and turns the configuration graph into a shared blackboard rather than an input. Nobody chose that. It is the accumulated result of five locally-shortest-path decisions, and it is the thread the final part of this series pulls on.

Next, the upstream DSL in forty lines, and the route-data key that behaves differently from every other one.