For the cutover decision, request owner assertions, generated-link checks, and an exercised rollback boundary. Passing status-code tests alone cannot establish that the intended application handled the request.
What to ask before approving the cutover
An incremental migration can keep the public URL unchanged while changing which application handles it. For a VP of Engineering or Technology reviewing a cutover, the question is whether the team can prove that the agreed traffic moved and show how to return ownership to the legacy application.
Ask the team for an ownership matrix exercised against the boundary cases, tests that follow generated URLs back to the intended application, and a rollback check that changes the request owner. A successful response alone answers none of those questions. The example below shows how that gap can survive an otherwise plausible implementation.
Consider moving the order-details GET endpoint from ASP.NET Framework into ASP.NET Core while the old application still exports orders and accepts updates. Both applications can return the same JSON for order 42, so a test that checks 200 and the response body can pass even after the request goes to the wrong application.
The first decision is the boundary. In this example, Core owns GET requests whose final segment fits a signed 32-bit integer, including IDs that do not exist. The legacy application owns the export and every write. This is an illustrative contract to agree with your team, not a rule that every migration should use.
The examples target ASP.NET Core 10. The companion routing harness was compiled and run with .NET SDK 10.0.401 and ASP.NET Core 10.0.12. It uses a local legacy stub, so the results below establish endpoint selection, not real proxy, authentication, or database behavior.
Request Selected owner Expected result GET /orders/42 Core 200, order 42 GET /orders/999 Core 404, no legacy attempt GET /orders/export Legacy CSV export POST /orders/42 Legacy Existing update behavior GET /orders/abc Legacy Existing response GET /not-a-real-route Legacy Existing 404 behavior
Put the export on the correct side of the example
Core has one local order route. There is no local /orders/export endpoint. Export exists only in the Framework application behind the proxy. After registering YARP services with AddReverseProxy, the relevant endpoint registrations look like this:
ReadOrder is the migrated handler; legacyOrigin is the configured internal address of the Framework application. Authentication and the rest of the application are omitted from this routing excerpt. The low-priority forwarder accepts all HTTP methods. Microsoft documents this arrangement for incremental migration.
For GET /orders/export, the :int constraint rules out the local endpoint. The catch-all can then forward it. For POST /orders/42, the GET restriction rules out the local endpoint, while the all-method forwarder remains eligible. If you remove the forwarder, the same POST can instead produce 405 Method Not Allowed.
A local literal /orders/export route would be a different example. At equal Order, it is more specific than /orders/{id} and wins. Moving MapGet lines around is not a reliable way to decide which application owns a request.
app.MapGet("/orders/{id:int}", ReadOrder)
.WithName("orders.read");
app.MapForwarder("/{**path}", legacyOrigin)
.WithOrder(int.MaxValue);Removing a constraint changes ownership before the handler runs
Now remove :int while keeping the handler parameter as int. It may look redundant: the method already declares the required type. But those two declarations participate in different decisions.
Endpoint selection now admits /orders/export to the Core route. Only afterwards does parameter binding attempt to turn export into an int. With ThrowOnBadRequest disabled, that conversion failure produces 400. ReadOrder is never called. The legacy export is never called either.
Changing the parameter to string and returning NotFound for an unrecognized value does not restore the boundary. It changes the response to a Core 404. The request has already selected a Core endpoint.
// Broken for this migration boundary:
app.MapGet("/orders/{id}", (int id) => ReadOrder(id));
// GET /orders/export
// 1. Core route matches.
// 2. int binding fails.
// 3. Core returns 400; legacy is not attempted.
// The companion mechanism test verifies this response and owner.A Core 404 does not mean try the old application
Consider /orders/999 with the original constrained route. The value 999 is an int, so Core owns the request. If the order does not exist, the handler returns 404. Normal endpoint execution does not select the catch-all afterwards. A fallback route participates in selection; it is not a retry policy for unsuccessful responses.
This matters for more than a missing record. If Core denies a request, forwarding it elsewhere in search of a successful response can undermine the intended authorization decision. Keep ownership independent of whether the selected application likes the request. An application that deliberately uses status-code re-execution or custom retry middleware needs separate analysis; this example has neither.
It also changes the rollback design. A feature flag inside ReadOrder that returns 404 has not returned ownership to Framework. For a simple startup-controlled switch, omit the Core registration and restart or redeploy the frontend. Then verify that the legacy application actually handles the request. A live switch needs an explicit dispatch design and the same ownership tests.
Decide what the route constraint is allowed to mean
Using :int here separates a numeric resource address from legacy nonnumeric paths. It does not validate that an order exists, belongs to the caller, or may be edited. Those are application decisions after selection.
Check the real identifier domain before adopting the example. If the old system accepts 64-bit IDs, :int would send larger IDs back to legacy. Adding :min(1) would also move zero and negative values outside the Core route. Are those requests supposed to receive a Core validation response or retain legacy behavior? Write that down and test the boundary values.
If Core should own every /orders/... request and reject malformed IDs itself, use a broader boundary and intentionally migrate or explicitly forward the reserved paths. If the old URL scheme mixes several resource kinds, a new prefix can simplify routing, but clients and generated links must change. The appropriate choice depends on the compatibility promise you are making.
Observe selection before parameter binding
A marker written inside ReadOrder would miss the broken export request because binding fails first. In the test harness, add owner metadata to the endpoints and read it in middleware after UseRouting but before endpoint execution.
The fixture labels the local catch-all legacy-stub and counts its invocations. X-Selected-Owner establishes which endpoint was selected inside Core. It does not prove that a remote application received anything, and it must not drive authorization. Keep this instrumentation in the test environment.
This sample intentionally omits ShortCircuit. The migration documentation shows that option on the forwarder; it executes the endpoint during routing and skips this observer. If your real pipeline uses it, observe selection from middleware registered before routing with an OnStarting callback, or use suitable traces. Do not add a test header after the response has started.
// Pipeline observer; register before endpoint execution:
app.UseRouting();
app.Use(async (HttpContext context, RequestDelegate next) =>
{
var owner = context.GetEndpoint()?.Metadata
.GetMetadata<RouteOwner>()?.Name;
context.Response.Headers["X-Selected-Owner"] =
owner ?? "unclassified";
await next(context);
});
app.MapGet("/orders/{id:int}", (int id) => ReadOrder(id))
.WithName("orders.read")
.WithMetadata(new RouteOwner("core"));
internal sealed record RouteOwner(string Name);Test the boundary rather than a successful response
Download the companion test project from the first link under Sources. It includes the complete fixture, tests, and baseline and mutation run instructions.
The companion project builds the pipeline with TestServer. Its local catch-all returns the same successful order response as Core for /orders/42, so the owner assertion has work to do. The POST stub does not persist an update; it only demonstrates method-based selection.
Each test starts a fresh fixture. Besides status, assert selected owner and fallback invocation count. A missing resource must remain a Core 404 with zero fallback calls. An export must reach the stub. The generated-link test also checks the returned order ID.
The fixture explicitly sets RouteHandlerOptions.ThrowOnBadRequest to false. Development can throw on failed binding, which would make a test expecting an HTTP 400 depend on its hosting configuration. The assertion should describe a deliberate configuration rather than an accidental environment default.
[Theory]
[InlineData("GET", "/orders/42", 200, "core", 0)]
[InlineData("GET", "/orders/999", 404, "core", 0)]
[InlineData("GET", "/orders/export", 200, "legacy-stub", 1)]
[InlineData("POST", "/orders/42", 200, "legacy-stub", 1)]
public async Task Request_reaches_the_agreed_owner(
string method, string path, int status, string owner, int hits)
{
await using var fixture = await RoutingFixture.StartAsync(
cancellationToken: TestContext.Current.CancellationToken);
using var request = new HttpRequestMessage(new HttpMethod(method), path);
using var response = await fixture.Client.SendAsync(
request, TestContext.Current.CancellationToken);
Assert.Equal(status, (int)response.StatusCode);
Assert.Equal(owner, Assert.Single(
response.Headers.GetValues("X-Selected-Owner")));
Assert.Equal(hits, fixture.Legacy.Hits);
}Make the unchanged contract reject two broken versions
A passing happy path is weak evidence. Keep the ownership assertions fixed and change the route registrations in a disposable test fixture.
First use the unconstrained route. The export assertion should fail because it sees Core and 400 rather than legacy-stub and 200. Next remove the Core read route. GET /orders/42 should still return 200 with the same JSON from the stub, but the owner and fallback-count assertions should fail. That second mutation demonstrates why checking only status and content is insufficient.
The companion fixture selects these mutations through ROUTE_MUTATION. It also has separate mechanism tests for the 400, local-literal precedence, Core 404, and 405 without a catch-all. Those explanatory tests use explicit fixtures; the acceptance expectations remain unchanged.
The recorded run passed all 13 baseline cases. The unconstrained mutation failed four checks: export, malformed ID, overflowing ID, and invalid outbound-link generation. Removing the read endpoint failed three checks: two owner assertions and the generated read link. Restoring the baseline passed all 13 again. The build completed with warnings treated as errors and reported no warnings or errors.
# In the companion sample directory, with .NET 10 installed: dotnet run # Bash: these two runs are expected to fail contract assertions. ROUTE_MUTATION=unconstrained dotnet run ROUTE_MUTATION=removed dotnet run # Restore the baseline; no environment variable is set for this command. dotnet run # Inspect the failed assertions. A restore or build error does not # establish that the mutation was caught. Keep expected owners fixed.
A generated URL must return to the intended owner
Route names address endpoints for link generation. WithName does not make a route win inbound selection. Use unique, stable names and test the generated path as a real request.
For this example, orders.read with id 42 must generate /orders/42, and following it must return the Core-owned order. Check failure cases too: a missing name, missing required id, or value rejected by the constraint should not quietly produce a usable order link.
The same concern applies when MVC controllers and Minimal APIs coexist. Two equal-priority GET endpoints for the same effective pattern can be ambiguous; naming them differently does not choose the winner. Exercise the actual request instead of assuming registration order resolves the collision.
If an existing create operation returns Location, test the actual response and follow that URL through the frontend. A unit test that only inspects a CreatedAtRoute result has not exercised URL generation during result execution. For virtual-directory deployments, also verify the public path base. Microsoft requires matching virtual-directory layouts for the Framework and Core remote-app setup.
var links = fixture.App.Services.GetRequiredService<LinkGenerator>();
var path = links.GetPathByName("orders.read", new { id = 42 });
Assert.Equal("/orders/42", path);
using var response = await fixture.Client.GetAsync(
path, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("core", Assert.Single(
response.Headers.GetValues("X-Selected-Owner")));
// Also deserialize the response and assert that its order ID is 42.The real proxy still needs its own evidence
The fast harness isolates the selection mistake. Before a release, run the contract through the deployed frontend with the real YARP forwarder and Framework application. A local stub cannot establish forwarding, authentication, or persistence behavior.
Keep two observations separate: which endpoint Core selected, and which application actually handled the request. Use controlled test-only response markers or correlated server traces. A frontend marker saying legacy is insufficient if the upstream request failed.
Use fixtures with known records and a test-only correlation ID. For one submitted update, verify the approved persisted change in the legacy store and no Core write. That establishes this test case; it does not establish exactly-once delivery under retries or network failure. Add retry and idempotency cases if those are part of the application contract.
- For exports and writes, verify the upstream method, path, query string, and body. Check response content type and relevant headers as well as status.
- For a Core 404 or binding 400, verify there was no corresponding legacy request.
- For authorization, use unauthenticated, permitted, and other-tenant identities. Assert the agreed denial or challenge behavior, no protected data, and no fallback that bypasses the decision.
- Disable automatic redirect following when inspecting login challenges and Location headers. Then test the intended browser flow, including cookies and the public origin.
- Exercise the rollback configuration and prove the migrated read reaches Framework again. Check that the legacy version can still read any data produced since rollout.
What to give the coding agent
Once the team has chosen the ownership boundary, give the agent the actual registrations, the neighboring legacy routes, and the fixed tests. Ask it to implement that decision and flag any case it cannot classify. A route constraint, a new HTTP verb, an authorization change, and a fallback change each deserve review because each can alter the boundary.
The useful deliverable is a small routing change whose owner, failure behavior, generated links, and rollback path can be checked. The request table is the beginning of that work. The failing mutations and the real-proxy evidence are how you find out whether it was done.
Sources
- Download the runnable route ownership test project (.zip)
- Microsoft incremental migration setup and virtual directories
- Microsoft routing precedence and route constraints
- YARP 2.3 MapForwarder implementation
- ASP.NET Core 10 endpoint execution
- Microsoft Minimal API parameter binding
- ASP.NET Core 10 binding failure configuration
- Microsoft named endpoints and link generation
- Microsoft LinkGenerator GetPathByName contract
- ASP.NET Core 10 ambiguous endpoint selection
- ASP.NET Core 10 CreatedAtRoute result execution
- Microsoft ASP.NET Core integration testing
- ASP.NET Core 10 HTTP method matching
- ASP.NET Core 10 short circuit execution