Skip to content
Kumar Chandrachooda
Microservices

A Gateway in YAML and Its Twin in Code

Pacco ships a second API gateway built on Ocelot that hand-rolls everything the Ntrada YAML declares - auth bypass, async bus routes, edge-minted IDs, correlation headers - plus the estate's only circuit breakers. It is instructive, revealing, and deployed by absolutely nothing.

By Kumar Chandrachooda 22 Dec 2025 6 min read
Two front doors, one never opened

The Pacco estate contains a second API gateway. Pacco.APIGateway.Ocelot has its own repository, its own Travis pipeline that builds it and pushes images to Docker Hub, its own entry in the umbrella's clone script, its own .rest file demonstrating its API. It also has: no entry in any compose file, no line in either PM2 manifest, and no mention in the README's clone list — twelve repositories are listed there; this is the thirteenth. It is the estate's most thoroughly built piece of software that has never run anywhere.

Part 3 read the deployed gateway — ninety lines of C# steering five hundred of Ntrada YAML. This part reads its twin, because the comparison is the payoff: everything the YAML declares in a key, the twin implements in a class, and seeing the same four jobs done both ways is the closest thing I know to a controlled experiment in gateway design. Before any of that, though, be clear about the deployment status, because it shapes every judgement that follows: nothing below describes running software. It describes a well-preserved sketch — down to the fossil in its Dockerfile, which ends with ENV NTRADA_CONFIG ntrada.docker, an environment variable copy-pasted from the other gateway's Dockerfile that Ocelot never reads.

Job one: authentication with a guest list

Ntrada spells per-route auth as auth: true and auth: false. Ocelot's model is per-route too, but Pacco layers Convey JWT handling on top by overriding Ocelot's authentication middleware wholesale, in Program.cs:

AuthenticationMiddleware = async (context, next) =>
{
    if (!context.DownstreamReRoute.IsAuthenticated)
    {
        await next.Invoke();
        return;
    }

    if (context.HttpContext.RequestServices.GetRequiredService<IAnonymousRouteValidator>()
        .HasAccess(context.HttpContext.Request.Path))
    {
        await next.Invoke();
        return;
    }

    var authenticateResult = await context.HttpContext.AuthenticateAsync();
    if (authenticateResult.Succeeded)
    {
        context.HttpContext.User = authenticateResult.Principal;
        await next.Invoke();
        return;
    }

    context.Errors.Add(new UnauthenticatedError("Unauthenticated"));
}

The AnonymousRouteValidator behind that second guard is a HashSet<string> loaded from an AnonymousRoutes config section — /identity/sign-in and /identity/sign-up — with HasAccess doing an exact-path Contains. It is a guest list: even routes Ocelot considers authenticated get waved through if the path is on it. Compare the YAML twin, where the same fact is auth: false on two route entries. Both are readable; the YAML one cannot have a bug in it, because it has no code to be wrong in. The code one earns something in exchange — the bypass logic is testable, debuggable and extensible — none of which a sample with no tests collects on.

Job two: the 202 machine, hand-rolled

The star of the repository is Infrastructure/AsyncRoutesMiddleware.cs — Ntrada's use: rabbitmq feature reimplemented in about fifty readable lines. An AsyncRoutes config section maps request signatures to bus addresses:

"AsyncRoutes": {
  "Routes": {
    "POST /availability/resources": {
      "Exchange": "availability",
      "RoutingKey": "add_resource"
    }
  }
}

and the middleware, sitting before Ocelot in the pipeline, short-circuits matching requests onto the bus:

if (!Conventions.TryGetValue(key, out var conventions))
{
    conventions = new MessageConventions(typeof(object), route.RoutingKey, route.Exchange, null);
    Conventions.TryAdd(key, conventions);
}

var message = await _payloadBuilder.BuildFromJsonAsync<object>(context.Request);
var resourceId = Guid.NewGuid().ToString("N");
if (context.Request.Method == "POST" && message is JObject jObject)
{
    jObject.SetResourceId(resourceId);
}

var messageId = Guid.NewGuid().ToString("N");
var correlationId = Guid.NewGuid().ToString("N");
var correlationContext = _correlationContextBuilder.Build(context, correlationId, spanContext,
    route.RoutingKey, resourceId);
_rabbitMqClient.Send(message, conventions, messageId, correlationId, spanContext, correlationContext);
context.Response.StatusCode = 202;
context.Response.SetOperationHeader(correlationId);

