Skip to content
Kumar Chandrachooda
Microservices

A Gateway Written in YAML

DShop.Api.Next rebuilds the whole code-first gateway as Ntrada configuration - a 17-line Program.cs and a folder of YAML. Reading both side by side shows exactly what you trade when a gateway becomes data.

By Kumar Chandrachooda 04 Sep 2025 5 min read
A stack of YAML lines standing in for a wall of C# controllers

The most interesting thing about DShop.Api.Next is that it does the same job as DShop.Api and contains almost no code. The code-first gateway is seven controllers, a BaseController full of dispatch logic, RestEase interfaces, and a Startup. Api.Next is a Program.cs you can read in one breath and a folder of YAML:

public static async Task Main(string[] args)
    => await WebHost.CreateDefaultBuilder(args)
        .UseNtrada()
        .UseRabbitMq()
        .Build()
        .RunAsync();

That is the entire program. UseNtrada() boots Ntrada — the authors' own configuration-driven API gateway — which reads a tree of YAML at startup and becomes the gateway. There are no controllers to write. This part reads that YAML against the C# it replaces, because the comparison is unusually honest: it is the same team, the same estate, the same routes, expressed two ways within a year. And it turns out that turning a gateway into data does not delete its decisions — it just moves them somewhere a schema validator can disagree with the compiler.

Routes as downstream or dispatcher

Every route in Ntrada declares a use: verb, and the two that matter are exactly the two doors the C# gateway had. A read is use: downstream — forward this HTTP call to a service. A write is use: dispatcher — publish this to RabbitMQ. Here is the Orders module, in full for the create-and-read pair:

routes:
- upstream: /
  method: GET
  use: downstream
  downstream: orders-service/orders?customerId=@user_id
  on_success:
    data: response.data.items

- upstream: /
  method: POST
  use: dispatcher
  exchange: orders
  routing_key: orders.create_order
  bind:
  - customerId:@user_id
  payload: create_order
  schema: create_order.schema

Read it slowly, because six controller methods collapse into this shape:

  • downstream: is the RestEase forwarder, as a string. orders-service/orders?customerId=@user_id — the @user_id token is pulled from the JWT claim and spliced into the URL. What IOrdersService did with a [Get] attribute, Ntrada does with a path template.
  • dispatcher: is SendAsync, as config. exchange: orders + routing_key: orders.create_order is the RabbitMQ publish the C# BaseController did in code. The routing key is spelled out here rather than derived from a class name.
  • bind: is .Bind(...). customerId:@user_id stamps the caller's id onto the payload — the YAML version of command.Bind(c => c.CustomerId, UserId).
  • payload: and schema: name a request-shaping template and a JSON-Schema to validate against. This is the one thing the C# gateway did not have — validation is now declarative.
  • on_success: data: response.data.items reshapes the downstream response, unwrapping the paged envelope so the client sees a bare array.

Authorisation is a claim check on the route — claims: {role: admin} on the approve and product-write routes — and global JWT auth is configured once at the top of ntrada.yml, issuer dshop-identity-service, the same 70-character HS256 secret the code-first gateway carries (committed in plaintext, which is its own problem). Read routes on Products even opt out entirely with auth: false. The gateway's whole security model is now grep-able.

Where the two gateways quietly disagree

Here is the payoff, and it is the honest ledger of configuration-as-code: the two gateways enforce different contracts for the same route, and nothing forces them to agree. The C# gateway's CreateProduct command has a decimal Price. The Ntrada gateway validates the same request against a JSON-Schema — and the schema types price as an integer:

"price": {
  "type": "integer",
  "minimum": 1
}

So a product priced at 19.99 sails through the code-first gateway and is rejected by the YAML one, because 19.99 is not an integer. Two front doors to the same products service, and they disagree about whether the shop can sell anything that costs cents. Nobody wrote that disagreement on purpose; it fell out of a hand-authored schema drifting from a C# type, with no test spanning both.

The drift does not stop there:

  • The schema and the payload template contradict each other. create_product.schema.json lists id, name, description, vendor, price as required — no quantity. But the create_product.json payload template includes quantity, and seeds price at 0, which its own schema's minimum: 1 would reject. The template and the schema that guards it were edited at different times and never reconciled.
  • Most schemas dangle. Every write route names a schema:, but only add_product_to_cart.schema and create_product.schema actually exist on disk. The rest are references to files that were never written — a validation that looks configured and isn't.
  • Pagination is lost in the unwrap. on_success: data: response.data.items throws away the envelope that carried totalResults and the page links. The C# gateway built RFC-5988 Link headers (imperfectly — see part 8); the YAML gateway silently discards the paging metadata to hand back a flat array.
  • A feature commented out instead of shipped. The auth: block ends with a # policies: section sketching admin and product_manager roles with per-action access — RBAC designed, commented out, never enabled. The idea is preserved as a comment; the capability was never turned on.
  • Skew inside one small repo. There is no README. The Travis config targets an older SDK and OS than its sibling gateway, and the Dockerfile's runtime image is a version ahead of the project's target framework. Thirteen commits, and it still can't agree with itself about which .NET it is.

The trade, stated plainly

To be fair to Ntrada: the YAML gateway has one thing the C# gateway lacks and wants — a retry policy. http: {retries: 2, interval: 2.0, exponential: true} gives every downstream forward exponential-backoff retries for free, a resilience primitive the code-first gateway simply doesn't have. Declarative config makes cross-cutting behaviour like that a three-line addition instead of a middleware. And collapsing six controller methods into forty lines of readable YAML is a real reduction in surface area. For a gateway whose job is genuinely “forward these, publish those,” configuration is the right altitude.

But the source shows the bill. The compiler was doing unpaid work in the C# gateway: it guaranteed the request shape matched the command, that a route referenced a handler that existed, that price was a decimal everywhere. Move the gateway to YAML and that guarantor is gone. A dangling schema: reference compiles fine because there is no compile. An integer price and a decimal price coexist because no type system spans the two files. Configuration doesn't remove a gateway's decisions; it removes the tool that was checking them. The YAML is shorter, and every line is now a place where two hand-edited files can drift apart with nothing to notice.

Next, the header contract both gateways share — and the small bug hiding in it: the header that names the future.