Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/no-coding-whatsoever-reading-an-api-gateway && read --section="top" 0%
.NET

No Coding Whatsoever

A 2018 API gateway promises that routing, auth, validation and messaging need no code at all - this series reads the source that makes the promise true, and the places where it is not.

By Kumar Chandrachooda 12 Feb 2026 7 min read
Many arrows converging on one archway and leaving as a single ordered stream

Every microservices estate arrives at the same fork. You have eleven services, each with its own auth story, its own base URL and its own idea of what a 404 body looks like, and you need one address the browser can talk to. So somebody writes a gateway: a small ASP.NET Core project with a controller per upstream concern, a hand-rolled HttpClient wrapper, a couple of middleware classes for JWT and CORS, and a Startup.cs that grows a new line every time a service is added. Eighteen months later that gateway is the least-tested and most-deployed component you own, and nobody can tell you what it does without reading it.

Ntrada — the open-source API gateway by Piotr Gankiewicz and the DevMentors community at github.com/snatch-dev/Ntrada, MIT-licensed, © 2018 snatch.dev — takes the other fork. Its README opens with a claim that is either the boldest or the most naive sentence in gateway design, depending on how the code turned out: it provides a gateway “that requires no coding whatsoever and can be started via Docker or as .NET Core application.” One YAML document declares your routes, your downstream services, your auth policies, your request-body templates and your message publishing. There is no Startup to write, no controller, no handler class. You write configuration and you run a container.

To be clear up front: I did not write Ntrada. I am a source-reader and a consumer of the estate it sits in front of. Where the implementation is the story I will quote it, attributed and at line, against the working tree at commit 84f0882; everything else is fresh illustrative code. This is a 2018–2020 codebase targeting netcoreapp3.1, and I intend to judge it against what was idiomatic then, not against .NET 9.

The whole product is one file

Here is a gateway. Not a fragment of one — the entire deployable surface, adapted from the repository's own samples/Ntrada.Samples.Api/ntrada.yml:

auth:
  enabled: true
  global: false

http:
  retries: 2
  interval: 2.0
  exponential: true

useForwardedHeaders: true
passQueryString: true
generateRequestId: true
generateTraceId: true
resourceId:
  generate: true
  property: id
useLocalUrl: true

modules:
  home:
    routes:
      - upstream: /
        method: GET
        use: return_value
        returnValue: Welcome to Ntrada API!

  orders:
    routes:
      - upstream: /orders
        methods:
          - GET
          - POST
          - DELETE
        matchAll: true
        use: downstream
        downstream: orders-service/orders

    services:
      orders-service:
        localUrl: localhost:5001
        url: orders-service

Read it slowly, because the whole design argument is visible in forty lines.

modules: is a mapping, not a list. Each key names a logical grouping of routes and carries its own service table. That is the unit of organisation — not a controller, not a folder, a YAML key. Whether the key or a name: property is the module's real identity turns out to matter enormously, and part 5 is about the day those two answers disagree.

use: is the dispatch verb. return_value, downstream, rabbitmq — a string that selects an IHandler implementation from a registry at request time. The RabbitMQ one is what turns a REST call into a published message with no code, which is the feature the DevMentors estate leans on hardest.

services: gives you symbolic downstreams. orders-service/orders is resolved against the module's service table at boot, with useLocalUrl swapping the whole estate between localhost:5001 and a container DNS name. That is service discovery expressed as a lookup table, and for a gateway that already knows every route statically, it is exactly enough.

matchAll: true plus a methods: list is the load-bearing feature. One declaration proxies three verbs and every path beneath /orders to the same downstream. It is also the feature that quietly breaks two others; part 3 pulls that thread.

The three-state keys are the best design decision in the codebase. passQueryString, generateRequestId, forwardResponseHeaders and three others are bool? in C#, which gives a YAML author force on, force off, and inherit the global from one key. Six keys implement that rule identically. One implements it backwards.

