One YAML Flag Turns REST Into a Message
Pacco's Ntrada gateway is ninety lines of C# and five hundred of YAML, and swapping one route key - use downstream for use rabbitmq - turns the same HTTP surface from a proxy into a bus publisher answering 202. The two compose files disagree about which one you get.
Here is POST /orders in ntrada.yml, and here it is again in ntrada-async.yml. Spot the architectural revolution:
# ntrada.yml — the sync gateway
- upstream: /
method: POST
use: downstream
downstream: orders-service/orders
# ntrada-async.yml — the async gateway
- upstream: /
method: POST
use: rabbitmq
config:
exchange: orders
routing_key: create_order
Same upstream route, same auth, same request body. In the first file the gateway forwards your HTTP request to the Orders service and returns whatever Orders returns. In the second it serialises your request body onto a RabbitMQ exchange and returns 202 Accepted — the service will hear about your order whenever it consumes the message, and you will find out what happened through an entirely different channel (part 2's nervous system, whose feedback loop part 5 walks end to end). One key, use:, decides whether your public API is a proxy or a message publisher. That is the whole diff, and it is one of the best teaching artefacts in the Pacco estate.
A gateway with almost no code
Pacco.APIGateway is the estate's deployed front door, and it barely exists as a program. The repository's entire C# surface is about ninety lines: a Program.cs and three infrastructure classes we will meet properly in part 7. Everything a gateway does — routing, JWT validation, CORS, swagger, tracing, retries, the RabbitMQ publishing itself — comes from Ntrada, the authors' configuration-driven gateway framework, and is declared in YAML. The only decision Program.cs makes is which YAML:
webBuilder.ConfigureAppConfiguration(builder =>
{
const string extension = "yml";
var ntradaConfig = Environment.GetEnvironmentVariable("NTRADA_CONFIG");
var configPath = args?.FirstOrDefault() ?? ntradaConfig ?? $"ntrada.{extension}";
if (!configPath.EndsWith($".{extension}"))
{
configPath += $".{extension}";
}
builder.AddYamlFile(configPath, false);
})
Command-line argument, then the NTRADA_CONFIG environment variable, then the sync default. The gateway's architecture is an environment variable. Hold that thought.
Queries stay synchronous, and the split shows
Flip through ntrada-async.yml and a pattern falls out immediately: every POST, PUT and DELETE becomes use: rabbitmq; every GET stays use: downstream. Reservations publish reserve_resource to the availability exchange, parcel creation publishes add_parcel, vehicle updates publish update_vehicle — but reading a resource, listing orders, fetching a parcel all remain plain HTTP proxying, because a query has to return data now and a message cannot.
This is CQRS made visible at the transport layer: commands ride the bus, queries ride HTTP, and the gateway config is the document that says so. The services themselves are built for exactly this duality — each one subscribes to its commands over RabbitMQ and exposes them over HTTP — which is what makes the whole surface swappable by flag in the first place; the domain-side view of that dual ingestion is covered in the companion piece on CQRS at the transport layer. What the estate demonstrates, better than any diagram I have seen, is that sync-versus-async is a deployment decision, not a code decision — if and only if every service already speaks both protocols.
bind: — identity is decided at the edge
The async config's routes carry a small enrichment block that repays close reading:
- upstream: /{orderId}/parcels/{parcelId}
method: POST
auth: true
use: rabbitmq
config:
exchange: orders
routing_key: add_parcel_to_order
bind:
- orderId:{orderId}
- parcelId:{parcelId}
- customerId:@user_id
bind: injects values into the outgoing message body: route parameters by name, and @user_id — the subject claim from the validated JWT. The message that reaches the Orders service carries a customerId that the gateway wrote, after authentication, from the token. Whatever customerId the caller put in their JSON body is overwritten. A customer cannot add parcels to someone else's order by editing a request field, and no downstream service needs to re-derive the caller's identity from headers — it is already in the payload, bound at the trust boundary. The same idiom guards the queries: GET /orders proxies to orders-service/orders?customerId=@user_id, so “list my orders” is structurally scoped to the caller. Small feature, correct place, exactly the kind of edge responsibility that hand-rolled gateways forget.
generate: true — the ID arrives before the order does
The second enrichment is stranger and more consequential:
- upstream: /
method: POST
auth: true
resourceId:
property: orderId
generate: true
use: rabbitmq
config:
exchange: orders
routing_key: create_order
generate: true tells the gateway to mint a fresh GUID, inject it into the message as orderId, and return it to the caller in a Resource-ID response header — before any service has processed anything. The aggregate's identity is assigned at the edge, not by the owning service. The estate's own onboarding artefact leans on this: Pacco-sample-scenario.rest, the executable walkthrough in the gateway repo, chains twenty-four requests with lines like @orderId = {{create_order.response.headers.Resource-ID}} — create an order, capture the ID from the header, use it in the next call, all without ever reading a response body.
Client-side ID generation is a known idempotency-friendly pattern; Pacco's variant moves it to the gateway, which keeps clients dumb but means the ID authority is now config. Note the quiet coupling: the YAML must know each aggregate's ID property name (orderId, parcelId, deliveryId, userId, vehicleId — all five appear). Rename a property in a service contract and a YAML file in another repository silently mints IDs into a field nobody reads. Part 5 returns to this header, because it is one half of how a 202 names a future that has not happened yet.
Four copies of every route
Now the operational ledger. There are not two YAML configs in this repository; there are four: ntrada.yml, ntrada-async.yml, and a .docker variant of each. I diffed the sync pair. The docker file differs from the local one by exactly three lines — useLocalUrl: false, the load balancer URL pointing at fabio:9999 instead of localhost:9999, and Jaeger's UDP host. Three lines of genuine environment delta, achieved by duplicating a 455-line file (534 for the async pair). Every route in the estate's public API exists in four copies that must be kept in sync by hand. This is configuration's version of the copy-paste tax the estate pays everywhere, and it is the mild form of the problem.
The sharp form is the one from part 1's cold open, now fully legible: the umbrella's compose/services.yml starts the gateway with NTRADA_CONFIG=ntrada-async.docker.yml, while compose/services-local.yml starts it with NTRADA_CONFIG=ntrada.docker. (Both values work — Program.cs appends .yml only when it is missing, an accidental kindness.) Pull the published images and mutating requests return 202 and ride the bus; build from local clones and they proxy synchronously. The gateway made architecture swappable, and the estate promptly deployed both architectures without telling anyone. Nothing in either compose file, the README or the run-book marks the difference. A developer who tested against one and demoed against the other would meet a different status code, different latency, different failure modes and a different feedback channel — from the same curl command.
The schema that never shipped
One more honest finding. The sync config's customer-creation route declares request validation:
payload: create_customer
schema: create_customer.schema
and the async variant references complete_customer_registration.schema. Search the repository for schema files: there are none. The validation feature is configured, the artefacts were never committed (or were lost), and Ntrada simply carries on — the route works, unvalidated. Config that documents an intent the repo cannot fulfil is a special kind of drift: it passes every review that reads only the YAML, and it teaches the next reader a feature that does not exist. Two smaller notes in the same ledger: customErrors.includeExceptionMessage: true forwards downstream exception text through the edge to callers, and exactly one module — vehicles — reshapes its response envelope (onSuccess: data: response.data.items) while orders and parcels pass their paged envelopes through untouched, so the public API's list shape depends on which service you ask.
To be fair to Ntrada and to Pacco: a 500-line YAML file that is the entire public API is also a wonderful thing. It diffs, it reviews, the port map and the auth rules and the exchange bindings are all in one place, and the sync/async duality is expressible at all — which is more than most gateways can say. The defects above are operational hygiene, not design flaws.
The obvious question is what this gateway would look like without Ntrada — if you had to write the auth bypass, the async routes, the ID minting and the correlation header yourself. The estate answers that question literally: it contains a second gateway, built on Ocelot, that hand-rolls all four jobs — and that no compose file has ever run. Next: a gateway in YAML and its twin in code.