Mert Özen Further With Every Line
Backend API Gateway and YARP — #02

YARP Configuration: Route Matching, Transforms, Config from Code and Live Reload

Mert Özen Nov 14, 2026 15 min 12 views
YARP Configuration: Route Matching, Transforms, Config from Code and Live Reload

In YARP, route order is decided first by Order, then by path, method, host, header and query specificity; path transforms map the external URL to the internal one, and X-Forwarded overwrites the client's value by default, but you have to strip internal headers yourself. Using real Bookstore.Gateway logs, we show the catch-all trap, what a broken config does on live reload, LoadFromMemory, an IProxyConfigProvider that reads SQL Server, and a per-environment IProxyConfigFilter.

Version note: The code in this post was built and run with .NET 10, Yarp.ReverseProxy 2.3, Microsoft.Data.SqlClient 7.1 and SQL Server 2025.

Guess First

A teammate tidies up the route table of Bookstore.Gateway, our YARP gateway, and makes two changes: they add "Order": 10 to the catalog-v1 route, and they add one more route at the bottom for requests that should go to the old app:

"legacy-fallback": {
  "ClusterId": "ordering",
  "Match": {
    "Path": "{**catch-all}"
  }
}

Where does GET /api/v1/books/9780547928227 go? As a path, catalog-v1 is far more specific, and the catch-all was written for "whatever is left". A second question: a client adds X-Forwarded-For: 203.0.113.66 to its own request. When the Catalog service receives that request, what is the value of X-Forwarded-For? The answers are in this post, with real logs.

Route Matching: Path, Method, Host, Headers and Query

In the previous post, the gateway we built for Bookstore, the online bookstore used as the shared example across this site's series, matched routes on the path alone, and the external and internal URLs were the same. Now we give the outside world a versioned URL: the client calls /api/v1/books, while the Catalog service listens on a plain /books internally. We also want different behavior for the mobile app: the mobile client doesn't need prices and authors in the book list, only ISBN and title. The route definitions:

"catalog-v1": {
  "ClusterId": "catalog",
  "Match": {
    "Path": "/api/v1/books/{**rest}"
  },
  "Transforms": [
    { "PathPattern": "/books/{**rest}" }
  ]
},
"catalog-v1-mobile": {
  "ClusterId": "catalog",
  "Match": {
    "Path": "/api/v1/books",
    "Methods": [ "GET" ],
    "Headers": [
      {
        "Name": "X-Client-Platform",
        "Values": [ "mobile" ],
        "Mode": "ExactHeader"
      }
    ]
  },
  "Transforms": [
    { "PathSet": "/books/summary" }
  ]
}

Every condition inside Match is combined with AND: catalog-v1-mobile only matches when the path is /api/v1/books, the method is GET, and the X-Client-Platform header is exactly mobile. Besides ExactHeader, header matching supports HeaderPrefix, Contains, NotContains, Exists and NotExists; the QueryParameters section works the same way for query parameters with Exact, Prefix, Contains, NotContains and Exists. Host matching is an array, such as "Hosts": [ "partner.bookstore.test" ]. Let's send four requests:

