API Gateway and BFF: A Single Entry Point and Aggregation for Angular with YARP
Bookstore.Bff becomes Angular's single entry point with YARP: proxy routes resolved through service discovery, a rewritten Location header that no longer leaks the internal address, and a rate limit on the order route. The book page comes from an aggregation endpoint that calls Catalog and Inventory in parallel; with Inventory down the page arrives without stock in 533 ms, with Catalog down it returns 503 in 2 seconds instead of a 500 after 30.
Version note: The code in this post was built and run with .NET 10, Aspire 13.6, YARP 2.3, Microsoft.Extensions.ServiceDiscovery.Yarp 10.11 and Grpc.Net.ClientFactory 2.85.
Guess First
The book page in Angular shows two things: the book's details from Catalog and the current stock from Inventory. We write an endpoint that combines them, and in the first version we start both calls in parallel and wait for both. With Inventory down, what does the page return and how long does it take? With Catalog down? The second answer surprised us too on the first try.
Why a Separate BFF Project?
Until now every service sat on its own port: Catalog on 5101, Ordering on 5102. If Angular called them directly, the browser would know five addresses, every service would need CORS settings, and in post 10 each one would manage sessions on its own. In the API Gateway and Backend for Frontend post of the Microservices series we argued for a single entry point instead, dedicated to one client (here, Angular): a BFF.
In Bookstore, the online bookstore used as the shared example across this site's series, that entry point is Bookstore.Bff. We could also have declared a YARP container in the AppHost with Aspire's Aspire.Hosting.Yarp package and given it its routes there. That would be enough if all we needed was a proxy; our BFF will contain code: an aggregation endpoint for the book page in this post, OIDC sessions and token handling in 10, the Angular files in 11. So it is our own ASP.NET Core project with YARP as a library inside it. The project lives in src/Services/Bff, so the architecture test from 01 applies to it as well: it cannot reference another service's implementation, only contract projects such as Bookstore.Inventory.Contracts.

