The routing shim puts you in a position most legacy migrations never reach: every request to the old system passes through something you control. Once a slice is rebuilt, that gives you an option better than a launch date and a held breath. You can send the same live requests to both implementations, throw away the new system's answer, and compare.
This is shadow traffic, sometimes called a parallel run or dark launch. The characterization harness proves the new slice matches recorded behavior. Shadow traffic proves it matches today's behavior, on real data, at real volume, with the inputs your users actually send rather than the ones you thought to record. It is the last check before cutover, and it is where most of the surprises show up.
This tutorial assumes the setup from the routing shim: YARP on .NET 8 in front of a legacy IIS application, routing a few paths to a rebuilt slice and everything else to the old app.
Mirror read traffic first
Fork the request in the proxy
YARP forwards a request and returns one response. To shadow, you need a second forward whose response nobody sees. Do it in middleware, after buffering the body so both forwards can read it:
app.Use(async (context, next) =>
{
if (!ShadowRoutes.Matches(context.Request))
{
await next();
return;
}
context.Request.EnableBuffering();
var snapshot = await RequestSnapshot.CaptureAsync(context.Request);
context.Request.Body.Position = 0;
// Primary path: legacy answers the user, unchanged.
var primary = await ResponseRecorder.InvokeAndCaptureAsync(context, next);
// Shadow path: fire and forget, never on the user's clock.
_ = Task.Run(() => shadow.CompareAsync(snapshot, primary));
});
Three rules hide in those twelve lines, and all three matter:
- The user's response comes from the legacy system, always. Until cutover the new slice is an observer.
- The shadow call runs off the request thread. If the new slice is slow or throws, the user never learns about it.
- The shadow call gets a hard timeout, two or three seconds, and a bounded queue. Shadowing must not be able to take down the thing it is watching.
Start with GETs only
ShadowRoutes.Matches should begin as narrow as you can stand: GET requests to the paths in the slice. Read traffic has no side effects, so mirroring it costs you nothing but CPU. Writes need their own handling, and they are the next section.
Send the same identity
A comparison is worthless if the two systems see different users. Forward the authentication cookie or bearer token to the shadow, and forward the tenant, location or role headers the legacy app reads. If the new slice runs on .NET 8 Identity while the old one still runs Forms Authentication, you need the translation layer from moving Forms Authentication onto .NET 8 Identity working before shadowing tells you anything true.
Compare in a way that produces a number
Normalize before diffing
Raw HTML comparison produces noise on the first request and gets ignored by the third day. Compare what the screen means, not how it is spelled:
public sealed record Comparison(string Route, bool StatusMatch, bool PayloadMatch,
string? FirstDifference);
static Comparison Compare(Captured legacy, Captured shadow)
{
var statusMatch = legacy.StatusCode == shadow.StatusCode;
var a = Extract(legacy); // ordered field values, not markup
var b = Extract(shadow);
var diff = a.Zip(b).FirstOrDefault(p => p.First != p.Second);
return new Comparison(legacy.Route, statusMatch,
a.SequenceEqual(b), diff == default ? null : $"{diff.First} != {diff.Second}");
}
Extract is the piece you write per slice: pull the grid rows, the totals, the field values out of each response and return them in a canonical order. For a report or an export, compare the bytes, because the bytes are the contract. For a rendered page, compare the data, because the new stack will emit better markup and you do not want to hear about it four thousand times.
Elide the same things the characterization harness elides: ViewState, timestamps, session identifiers, anti-forgery tokens. When you find yourself eliding a field that carries meaning, stop and treat it as a real difference.
Log mismatches with enough context to reproduce
A mismatch you cannot reproduce is not a finding. Record the route, the normalized inputs, both extracted payloads, the user role and the timestamp, and log at most a few hundred characters of each:
logger.LogWarning("Shadow mismatch {Route} status={Legacy}/{Shadow} first={Diff}",
c.Route, legacy.StatusCode, shadow.StatusCode, c.FirstDifference);
Then count them. Emit two counters, shadow_compared_total and shadow_mismatch_total, tagged by route. The mismatch rate per route is the number that decides whether you cut over, and you want it on a chart, not in someone's memory.
Shadowing writes
Read parity is the easy half, and the half that lies to you. Most of the risk in a line-of-business system lives in the code that saves.
You have three honest options.
Write to a copy. Point the shadow at a restored copy of production, refreshed nightly, and compare the rows each system wrote after the request. This is the most informative and the most work: the copy drifts from production during the day, so expect false mismatches on anything that depends on state the legacy system changed and the copy did not see.
Compare the intent, not the effect. Have the new slice compute what it would write and return it as a structured payload behind a shadow-mode flag, without committing the transaction. Compare that payload to the rows the legacy system actually wrote. Less realistic, far safer, and it catches the bulk of calculation bugs — the rounding, the tax rule, the discount tier.
Do not shadow writes at all. Rely on the characterization harness for write paths and shadow only reads. For a slice with two write endpoints and thirty read paths, this is often the right call. Say so out loud rather than letting it be an oversight.
What you must never do is let the shadow write to production. Two systems inserting the same order is a worse outcome than any bug shadowing would have found. Enforce it with a connection string that has no write permission, not with a code review.
Run it, then decide with the numbers
Leave shadowing on for at least one full business cycle for the slice. If it closes the month, that means through a month-end close. A week of Tuesdays will not show you the batch that only runs on the last business day.
Expect the first two days to be mostly your own normalization bugs. That is normal. Work the list down until mismatches are real, then triage each one into three buckets: legacy bug the new system fixed (write it down, get it agreed, update the approved files in the harness), new system bug (fix it), or difference nobody has decided about yet (escalate it to the owner, because it is a business decision wearing a technical costume).
Agree the cutover gate in advance, in writing. A gate we have used:
- Every route in the slice shadowed for 10 business days including a month-end.
- Mismatch rate under 0.1% per route, with every remaining mismatch individually explained and accepted.
- p95 shadow latency no worse than the legacy path, measured, not assumed.
- Rollback rehearsed: the shim switched to the new slice and back again in a staging environment, with a stopwatch on it.
Cutover is then a configuration change in the shim, not an event. Flip one route, watch the mismatch counters keep running the other way — legacy as shadow, new system as primary — and leave them running through hypercare. That reversed shadow is your rollback evidence: if it lights up, you flip the route back in seconds and still have the comparison log that tells you why.
What this does not do
Shadow traffic tells you the new slice agrees with the old one on the traffic it saw. It says nothing about paths nobody exercised during the window, which is why the harness stays green throughout and why the rare-but-expensive flows still need deliberate recorded tests. It also costs real money in compute and engineer attention, typically a few days of setup per slice and an hour a day of triage while it runs. On a slice that touches invoicing, payroll or inventory, we have never regretted it. On a slice that renders a read-only lookup screen, the harness alone is usually enough.