curl http://localhost:5300/api/v1/books
[{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":420.00},...]

curl -H "X-Client-Platform: mobile" http://localhost:5300/api/v1/books
[{"isbn":"9780441172719","title":"Dune"},{"isbn":"9780141439587","title":"Emma"},{"isbn":"9780547928227","title":"The Hobbit"},{"isbn":"9780141439549","title":"Middlemarch"}]

curl -X POST -H "X-Client-Platform: mobile" -w "%{http_code}\n" http://localhost:5300/api/v1/books
405

curl http://localhost:5300/api/v1/books/9780547928227
{"isbn":"9780547928227","title":"The Hobbit","author":"J. R. R. Tolkien","price":310.00}

The third request is the interesting one. Because the method is POST, the mobile route didn't match, but the request didn't fall through: the catch-all parameter of catalog-v1 also accepts an empty remainder, so the request went there and was forwarded to Catalog as /books. The 405 came from Catalog, which has no POST endpoint, not from the gateway. A request that misses one route and gets caught by another is the behavior that surprises people most as the route table grows.

Order and Catch-All: In What Order Is the Route Table Read?

Since YARP routes are added to ASP.NET Core routing as endpoints, its rules decide the order. According to the YARP documentation, matching considers the path first, then method, host, headers and finally query; the more specific route wins. When you set Order, it's compared before that specificity check, and the lower value wins. A route without Order gets 0; in YARP's source code that's config.Order.GetValueOrDefault().

I wrote the change from "Guess First" into appsettings.json while the gateway was running. The gateway watches the file and logs this right away:

info: Yarp.ReverseProxy.Configuration.ConfigProvider.ConfigurationConfigProvider[1]
      Loading proxy data from config.

Then two requests:

curl -w "  [%{http_code}]\n" http://localhost:5300/api/v1/books/9780547928227
  [404]
curl -w "  [%{http_code}]\n" http://localhost:5300/api/v1/orders/1001
{"id":1001,"customerEmail":"ada@example.com","total":605.50,"status":"Shipped"}  [200]

info: Bookstore.Gateway[0]
      Route legacy-fallback -> cluster ordering, 1 available destination(s)
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to http://localhost:5302/api/v1/books/9780547928227 HTTP/2 RequestVersionOrLower
info: Bookstore.Gateway[0]
      Forwarded to ordering-1, status 404
info: Bookstore.Gateway[0]
      Route orders-v1 -> cluster ordering, 1 available destination(s)

The book request went to the catch-all, that is, to the Ordering service. The reason: catalog-v1 has Order 10, and the catch-all has the default 0. The catch-all won before specificity was even considered. The order request went to the right place, because orders-v1 also has Order 0, and on a tie the more specific path wins. So the bug shows up in half the table and not in the other half; if you only tested the order flow, you wouldn't notice.

My rule: don't use Order for "tidiness". The default ordering is right in most cases. When you really need it, give Order only to the exceptional route, and give the fallback route a large value (for example "Order": 1000) so that it stays last even if someone later writes positive values on other routes.

Transforms: Shaping the Request for the Backend

Path transforms

The routes above use three different path transforms. I sent, in order, /api/v1/books, /api/v1/books/9780547928227, /api/v1/orders/1001, /api/catalog/books/9780141439587, /api/orders/1001, /api/v1/books with the mobile header, and the POST with the mobile header. The URLs the gateway sent to the backends are in the Proxying to lines:

Proxying to http://localhost:5301/books HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books/9780547928227 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5302/orders/1001 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books/9780141439587 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5302/orders/1001 HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books/summary HTTP/2 RequestVersionOrLower
Proxying to http://localhost:5301/books HTTP/2 RequestVersionOrLower

PathPattern places the route's parameters into a new path template; it's handy that it drops the trailing slash when {**rest} is empty and produces /books. PathSet replaces the path completely, PathRemovePrefix removes the leading segment, and PathPrefix adds one. The query string isn't affected by these transforms and goes through as is; if you want to change it, there are QueryValueParameter and QueryRemoveParameter. We didn't delete the old /api/catalog/** URLs either: the catalog-legacy route goes to the same cluster with PathRemovePrefix. When you retire an external URL, this is the cheapest way to give clients a transition period.

Header transforms and X-Forwarded

To see what the backend actually receives, I added an endpoint to Catalog that only exists in the Development environment. It returns the path, query, Host and the headers starting with X- as JSON. On the gateway side, the route to that endpoint is defined only in appsettings.Development.json:

"catalog-debug": {
  "ClusterId": "catalog",
  "Match": {
    "Path": "/debug/catalog"
  },
  "Transforms": [
    { "PathSet": "/debug/request" },
    { "RequestHeaderOriginalHost": "true" }
  ]
}

Now let's act like a malicious client and send fake headers:

curl "http://localhost:5300/debug/catalog?lang=tr" \
  -H "X-Forwarded-For: 203.0.113.66" -H "X-Forwarded-Host: evil.example" \
  -H "Forwarded: for=203.0.113.66" -H "X-Bookstore-User-Id: 1" \
  -H "X-Client-Platform: web"
{
    "path": "/debug/request",
    "query": "?lang=tr",
    "host": "localhost:5300",
    "headers": {
        "X-Bookstore-Route": "catalog-debug",
        "X-Client-Platform": "web",
        "X-Forwarded-For": "::1",
        "X-Forwarded-Host": "localhost:5300",
        "X-Forwarded-Proto": "http"
    }
}

Here's the answer to the second question from "Guess First". By default YARP adds the X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Prefix transforms to every route in Set mode: it deletes what the client sent and writes what it saw itself. X-Forwarded-For holds the address curl connected from, ::1, the IPv6 loopback, not the IP the client made up. The Forwarded header the client sent was removed entirely; version 2.3 added a parameter to AddXForwarded to turn that off, and the default is to remove it. Thanks to RequestHeaderOriginalHost, Catalog sees localhost:5300, the host the client used, instead of localhost:5301; that matters for backends that build absolute URLs or behave differently per host.

What if we'd used Append instead of Set? In chains with another proxy in front, that can be necessary, because each proxy appends its own address to the list. But it's dangerous when the gateway faces the internet directly. When I added { "X-Forwarded": "Append" } to the same route and turned off our custom transform, Catalog saw this:

"headers": {
    "X-Bookstore-User-Id": "1",
    "X-Forwarded-For": "203.0.113.66, ::1",
    "X-Forwarded-Host": "localhost:5310",
    "X-Forwarded-Proto": "http"
}

The fake IP landed at the head of the list. If the ForwardedHeaders middleware in the backend is configured wrong, rate limits, audit logs and IP restrictions use that fake address. We covered how a backend decides which proxies to trust in the forwarded headers post of the Web Servers and Hosting series. X-Bookstore-User-Id is even worse: by default YARP copies the client's headers as is, so the client can pre-fill an internal header that the gateway itself is supposed to set. The transform in the next section closes that hole.

Transforms in Code: One Rule for Every Route

Transforms in JSON are per route. For a rule that applies to every route, we use AddTransforms. Bookstore has a rule: headers starting with X-Bookstore- are written by the gateway only. Anything with that prefix in the incoming request is removed, then the gateway adds X-Bookstore-Route with the route the request went through. In post 04 we'll carry the authenticated user's ID with the same prefix, so this rule needs to be in place already.

using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;

namespace Bookstore.Gateway.Transforms;

public static class InternalHeaderTransforms
{
    // runs once per route when the config is (re)loaded, not once per request
    public static void Apply(TransformBuilderContext context)
    {
        string routeId = context.Route.RouteId;

        context.AddRequestTransform(transformContext =>
        {
            foreach (string name in transformContext.HttpContext.Request.Headers.Keys)
            {
                if (name.StartsWith(InternalHeaders.Prefix, StringComparison.OrdinalIgnoreCase))
                {
                    transformContext.ProxyRequest.Headers.Remove(name);
                }
            }

            transformContext.ProxyRequest.Headers.TryAddWithoutValidation(InternalHeaders.Route, routeId);
            return ValueTask.CompletedTask;
        });
    }
}

// Program.cs
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(InternalHeaderTransforms.Apply)
    .AddConfigFilter<DestinationAddressFilter>();

Two details matter. First, the Apply method itself doesn't run per request; it runs once per route when the config loads and builds that route's transform list. Per-route decisions (add to this route, not to that one) are made there; per-request work happens inside the delegate you pass to AddRequestTransform. Second, transforms are for headers, path and query; don't touch the request body. According to the YARP 2.3 release notes, replacing HttpRequestMessage.Content in a request transform now throws, because YARP relies on that property staying unchanged internally. If you need to change the body, that's usually not the gateway's job; if you really must, do it in a middleware that runs before the YARP pipeline, with size limits.

Validating Config and Live Reload

LoadFromConfig watches configuration changes; when you save appsettings.json, the new route table takes effect within a few seconds and the app doesn't restart. That's powerful, but a wrong change goes live just as fast. YARP 2.3 brought a JSON schema for the config: the package registers the schema for the ReverseProxy section of appsettings*.json files with Visual Studio, and the editor warns about wrong field names and invalid enum values. The release notes also give its GitHub address for anyone who wants the latest schema without updating the package. But the schema is only editor help; it doesn't change runtime behavior. I checked what happens at runtime with three experiments.

When I started the app with a wrong header mode ("Mode": "Exact"; for headers it's ExactHeader):

fail: Yarp.ReverseProxy.Configuration.ConfigProvider.ConfigurationConfigProvider[47]
      Configuration data conversion failed.
      System.ArgumentException: Requested value 'Exact' was not found.
Unhandled exception. System.InvalidOperationException: Unable to load or apply the proxy configuration.

The app doesn't start at all. That's good behavior: with a broken config, the deployment pipeline goes red. When I wrote the same error into the file while the app was running, the same fail line was logged, but the gateway kept running and kept serving requests with the last valid config. So in a live reload, a broken change doesn't cut traffic, but it doesn't tell you it wasn't applied either; you need to watch that log line as an alert.

The third experiment is sneakier: I changed a route's ClusterId to a cluster that doesn't exist (orders). The app started without errors. A request to that route got this:

HTTP/1.1 503 Service Unavailable
Content-Length: 0

info: Yarp.ReverseProxy.Model.ProxyPipelineInitializerMiddleware[4]
      Route 'orders-v1' has no cluster information.

The best protection against errors that aren't caught at startup is to start the gateway in an integration test and send one request to every route before you deploy the config.

Config from Code and from a Database

LoadFromMemory and InMemoryConfigProvider.Update

The route table doesn't always have to live in a file. LoadFromMemory takes routes and clusters from code and registers an InMemoryConfigProvider in the DI container. With that provider's Update method you can change the table at runtime. The samples/Bookstore.Gateway.InMemory project in the repo shows this with an endpoint that changes the Catalog cluster's address:

builder.Services.AddReverseProxy()
    .LoadFromMemory(
        [
            new RouteConfig
            {
                RouteId = "catalog-v1",
                ClusterId = "catalog",
                Match = new RouteMatch { Path = "/api/v1/books/{**rest}" },
                Transforms = [new Dictionary<string, string> { ["PathPattern"] = "/books/{**rest}" }],
            },
        ],
        [CatalogCluster("http://localhost:5301/")]);

var app = builder.Build();

app.MapReverseProxy();

if (app.Environment.IsDevelopment())
{
    // points the cluster to another address without a restart
    app.MapPost("/admin/catalog-address", (string address, InMemoryConfigProvider provider) =>
    {
        IProxyConfig current = provider.GetConfig();
        provider.Update(current.Routes, [CatalogCluster(address)]);
        return Results.Ok(new { previousRevision = current.RevisionId, address });
    });
}
curl -X POST "http://localhost:5309/admin/catalog-address?address=http://localhost:5311/"
{"previousRevision":"9f727934-0c1d-45a3-a72b-8138156fca75","address":"http://localhost:5311/"}

      Proxying to http://localhost:5301/books/9780141439549 HTTP/2 RequestVersionOrLower
      Proxying to http://localhost:5311/books/9780141439549 HTTP/2 RequestVersionOrLower

The request before the update went to 5301, the one after it to 5311. This endpoint is only available in Development and has no identity check; if you write a real admin API, put the authorization from post 04 in front of it.

An IProxyConfigProvider that reads from a database

If you want to keep the route table somewhere the operations team manages, you write your own IProxyConfigProvider. The contract is small: GetConfig() returns the current IProxyConfig; when that config's ChangeToken fires, YARP calls GetConfig() again. In Bookstore we keep extra routes in the dbo.GatewayRoutes table of the BookstoreGatewayDb database on SQL Server (the script that creates it is db/gateway-config.sql in the repo). Clusters stay in appsettings.json; YARP merges multiple config sources, and a route from one source can use a cluster from another.

public sealed class SqlProxyConfigProvider(
    string connectionString,
    TimeSpan pollInterval,
    ILogger<SqlProxyConfigProvider> logger) : BackgroundService, IProxyConfigProvider
{
    private volatile SqlProxyConfig _config = new([], "empty");

    public IProxyConfig GetConfig() => _config;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(pollInterval);
        do
        {
            try
            {
                await ReloadAsync(stoppingToken);
            }
            catch (SqlException ex)
            {
                // keep serving the last good config when the database is unreachable
                logger.LogWarning(ex, "Could not read gateway routes, keeping revision {Revision}", _config.RevisionId);
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }

    private async Task ReloadAsync(CancellationToken cancellationToken)
    {
        List<RouteConfig> routes = [];
        StringBuilder fingerprint = new();

        await using SqlConnection connection = new(connectionString);
        await connection.OpenAsync(cancellationToken);
        await using SqlCommand command = new(
            """
            SELECT RouteId, ClusterId, Path, [Order], PathPattern
            FROM dbo.GatewayRoutes
            WHERE IsEnabled = 1
            ORDER BY RouteId
            """, connection);
        await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);

        while (await reader.ReadAsync(cancellationToken))
        {
            RouteConfig route = new()
            {
                RouteId = reader.GetString(0),
                ClusterId = reader.GetString(1),
                Match = new RouteMatch { Path = reader.GetString(2) },
                Order = reader.IsDBNull(3) ? null : reader.GetInt32(3),
                Transforms = reader.IsDBNull(4)
                    ? null
                    : [new Dictionary<string, string> { ["PathPattern"] = reader.GetString(4) }],
            };
            routes.Add(route);
            fingerprint.Append(route.RouteId).Append('|').Append(route.ClusterId).Append('|')
                .Append(route.Match.Path).Append('|').Append(route.Order).Append('|')
                .Append(reader.IsDBNull(4) ? "" : reader.GetString(4)).Append(';');
        }

        string revision = Convert.ToHexString(
            System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(fingerprint.ToString())))[..12];
        if (revision == _config.RevisionId)
        {
            return;
        }

        SqlProxyConfig old = _config;
        _config = new SqlProxyConfig(routes, revision);
        old.SignalChange();
        logger.LogInformation("Loaded {Count} route(s) from SQL, revision {Revision}", routes.Count, revision);
    }

    private sealed class SqlProxyConfig : IProxyConfig
    {
        private readonly CancellationTokenSource _changed = new();

        public SqlProxyConfig(IReadOnlyList<RouteConfig> routes, string revisionId)
        {
            Routes = routes;
            RevisionId = revisionId;
            ChangeToken = new CancellationChangeToken(_changed.Token);
        }

        public string RevisionId { get; }
        public IReadOnlyList<RouteConfig> Routes { get; }
        public IReadOnlyList<ClusterConfig> Clusters { get; } = [];
        public IChangeToken ChangeToken { get; }

        public void SignalChange() => _changed.Cancel();
    }
}

The provider reads the table every 10 seconds, computes a fingerprint from the rows, and only publishes a new config and fires the old one's token when the content changed. Since the table has a handful of rows, reading all of it every time is simpler and less error-prone than relying on an UpdatedAt column and triggers. It's registered only when a connection string is given; put the connection string in an environment variable (ConnectionStrings__GatewayConfig), not in a file:

string? gatewayDb = builder.Configuration.GetConnectionString("GatewayConfig");
if (!string.IsNullOrEmpty(gatewayDb))
{
    builder.Services.AddSingleton(sp => new SqlProxyConfigProvider(
        gatewayDb,
        TimeSpan.FromSeconds(10),
        sp.GetRequiredService<ILogger<SqlProxyConfigProvider>>()));
    builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<SqlProxyConfigProvider>());
    builder.Services.AddHostedService(sp => sp.GetRequiredService<SqlProxyConfigProvider>());
}

Let's add a v2 route to the table while the gateway is running:

curl -o /dev/null -w "%{http_code}\n" http://localhost:5300/api/v2/books/9780441172719
404

INSERT dbo.GatewayRoutes (RouteId, ClusterId, Path, PathPattern)
VALUES (N'catalog-v2', N'catalog', N'/api/v2/books/{**rest}', N'/books/{**rest}');

info: Bookstore.Gateway.Configuration.SqlProxyConfigProvider[0]
      Loaded 1 route(s) from SQL, revision 16D84FEED90D

curl -w "  [%{http_code}]\n" http://localhost:5300/api/v2/books/9780441172719
{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":420.00}  [200]

When I set the row to IsEnabled = 0, the next read logged Loaded 0 route(s) and the same request returned 404 again. When I froze the database on purpose (I stopped the container with docker pause), the query waited until the command timeout, then the provider wrote this warning and the v2 route kept working:

warn: Bookstore.Gateway.Configuration.SqlProxyConfigProvider[0]
      Could not read gateway routes, keeping revision 16D84FEED90D

That's deliberate: if the gateway lost its routes because the database went down, a database outage would become an outage of the whole site.

IProxyConfigFilter: Addresses per environment

The route table is the same in every environment, but the addresses change: localhost:5301 in development, catalog:8080 in a container environment. Instead of copying the whole config for each environment, we use YARP's config filter. The filter runs on every route and cluster before it's handed to YARP, whatever source it came from:

// if DestinationAddresses:catalog-1 is set, it replaces the destination address
public sealed class DestinationAddressFilter(IConfiguration configuration) : IProxyConfigFilter
{
    public ValueTask<ClusterConfig> ConfigureClusterAsync(ClusterConfig cluster, CancellationToken cancel)
    {
        if (cluster.Destinations is null)
        {
            return new(cluster);
        }

        Dictionary<string, DestinationConfig> destinations = new(StringComparer.OrdinalIgnoreCase);
        foreach ((string id, DestinationConfig destination) in cluster.Destinations)
        {
            string? address = configuration[$"DestinationAddresses:{id}"];
            destinations[id] = string.IsNullOrEmpty(address)
                ? destination
                : destination with { Address = address };
        }

        return new(cluster with { Destinations = destinations });
    }

    public ValueTask<RouteConfig> ConfigureRouteAsync(
        RouteConfig route, ClusterConfig? cluster, CancellationToken cancel) => new(route);
}
dotnet run --project src/Bookstore.Gateway -- --DestinationAddresses:catalog-1=http://localhost:5311/
curl -o /dev/null -w "%{http_code}\n" http://localhost:5300/api/v1/books
200
      Proxying to http://localhost:5311/books HTTP/2 RequestVersionOrLower

Here I passed the value as a command line argument. Because the destination ID contains a hyphen, bash and zsh can't export that key as an environment variable; you can use appsettings.Production.json, the command line, or your container platform's environment settings. ClusterConfig and DestinationConfig are records, so copying with with is enough; YARP's config objects are immutable.

Production Notes

Treat live reload as a change channel and protect it accordingly. If you read from a file, control who can change the file; if you read from a database, control who can write to the table. Whoever can write the gateway's route table can send all traffic wherever they want. Watch every config load (Loading proxy data from config, our own Loaded N route(s) from SQL line), and especially the Configuration data conversion failed error; the latter means "your change was not applied".

In a gateway with multiple instances, reloading from a file means each instance sees its own file. If you baked the config into the container image, reload is pointless anyway, and changes arrive with a new deployment; if you read the config from a shared source (a database, a config service), instances update a few seconds apart. In between, the same request can go to different routes on two instances; plan your transitions accordingly. We'll cover this in detail in post 07.

Watch header size and count in header transforms too. Every header the gateway adds counts toward the backend's header limits. Instead of blindly forwarding every header the client sends, you can use RequestHeadersAllowed to let only allowed headers through when needed.

Common Pitfalls

First, trusting internal headers that come from the client. YARP copies all headers by default; if you don't remove every header the gateway is supposed to set, such as X-User-Id, X-Tenant or X-Internal-*, on the way in, the client fills them in itself. In Bookstore we solved this with a single rule based on the X-Bookstore- prefix.

Second, setting X-Forwarded to Append on an internet-facing gateway. Unless there's a truly trusted proxy in the chain, the first address is always the one the client wrote.

Third, using Order to "sort" routes. As you saw above, a catch-all with the default 0 swallows every route you gave a positive Order.

Fourth, assuming a transform runs per request. If you try to read request data in the body of the delegate you pass to AddTransforms, there is no request; that code runs once per route while the config loads.

Fifth, publishing an empty config on every error in a database-backed provider. If the read fails, keep the last valid config; you may also want to check whether a "0 routes" result is really intentional.

A Small Experiment

★☆☆ Change the catalog-v1-mobile route to match on a ?client=mobile query parameter instead of the X-Client-Platform header, and use a route like /debug/catalog to check which path and query the /api/v1/books?client=mobile request reaches Catalog with.

★★☆ Add the legacy-fallback route back, but in the right order this time: give the catch-all "Order": 1000 and keep the Order on catalog-v1. Use the log to confirm which route /api/v1/books/9780547928227 and /api/unknown go through.

★★★ Add a Hosts column to dbo.GatewayRoutes (comma separated) and read it in SqlProxyConfigProvider. Then write a step that validates the routes the provider reads before handing them to YARP: if the ClusterId isn't one of the known clusters, skip that row and log a Warning. That way the 503 "no cluster information" case we saw above never happens for routes coming from the database.

The route table is flexible now, but every cluster still has a single destination. In the next post we scale Catalog to three instances: we'll compare load balancing policies, kill an instance and measure how active and passive health checks react, and discuss when session affinity is really needed.