Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/nothing-was-ever-frozen && read --section="top" 0%
.NET

Nothing Was Ever Frozen

The retrospective - configuration stays mutable, ordering stays emergent, one method absorbs every concern, and the only outside contributor correctly chose to duplicate sixteen lines rather than refactor.

By Kumar Chandrachooda 20 Feb 2026 7 min read
One outline repeated three times at slight offsets, mid-transformation, never settling

On 8 November 2020, eleven months after the last commit by the project's author, a developer called gioxoay opened pull request #14 against Ntrada. It fixed a real bug: Content-Type lives on HttpContent.Headers, not on HttpResponseMessage.Headers, so the response-header forwarding loop was copying every header except the one clients most need. The fix is sixteen lines, and it is a verbatim duplicate of the sixteen lines immediately above it, with the collection swapped and a comment saying what it is:

// Fixed for missing Content-Type header
foreach (var header in httpResponse.Content.Headers)
{
    if (ExcludedResponseHeaders.Contains(header.Key.ToLowerInvariant()))
    {
        continue;
    }

    if (response.Headers.ContainsKey(header.Key))
    {
        continue;
    }

    response.Headers.Add(header.Key, header.Value.ToArray());
}

DownstreamHandler.cs:341-355

That was the correct decision, and it is the most eloquent artefact in the repository. A stranger, with no test suite covering this class, no maintainer to review, and 443 lines of method-and-branch above him, correctly assessed that the safest possible change was to add rather than to alter. Long Method has a cost, and this is what the cost looks like when somebody finally has to pay it.

Twelve parts of this series have been about the code. This one is about the shape.

One sentence for the whole estate

Read the twelve findings back to back and they collapse into one property. In part 2 the route objects are still being mutated while the route table is built. In part 5 a dictionary is keyed by one snapshot of a mutable object and read with another, and which snapshot you get is a function of dependency-injection activation order. In part 8 one rule is spelled three times in three files. In part 9 a security invariant holds because a constructor happens to run. In part 12 one method has absorbed header forwarding, status mapping, 204 handling, five content-type commits, response shaping and hooks, in eleven separate accretions.

Nothing in this codebase is ever declared finished. Configuration stays mutable after boot, ordering stays emergent rather than declared, one method keeps absorbing every new concern, and the defects live not in any single decision but in the absence of a moment where the system says this is now settled. There is no freeze. Route is bound from YAML, mutated by AddRoutes, mutated again by RouteProvider, and read per-request by five classes; IRouteConfigurator exists to be the freeze point and computes one string.

That is not a criticism of the author's ability — the parts of this codebase that are frozen are excellent, and they are excellent for exactly that reason. _methods is built once in a constructor and never touched. ExecutionData is constructed and passed. The endpoint table is compiled and handed to a matcher that cannot be modified afterwards. Where Ntrada freezes something, it is fast and correct. Where it does not, every bug in this series lives.

What Ntrada gets right

I have been critical for twelve parts, so let me be unambiguous about the other column, because it is longer than the criticism suggests.

  • Route matching is delegated to the framework. Ntrada writes no matcher and no middleware; it compiles YAML into IEndpointRouteBuilder calls and inherits ASP.NET Core's DFA, its constraints, its precedence rules and its performance. That is the single best engineering decision in the project.
  • The three-state bool? option family. Six keys, one inheritance rule, and a commit (795f717) that shows the author noticing a route could opt out but not opt in, and fixing all three id flags in one pass. For a declarative DSL, a nullable boolean is precisely the right primitive.
  • IHttpClientFactory used correctly, with a named client and a Polly policy attached at registration — in 2019, when a great deal of production code was still newing up HttpClient per request.
  • VerifyPolicies fails at boot. Every policy name any route references is checked against the policy table before the process serves traffic, with a message naming the missing ones.
  • No sync-over-async anywhere. Not one .Result, not one .Wait(), in 3,200 lines from the era when both were endemic.
  • ExecutionData as a single parameter object, which is what makes the all-singleton lifetime decision coherent rather than reckless, and what lets extensions see a request without reaching into HttpContext.
  • The Resource-ID pattern. The edge mints the identifier, injects it into the payload under a configurable property, and returns it in a response header. Identity at the edge, done properly, and free to every route that wants it.
  • The vocabulary. use:, bind:, transform:, matchAll:, onSuccess:. For a product whose users never see the C#, the nouns are the design, and the git log shows them being chosen deliberately — bb1870b … renamed request -> use is what makes use: rabbitmq read as English.