Read it against part 3's YAML and every key finds its counterpart. The config: {exchange, routing_key} block becomes a MessageConventions built around typeof(object) — the body is never deserialised into a contract, just re-published — and cached in a static ConcurrentDictionary keyed by "METHOD /path". The resourceId: generate: true feature becomes a GUID written into the JObject. The 202 is explicit, and SetOperationHeader adds the header the whole feedback loop hangs on: X-Operation: operations/{correlationId} — here is where you poll for what became of this. If you want to understand what a message-publishing gateway actually does, this one file teaches it better than the YAML, precisely because nothing is hidden. As a single teaching artefact it is the best thing in either gateway repository.

Jobs three and four: the body swap and the header

For routes that stay synchronous, ResourceIdGeneratorMiddleware does the ID minting: it buffers every POST body, injects a fresh id, then — the line worth remembering — replaces the request stream so Ocelot forwards the enriched payload:

await using var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)));
context.Request.Body = memoryStream;
await next(context);

Rewriting Request.Body mid-pipeline is the sort of trick you only learn from reading gateways; it is also applied to every POST, unconditionally, where the YAML twin declares ID generation per route — the code version is coarser than the config version. The middleware stashes the ID in HttpContext.Items through a helper whose name carries a genuine typo — SetResourceIdFoRequest — which I mention not to sneer but because it is the tell of hand-rolled glue: no schema, no review gate, just prose in identifiers.

Correlation, the fourth job, rides a DelegatingHandler hooked into Ocelot's outbound HttpClient: CorrelationContextHandler serialises a correlation context into a Correlation-Context header on every forwarded request and sets X-Operation on responses to mutating verbs. Ntrada does the same through an IHttpRequestHook. The spine itself — what is in that JSON blob and where it surfaces — is part 7's subject.

The only circuit breakers in the estate

Now the irony that makes this repository more than a curiosity. Each route in ocelot.json carries a Polly quality-of-service block:

"QoSOptions": {
  "ExceptionsAllowedBeforeBreaking": 3,
  "DurationOfBreak": 1000,
  "TimeoutValue": 5000
}

Three failures open the circuit for a second; downstream calls time out at five. Modest numbers, but they are the only circuit breakers anywhere in the thirteen repositories. The deployed Ntrada gateway retries — retries: 2, exponential: true — but never breaks; a struggling downstream service gets more traffic from it, not less. The estate's entire supply of the one resilience pattern every microservices talk begins with sits in the gateway that no compose file starts. If you want a one-sentence definition of the gap between an estate's architecture and its deployment, that is mine.

An honest scoreboard

Fairness requires saying plainly that the twin is a sketch, not a peer:

Ntrada (deployed) Ocelot (never deployed)
Services routed all 9 2 (availability, identity)
Async bus routes every mutating route 2 entries
Circuit breakers none Polly on each route
ID minting per-route, config every POST, code
Public contract RESTful (POST /resources/{id}/reservations/{dateTime}) flat (POST /resources/reservations, all in body)

That last row deserves a beat: the two gateways do not even expose the same API for the same operation. Ntrada's reservation route binds route parameters into the message; Ocelot's Pacco-ocelot.rest shows everything in the body. Swap gateways and you break every client. Two smaller ledger entries: Program.cs calls services.BuildServiceProvider() inside ConfigureServices to read config for the options binding — the classic double-container smell — and ExceptionToResponseMapper.Map is a switch containing only the discard arm, so every exception whatsoever becomes a 400 with {"code":"error","reason":"There was an error."}. The deployed gateway leaks downstream exception text; the undeployed one tells you nothing at all. Somewhere between them is a good error policy.

To be fair to the authors: everything about this repository makes sense as a teaching comparison. The course this estate accompanies compares gateway approaches, and a second gateway that routes two services is exactly enough to teach the difference; wiring it into compose would double the estate's run configurations for no pedagogical gain. The findings stand — the README omission, the Dockerfile fossil, the contract drift — but they are the scars of a deliberate demo, not an abandoned migration. What the pair genuinely teaches is the trade: YAML buys you consistency and reviewability; code buys you circuit breakers and a debugger. The estate chose YAML and deployed it, kept the code as a museum piece, and accidentally shelved its only resilience with it.

Both gateways end their mutating requests the same way: a 202, a minted ID, and a header pointing at an operation that does not exist yet. What happens on the other side of that promise — the Redis state machine, the saga override, the push to exactly one browser — is next: the 202 that names the future.