One Front Door for Five Services
FeedR's API gateway is a YARP reverse proxy whose entire routing table lives in appsettings.json - prefix-stripping routes, per-environment cluster overrides, and a one-line transform that starts every distributed trace. Part 2 of the FeedR deep dive.
The first thing a microservices system inflicts on its consumers is arithmetic. FeedR runs six services on six ports — gateway on 5000, aggregator on 5010, notifier on 5020, news on 5030, quotes on 5040, weather on 5050 — and nobody calling the system should ever need to know that. The whole point of a gateway is to replace six base URLs with one, and the interesting question is how much code that should cost.
In FeedR — the open-source DevMentors sample I'm walking through in this series — the answer is: almost none. The gateway project is one Program.cs you can read in a minute and one JSON section that does everything else. It is the best small demonstration I know of YARP (Yet Another Reverse Proxy, Microsoft's reverse-proxy toolkit) used the way it wants to be used: as configuration with a thin code garnish.
The whole gateway, minus the JSON
Here is the entire hosting code of FeedR.Gateway, lightly trimmed:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("yarp"))
.AddTransforms(transforms =>
{
transforms.AddRequestTransform(transform =>
{
var correlationId = Guid.NewGuid().ToString("N");
transform.ProxyRequest.Headers.AddCorrelationId(correlationId);
return ValueTask.CompletedTask;
});
});
var app = builder.Build();
app.MapGet("/", () => "FeedR Gateway");
app.MapReverseProxy();
app.Run();
LoadFromConfig points YARP at a configuration section; MapReverseProxy wires the proxy into the endpoint pipeline. Everything about where requests go lives in appsettings.json. The only code-level opinion is that transform — and it turns out to be the opening move of the correlation-ID story that part 8 of this series follows across the whole system.
Routes and clusters: YARP's two-table model
YARP splits routing into two tables. Routes match incoming requests; clusters name sets of destination addresses. A route points at a cluster by ID. FeedR declares five of each, and they all follow the same pattern (excerpted from the gateway's appsettings.json):
"routes": {
"feeds-quotes": {
"clusterId": "feeds-quotes",
"match": { "path": "feeds/quotes/{**catch-all}" },
"transforms": [ { "pathPattern": "{**catch-all}" } ]
}
},
"clusters": {
"feeds-quotes": {
"destinations": {
"destination1": { "address": "http://localhost:5040" }
}
}
}
Two details carry all the weight here.
The {**catch-all} capture. The route matches any path under feeds/quotes/, and the captured remainder is available to transforms by name. Without a transform, YARP would forward the full path — the quotes service would receive /feeds/quotes/pricing/start and have no idea what to do with it.
The pathPattern transform. Rewriting the outgoing path to just {**catch-all} strips the routing prefix. The gateway's public URL space (/feeds/quotes/pricing/start) and the service's private URL space (/pricing/start) stay independent. This is the difference between a gateway and a port-forward: internal services get to have clean, prefix-free APIs, and the public shape of the system is a gateway concern you can rearrange without touching a single service.
It costs five nearly identical JSON blocks to say this for five services, and the repetition is honestly a feature. There is no clever convention layer to learn, no route registration code to debug — you read the file, you know the topology.
The same routes, different worlds
The part of the gateway I point people at most often is not the routing — it's what happens across environments. FeedR ships an appsettings.docker.json that is loaded when ASPNETCORE_ENVIRONMENT=docker (each Dockerfile sets exactly that), and it overrides only the clusters:
"yarp": {
"clusters": {
"feeds-quotes": {
"destinations": {
"destination1": { "address": "http://feeds-quotes" }
}
}
}
}
The routes — the public contract — are identical everywhere. The destinations flip from localhost:5040 to http://feeds-quotes, the container's DNS name on the compose network. Because .NET configuration layers JSON files by environment, the docker file only needs to state the values that differ; match patterns and transforms flow through from the base file untouched.
This split is precisely the seam you want between “what the API looks like” and “where it physically runs”. I have seen teams reach for service-discovery infrastructure — Consul agents, custom resolvers — to solve a problem that, at small scale, is literally this: five hostnames that vary by environment, expressed in the configuration system the framework already has. When the destination count per cluster grows past one, YARP's load-balancing policies pick up from the same config. Until then, boring JSON wins.
The transform: one header to rule the trace
Back to the three lines of actual code. Every request passing through the gateway gets a fresh correlation ID:
transforms.AddRequestTransform(transform =>
{
var correlationId = Guid.NewGuid().ToString("N");
transform.ProxyRequest.Headers.AddCorrelationId(correlationId);
return ValueTask.CompletedTask;
});
AddCorrelationId is a small extension in FeedR.Shared that stamps a correlation-id header. Downstream, every FeedR service runs a middleware that reads that header (or mints a new value if absent) and parks it in HttpContext.Items for anything later in the pipeline to use. The gateway is the natural place to originate the ID because it is the one component every external request crosses.
One honest quibble with the implementation: the transform generates a new GUID unconditionally, ignoring any correlation-id the caller may have sent. For a system whose clients are curl and a console app that's fine — arguably even correct, since you shouldn't trust external IDs blindly — but it means the gateway can never join an existing trace, only start one. A production variant would validate-and-propagate an inbound ID and fall back to generating. Keep this in mind for part 8, where I trace how far this ID actually survives (spoiler: not as far as you'd hope).
What this gateway doesn't do
A fair reading requires the other list. FeedR's gateway has:
- No authentication or authorization. Anyone who can reach port 5000 can start the pricing generator. YARP composes with ASP.NET Core auth middleware naturally — the sample just doesn't exercise it.
- No rate limiting, no retries, no circuit breaking. A slow quotes service is a slow gateway response. YARP has passive health checks and .NET 7+ added
RateLimitermiddleware; here the failure model is “you get what the backend gives you”. - One destination per cluster. No load balancing is happening, though the config shape is already plural (
destination1invites adestination2). - No WebSocket/streaming special-casing. Notably, the gRPC pricing stream from part 5 bypasses the gateway entirely — the console client dials the quotes service's HTTP/2 port directly. Proxying gRPC through YARP works, but the sample keeps the gateway HTTP-only.
None of these are criticisms of the design so much as a map of the growth path. The skeleton is right: public shape in routes, physical placement in per-environment clusters, cross-cutting concerns in transforms. Each missing feature has an obvious slot to land in without restructuring anything.
What I'd steal
If I were building a small internal platform tomorrow, I would copy this gateway almost verbatim before reaching for anything heavier. The decision rule I use: an API gateway earns custom code only when it needs behavior — request aggregation, response shaping, protocol translation. For pure routing, prefix-stripping, and header stamping, a config-driven YARP instance is about fifteen lines of C# and a JSON file you can review in a pull request at a glance. FeedR's gateway is exactly that, and the fact that its entire behavior is inspectable from two files is not a limitation of the sample. It's the design.
Next up, part 3 goes inside the quotes service — where an HTTP endpoint that returns in microseconds and a price generator that runs for hours are decoupled by one of my favorite primitives in modern .NET: System.Threading.Channels.