When not to use it

An honest recommendation needs boundaries, and Ntrada's are sharp.

Do not use it in front of anything that returns bytes. Images, PDFs, archives, protobuf, file downloads, chunked streaming, server-sent events. Part 12 is not a corner case; it is every non-text response, silently.

Do not use it if you need PATCH upstream. Four verbs map. The failure is a KeyNotFoundException at boot naming nothing.

Do not use it as a security boundary without reading AuthorizationManager first. Claim matching is ordinal and exact, the documented alias map does not exist, and three components disagree about what auth.enabled means.

Do not use it where retry semantics matter. Retries apply to every verb with no per-route control, and a retried pass-through POST forwards an empty body.

Do use it for what it is unambiguously good at: a JSON-only edge in front of a handful of internal HTTP services, where the routing table changes more often than the code does, where you want REST-to-message publishing without writing a publisher, and where the operational alternative is a bespoke gateway nobody wants to own. In that shape it is a genuinely good trade, and the “no coding whatsoever” claim holds — you really can add a route, a policy and a message publication without touching C#.

And be clear about its status: the last substantive commit was Christmas Day 2019, and 101 of 103 commits are by one person. This is a well-preserved teaching artefact, not maintained software. Read it, learn from it, run it in front of an internal service if it fits — but do not build a platform on it in 2026 without expecting to own the fork.

The two refactors

If I could send two changes back, they would not be any of the individual bug fixes.

One: freeze the configuration. Bind the YAML into immutable records, compute everything per-route once — the upstream path, the payload key, RequiresAuth, skipPayload, the effective header dictionaries, the compiled JSON schema — into a fully-populated RouteConfig, and hand that to the endpoint closure. RouteConfig currently has two fields; it wants about twelve. That single change eliminates the part 5 key divergence, the part 8 triple predicate, and roughly ten per-request recomputations, and it makes the DI activation-order hazard structurally impossible. The performance fix and the correctness fix are the same refactor, which is the most useful thing to notice about the whole codebase. C# 9's record and init — arriving a year after the commits stopped — would have made the correct version easier to write than the current one.

Two: split DownstreamHandler. Four collaborators — request builder, sender, response mapper, response shaper — each testable, each with a name. The 443-line method exists because eleven commits each chose the locally cheapest place to add a concern, and every one of those choices was individually reasonable. The class has no test class at all, which is precisely why every confirmed defect in this series lives in it, and precisely why an outside contributor duplicated sixteen lines rather than touch it.

Behind both is the same rule, and it is the one I would take to any config-driven system: a declarative product needs a compile step, and if you do not write one, dependency-injection activation order becomes it.

Where this goes next

This series read one library — src/Ntrada, sixty files, the gateway's own code. It is not the whole estate. There are six extension packages that supply JWT, RabbitMQ, Jaeger tracing, CORS, custom errors and Swagger; two composition roots; two samples; a Dockerfile; a CI pipeline; two test projects, one of which contains no tests and is load-bearing anyway.

The system-of-systems read is the companion series, Six Packages and an Empty Test Project, which starts at twelve projects and one star. It answers the questions this series kept deferring: how an extension is discovered when nothing references it, what happens when the tracing extension loads after the error handler, what a 202 Accepted actually promises when the broker is dead, and why four of the eight JWT keys in the README's flagship example bind to nothing at all.

If you have ever shipped a product whose entire interface is a YAML file, you already know the feeling this series has been circling: the code is the easy half. Go and write the schema.