What it costs to make no coding real

The gateway is 60 C# files and roughly 3,200 lines in src/Ntrada, plus six extension packages that live outside it. The internal shape is consistent enough to describe in a sentence: twenty-three interfaces sit in the root Ntrada namespace, nineteen of them have exactly one internal sealed implementation in a sub-namespace, and [assembly: InternalsVisibleTo("Ntrada.Tests.Unit")] at NtradaExtensions.cs:21 is what makes them testable at all. The naming vocabulary is its own dialect — *Builder for string assembly, *Provider for enumerate-and-return, *Manager for a stateful registry — and almost none of those types are the Gang-of-Four pattern their suffix implies.

The public surface is two extension methods:

.ConfigureServices(services => services.AddNtrada())
.Configure(app => app.UseNtrada());

That is the entire host. It is also, as part 10 shows, the API-aesthetic decision that forced the composition root to build a second dependency-injection container inside ConfigureServices in order to read one value — a lovely illustration of a signature choice propagating into the shape of three types.

What genuinely impressed me, reading it cold: Ntrada writes no middleware of its own. It compiles the YAML route table into ASP.NET Core's own endpoint routing and hands matching to the framework's DFA, paying nothing for it. IHttpClientFactory is used correctly, with a named client and a Polly transient-error policy. There is no sync-over-async anywhere — not one .Result, not one .Wait(), in a codebase from the era when that was still common. And PolicyManager throws at construction if a route names an authorisation policy the config never defined, which is the strongest boot-time check in the repository.

The lineage, in one paragraph

Ntrada did not appear from nowhere. It began life in November 2018 under the name NGate, as the extraction of the code-first gateway that fronted DShop, the DevMentors teaching estate; Convey is its sibling framework, supplying the building blocks the services behind it use. It went on to become the deployed gateway for Pacco, where five hundred lines of YAML steer a ninety-line host — I read that deployment from the other side in a gateway in YAML and its twin in code, and I will not re-derive it here. This series reads the gateway itself: its source, its git history, and the gap between what the README says and what the code does.

That gap is the point. A configuration-driven product has a harder correctness problem than a library, because every defect is a defect in something a user cannot step through in a debugger. When a C# API misbehaves you read the stack trace; when a YAML key binds to nothing, you get silence and a plausible response. Several of the best findings in this series are exactly that shape.

Where the series goes

  1. No Coding Whatsoever — this post.
  2. The Route Table Is a Compiler Output — how the YAML becomes ASP.NET Core endpoints, and the four verbs that fit through the door.
  3. Match All and the Reserved Word Nobody Documented — the upstream DSL in forty lines, and url as a route-data key nothing documents.
  4. Thirteen Lines Are the Whole Pipeline — gate, dispatch, and four hook points, one of which lost its purpose to a bug fix.
  5. The Payload Template That Could Never Load — two notions of module identity, and the live bug the unreachable code was hiding.
  6. Five Parses to Reject One Request — the validation round trip, and the HTTP 200 that means your payload was invalid.
  7. The Parser That Works Because Its Bug Is Harmlessbind: and transform:, two mini-languages sharing one parser.
  8. Three Predicates, One Flag — three components deciding whether a route needs auth, and only one of them reading auth.enabled.
  9. The Claims Map That Does Not Exist — the documented workaround for the likeliest auth failure, which binds to nothing.
  10. Building a Second Container to Read One ValueBuildServiceProvider() inside ConfigureServices, and why the signature caused it.
  11. Everything Is a Singleton — twenty-two descriptors, zero scoped, and the good half of that decision.
  12. Your API Gateway Cannot Proxy an Image — one response-shaping feature that forced eager buffering onto all traffic.
  13. Nothing Was Ever Frozen — the honest retrospective, and what two refactors would have changed.

If you have ever argued that configuration is safer than code because there is less of it to get wrong, the next twelve parts are for you. We start where every request starts: at boot, watching a YAML document turn into a routing table.