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.
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_idtoken is pulled from the JWT claim and spliced into the URL. WhatIOrdersServicedid with a[Get]attribute, Ntrada does with a path template.dispatcher:isSendAsync, as config.exchange: orders+routing_key: orders.create_orderis the RabbitMQ publish the C#BaseControllerdid in code. The routing key is spelled out here rather than derived from a class name.bind:is.Bind(...).customerId:@user_idstamps the caller's id onto the payload — the YAML version ofcommand.Bind(c => c.CustomerId, UserId).payload:andschema: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.itemsreshapes 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.jsonlistsid, name, description, vendor, priceas required — noquantity. But thecreate_product.jsonpayload template includesquantity, and seedspriceat0, which its own schema'sminimum: 1would 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 onlyadd_product_to_cart.schemaandcreate_product.schemaactually 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.itemsthrows away the envelope that carriedtotalResultsand the page links. The C# gateway built RFC-5988Linkheaders (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 sketchingadminandproduct_managerroles 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.