Proxy Routes and Service Discovery
The routes are in the ReverseProxy section of appsettings.json. The path the browser sees and the service's path are separate: the BFF turns /api/catalog/books into Catalog's /api/v1/books. API versioning (the Versioning post of the ASP.NET Core Web API series) stays an internal detail of the services; Angular never sees a version number:
"catalog": {
"ClusterId": "catalog",
"Match": { "Path": "/api/catalog/{**rest}" },
"Transforms": [ { "PathPattern": "/api/v1/{**rest}" } ]
},
"orders-place": {
"ClusterId": "ordering",
"Match": { "Path": "/api/orders", "Methods": [ "POST" ] },
"RateLimiterPolicy": "place-order",
"Transforms": [ { "PathPattern": "/api/v1/orders" } ]
}
...
"Clusters": {
"catalog": {
"HttpRequest": { "ActivityTimeout": "00:00:05" },
"Destinations": { "catalog": { "Address": "https+http://catalog" } }
},
The clusters' address is https+http://catalog, the Aspire service discovery form we have used with HttpClients since 02. YARP cannot resolve it on its own; the Microsoft.Extensions.ServiceDiscovery.Yarp package adds a destination resolver:
// Proxy routes from configuration; "https+http://catalog" addresses are resolved by Aspire's service discovery.
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddServiceDiscoveryDestinationResolver()
The BFF is the only resource marked with WithExternalHttpEndpoints() in the AppHost, on port 5100. Locally the other services keep their ports for debugging; when we move to Kubernetes in 14, only the BFF will be exposed. Search now goes through the BFF:
curl "localhost:5100/api/catalog/books?search=emma&pageSize=2"
{"items":[{"isbn":"9780141439587","title":"Emma: A Novel","author":"Jane Austen","category":"Classics","price":199.90,"currency":"TRY","publishedYear":1815,"version":7}],"page":1,"pageSize":2,"totalCount":1}
The details (route match rules, transforms, live config reload) are in the YARP Configuration post of the API Gateway and YARP series.
The Location Header: Not Leaking the Internal Address
Since 07 Ordering answers an order with 202 and a Location header: Location: http://localhost:5102/api/v1/orders/01a1da7d-057f-70b4-bd4d-8621c2cbe883. YARP copies response headers as they are. If the browser followed that address it would try to skip the BFF and go straight to Ordering; in production we would also be handing out an address from the internal network. We added a response transform to the ordering cluster:
.AddTransforms(context =>
{
// Ordering answers 202 with Location: http://ordering-host/api/v1/orders/{id}. The browser must
// see the BFF's public path, never an internal address.
if (context.Route.ClusterId == "ordering")
{
context.AddResponseTransform(transform =>
{
if (transform.HttpContext.Response.Headers.Location.ToString() is { Length: > 0 } location &&
Uri.TryCreate(location, UriKind.Absolute, out Uri? internalUri))
{
transform.HttpContext.Response.Headers.Location = internalUri.AbsolutePath.Replace("/api/v1/orders", "/api/orders");
}
return ValueTask.CompletedTask;
});
}
});
An order sent through the BFF now gets Location: /api/orders/01a1da94-3c53-76a7-8e01-54632a360333, and that path reaches Ordering through the orders-read route.
Aggregation: One Request for the Book Page
The book page has its own endpoint: GET /api/books/{isbn}. It calls Catalog over HTTP and Inventory through the GetStock RPC of the gRPC contract from 05, and combines the two answers into one JSON body. Both calls start at the same time; the page waits for the slower one, not for their sum. We set one rule: without Inventory's answer the page is still shown and the stock is null. Without Catalog's answer there is no page to show.
// The page's own budget for Catalog. Without it the standard resilience handler waits up to 30 seconds
// (10-second attempts, retried) before giving up.
using CancellationTokenSource catalogBudget = CancellationTokenSource.CreateLinkedTokenSource(ct);
catalogBudget.CancelAfter(TimeSpan.FromSeconds(2));
// Both calls start at once; the page waits for the slower one, not for the sum.
Task<CatalogBook?> bookTask = catalog.GetBookAsync(isbn, catalogBudget.Token);
Task<StockInfo?> stockTask = GetStockAsync(isbn, inventory, clock, logger, ct);
CatalogBook? book;
try
{
book = await bookTask;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutRejectedException or OperationCanceledException && !ct.IsCancellationRequested)
{
// Without Catalog there is no page to show.
logger.LogWarning("Catalog failed for {Isbn}: {Error}", isbn, ex.GetType().Name);
return Results.Problem(title: "The book page is temporarily unavailable", statusCode: StatusCodes.Status503ServiceUnavailable);
}
GetStockAsync gives the gRPC call a 500 ms deadline and returns null on an RpcException. The result:
{"isbn":"9780141439587","title":"Emma: A Novel","author":"Jane Austen","category":"Classics","price":199.90,"currency":"TRY","publishedYear":1815,"description":"A young woman in a small English village keeps arranging other people's marriages.","stock":{"status":"InStock","available":17}}
HTTP 200 in 0.187830s
The first request took 188 ms; the next three took 4–6 ms. The first question from Guess First: we stopped Inventory.
{"isbn":"9780141439587","title":"Emma: A Novel", ... "stock":null}
HTTP 200 in 0.533609s
[bff] Stock for 9780141439587 unavailable: DeadlineExceeded
The page came back in 533 ms: the 500 ms deadline plus a little. The partial failure worked as designed. The second question is more instructive. The first version of the code had no catalogBudget and caught only HttpRequestException. We stopped Catalog:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500, ...}
HTTP 500 in 30.013406s
[bff] Resilience event occurred. EventName: 'OnTimeout', Source: '-standard//Standard-AttemptTimeout', Operation Key: '', Result: ''
[bff] Resilience event occurred. EventName: 'OnRetry', Source: '-standard//Standard-Retry', Operation Key: '', Result: 'The operation didn't complete
within the allowed timeout of '00:00:10'.'
[bff] Polly.Timeout.TimeoutRejectedException: The operation didn't complete within the allowed timeout of '00:00:30'.
Thirty seconds and a 500. The standard resilience handler from ServiceDefaults gives each attempt 10 seconds, repeats the GET and gives up after 30 seconds in total; the TimeoutRejectedException it throws when it gives up is not an HttpRequestException. As we saw in 05, Aspire's proxy accepts a connection to a stopped project and never answers; in production a stopped pod usually refuses the connection right away, but a slow Catalog causes the same 30 seconds. The defaults are reasonable for a service-to-service call, not for a page a user is waiting for. We gave the page its own 2-second budget and caught the cancellation too:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"The book page is temporarily unavailable","status":503,"traceId":"00-b61b671a789f3ab637fa3d5bcd3f8d17-58aa7be0f5ac9047-03"}
HTTP 503 in 2.052233s
The aggregation lives in the BFF because it is shaped by the needs of one screen. It holds no business rule: it combines two answers and decides what the screen shows when one is missing. A rule such as "don't show a discount when stock is below 3" does not belong here but in the service that owns it.
Timeouts and Rate Limits
The proxy routes need a time limit too. On the first try we used YARP's per-route "Timeout": "00:00:05" setting, which works with ASP.NET Core's request timeout middleware. With Catalog down the proxy route answered in 5 seconds, but the status code was a surprise: HTTP 400 in 5.009732s. We looked at YARP's source code. When the middleware cancels the request, YARP sees it through HttpContext.RequestAborted, assumes the client went away and writes 400. A browser that receives a 400, which says its own request was wrong, looks in the wrong place. We moved the limit to the cluster's HttpRequest.ActivityTimeout setting, which YARP enforces itself:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.5","title":"Gateway Timeout","status":504,"traceId":"00-4d4eb92f60b1f4f7469339c8d31a553c-f435fbe0e71d9a3f-03"}
HTTP 504 in 5.016410s
ActivityTimeout is not a total duration but a silence duration: it resets every time the destination sends data. For our short JSON answers both give the same result; on a route that downloads a large file the difference matters. How the two settings interact is described on the YARP Request Timeouts page on Microsoft Learn.
Placing an order is the BFF's most expensive route: behind it there is a saga, a stock reservation and a payment. We put a limit on the orders-place route with ASP.NET Core's rate limiter: 5 orders per 10 seconds per client address.
// Placing an order is the expensive route: at most 5 per 10 seconds per client address.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("place-order", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromSeconds(10), QueueLimit = 0 }));
});
POST 1 -> HTTP 202
POST 2 -> HTTP 202
POST 3 -> HTTP 202
POST 4 -> HTTP 202
POST 5 -> HTTP 202
POST 6 -> HTTP 429
POST 7 -> HTTP 429
A rate limit in the BFF does not replace Ordering protecting itself. A client that skips the BFF (another internal service, a script) can reach Ordering directly; we close that with authentication in 10. The details of cross-cutting concerns at the gateway are in the fourth post of the API Gateway and YARP series.
In Production
The rate limit's key must be the real client. If the BFF sits behind a load balancer, RemoteIpAddress is the load balancer's address and all users share one bucket. The ForwardedHeaders middleware should be configured to accept those headers only from trusted proxies. After 10 the key can be the signed-in user.
This limiter runs in memory. With three copies of the BFF, the limit is effectively three times higher. If you need a consistent global limit, the counter goes into a shared store such as Redis; a per-instance limit is often protection enough.
There is no CORS setting, on purpose. The Angular files come from the BFF's wwwroot and the API calls go to the same origin. When we put the Angular build output there in 11, the browser will see a single origin.
The BFF is a service too. Every request passes through it, so its logs and traces are where following an order starts; in 12 we start the distributed traces here.
Common Pitfalls
Putting business rules into the gateway. If price calculation, stock thresholds or order validation start growing in the BFF, it becomes a second monolith and every team waits for the BFF team to ship.
Giving the aggregation no budget. The default timeouts of the downstream calls are tuned for service-to-service traffic; an endpoint a user waits on should set its own, shorter budget.
Awaiting the downstream calls one after the other. Writing await catalog and then await inventory makes the page time the sum of both calls; starting both tasks first and awaiting them afterwards is enough.
Forgetting internal addresses in response headers. Location, Link and redirects carry the service's own address; if the proxy does not translate them, the client is sent into the internal network.
Not checking a timeout's status code. A wrong code like the 400 above misleads alerts and the client's retry decision.
A Small Experiment
★☆☆ Call /api/catalog/categories through the BFF and look at the response headers. Which headers of the answer from Catalog's output cache survive the trip through the BFF?
★★☆ Add a third source to the book page: from Ordering, whether the customer bought this book before. That information is user-specific; does it make more sense to add it before or after 10? Is it optional like Inventory or required like Catalog?
★★★ Make the rate limiter shared across instances: write a PartitionedRateLimiter that keeps its counter in Redis, run two copies of the BFF and measure that the limit stays at 5 in total.
The BFF is now Angular's single entry point, but it does not know who is calling: the customer placing the order is still written in the request body, and Catalog's admin endpoints are open to everyone. In the next post we set up the Keycloak realm: an OIDC cookie session in the BFF, JWT validation in every service, carrying the user's token to Ordering, client credentials for Inventory, and identity that travels with events.