DEV Community

Cover image for Request and Response Migration in .NET
Hitesh
Hitesh

Posted on

Request and Response Migration in .NET

If you've maintained a public API long enough, you've opened a folder that looks like this:

Handlers/
  GetQuoteV1Handler.cs
  GetQuoteV2Handler.cs
  GetQuoteV3Handler.cs   ← the only one anybody has read this year
Enter fullscreen mode Exit fullscreen mode

Three files, one behaviour. V1 and V2 are copies of V3 with a field dropped and a flag hardcoded.

Nobody planned this. We shipped GetQuote, symbol in and price out. A customer wanted the daily change, so v2 grew an IncludeChange flag. We sold into Europe, so v3 grew Currency. Every one of those was the right call on the day. And you can't delete the old ones, because there's a mobile app whose release cycle you don't own and a partner who integrated in 2022 and won't look at it again.

So you keep all three. A pricing fix is three edits. Then one Friday somebody patches two of the three, and from that afternoon v2 quotes a different number than v3. You find out five weeks later from a customer.

Asp.Versioning works out which version a request is. It has nothing to say about the duplication sitting behind that answer.

What we tried first

The instinct is to pull the logic into a shared method with optional parameters:

private async Task<Quote> GetQuoteCore(
    string symbol, bool includeChange = false, string currency = "USD", ...)
Enter fullscreen mode Exit fullscreen mode

But includeChange = false is v1's behaviour written down as a default in a signature that belongs to the current version. Nothing connects it to the version it exists for. Delete v1 in three years and that = false sits there forever, because nobody can prove which caller needs it. And it grows one permanent parameter per version until half the defaults are archaeology.

Branching on a version number inside one handler is worse: the checks interleave, so you can't read one version end to end, and one request type now has to carry the union of every field any version ever had. A base class with virtual hooks looks like real design but is a fragile base class in a costume, because adding v4 means changing the base, and changing the base changes what v1 does.

All three share a shape. Each one puts knowledge of every version in one place, so the thing you edit grows with every version you've shipped, and every edit can touch all of them. What we wanted was the opposite: each version knowing about exactly one neighbour, and old versions being genuinely finished.

The idea, borrowed from Stripe

Stripe has shipped close to a hundred backwards-incompatible changes and retired zero versions. Code written against their API a decade ago still runs. Brandur Leach wrote up how, and it comes down to two things:

  1. One implementation, always at the newest version. Nothing in it has heard of 2017.
  2. A small module per breaking change that knows how to undo it.

A response gets built at the current version, then walked backwards through those modules until the shape matches what the caller is pinned to. A version stops costing you a copy of your business logic and starts costing you a small transform. Nothing about that is HTTP-specific, so we built it for .NET, in a library called Versionary.

Building it

Start with plain records. No base class, no attributes, no library reference. The current one gets no prefix, because it isn't a version, it's the contract.

public static class V1
{
    public sealed record GetQuote(string Symbol) : IRequestContract<Quote>;
    public sealed record Quote(string Symbol, decimal Price);
}

public static class V2
{
    public sealed record GetQuote(string Symbol, bool IncludeChange) : IRequestContract<Quote>;
    public sealed record Quote(string Symbol, decimal Price, decimal Change);
}

// Current.
public sealed record GetQuote(string Symbol, bool IncludeChange, string Currency)
    : IRequestContract<Quote>;
public sealed record Quote(string Symbol, decimal Price, decimal Change, string Currency);
Enter fullscreen mode Exit fullscreen mode

Then one handler, written for the current shape. This is the only copy of the behaviour, and it has never heard of v1.

public sealed class GetQuoteHandler(IPriceFeed feed) : IVersionaryHandler<GetQuote, Quote>
{
    public async ValueTask<Quote> HandleAsync(GetQuote request, CancellationToken ct)
    {
        var price = await feed.PriceAsync(request.Symbol, ct);
        var change = request.IncludeChange ? decimal.Round(price * 0.015m, 2) : 0m;
        return new Quote(request.Symbol, price, change, request.Currency);
    }
}
Enter fullscreen mode Exit fullscreen mode

And the piece that replaces the two deleted handlers, one migrator per hop:

public sealed class V1QuoteMigrator :
    IMigrator<V1.GetQuote, V2.GetQuote>,   // forward:  the request goes up
    IMigrator<V2.Quote, V1.Quote>          // backward: the response comes down
{
    public ValueTask<V2.GetQuote> MigrateAsync(V1.GetQuote input, CancellationToken ct)
        => new(new V2.GetQuote(input.Symbol, IncludeChange: false));

    public ValueTask<V1.Quote> MigrateAsync(V2.Quote input, CancellationToken ct)
        => new(new V1.Quote(input.Symbol, input.Price));
}
Enter fullscreen mode Exit fullscreen mode

IncludeChange: false is the whole point. That one literal is the entire v1 behaviour, in one line you can put in front of a reviewer. It used to be an emergent property of a 200-line handler nobody had opened since 2023. Both directions live in one class on purpose, because the request transform and the response transform are two halves of the same decision, and split across files someone updates one and forgets the other.

Each migrator only speaks to its immediate neighbour. V1's knows v2 and nothing else, even after v3 ships. The chain does the rest.

builder.Services.AddVersionary(cfg => cfg.RegisterFromAssemblyContaining<GetQuoteHandler>());

app.MapGet("/v1/quotes/{symbol}", async (string symbol, IVersionarySender sender, CancellationToken ct) =>
    TypedResults.Ok(await sender.SendAsync(new V1.GetQuote(symbol), ct)));
Enter fullscreen mode Exit fullscreen mode

That endpoint mentions no migration and no response type. The contract declares what it returns, so asking a v1 request for a v2 response won't compile. What actually happens:

V1.GetQuote ──► V2.GetQuote ──► GetQuote ──► [ the one handler ]
                                                     │
V1.Quote    ◄─── V2.Quote    ◄─── Quote    ◄─────────┘
Enter fullscreen mode Exit fullscreen mode

Two hops up, run, two hops back. The client gets bytes identical to 2022.

The harder direction

Stripe mostly walks responses backwards. Migrating a request forward is harder, because you have to invent data the caller never sent. A v2 client couldn't tell us the currency, so somebody has to decide it, and sometimes that means a lookup:

public async ValueTask<GetQuote> MigrateAsync(V2.GetQuote input, CancellationToken ct)
{
    var currency = await feed.CurrencyAsync(input.Symbol, ct);
    return new GetQuote(input.Symbol, input.IncludeChange, currency);
}
Enter fullscreen mode Exit fullscreen mode

That's why migrations are async and migrators come out of DI. Pure reshaping allocates nothing, but a migration that needs a database round trip shouldn't have to fight the API to make one.

"So where do I configure the current version?"

This stalled the first design review. The tempting answer is a setting: cfg.CurrentVersion = "v3". Don't. It's a second source of truth that has to agree with your handler, it goes stale silently when you add v4 and forget to bump it, and it forces one versioning scheme on an API, an internal service, and a queue consumer that don't share one.

There's no setting, because the rule is:

A contract is current when nothing migrates away from it.

The forward walk is deliberately dumb: take the request's type, look for an outgoing hop, run it, repeat. When the lookup comes back empty, you've arrived, and that's where the handler is. The version map is derived from the code, not declared next to it and left to rot. Pinning a version that changed behaviour rather than shape then costs nothing: give it a handler and no outgoing migrator, and its requests arrive untouched.

The payoff

Add a version tomorrow, and here's what moves:

Changes?
The v1 endpoint No
The v2 endpoint No
A migrator for the new hop Added
Your handler, now on the new contract Changed

Two files, neither an endpoint. A v1 endpoint names v1 types and only v1 types, and those can never change.

The one way to get burned is to add the migrator and forget to move the handler forward, so it strands on an old contract and can never run. That's a startup failure, not a 3am one:

Error VER005: A handler is registered for 'Api.V3.GetQuote', but that contract still
              migrates onward to 'Api.GetQuote', so the handler can never run.
Enter fullscreen mode Exit fullscreen mode

Cycles, duplicate hops, stranded handlers, ambiguous paths- all checked while AddVersionary runs and reported together. There's a Graph.Validate() for a unit test too, and a Graph.Explain() that prints the version map from the graph itself, so your docs can't drift.

"Isn't this just mapping?"

Someone asked me this in review, and for two versions they're right: write MapV1ToCurrent and MapCurrentToV1 and skip this whole article.

The difference shows up at three, and it's structural. Hand-rolled mappers all point at the current contract:

V1 ───┐
V2 ──┐│
V3 ─┐││
    ▼▼▼
  current
Enter fullscreen mode Exit fullscreen mode

So when current changes, which is the only reason you're adding a version, every mapper is wrong at once, and the compiler only catches the fields that disappeared. Add a field with a default, and they all still build while quietly doing the wrong thing.

Chained hops point at their neighbour:

V1 ──► V2 ──► V3 ──► current
Enter fullscreen mode Exit fullscreen mode

V1QuoteMigrator names v1 and v2 and nothing else. Both types are frozen, so it compiled in 2022 and will compile after v7. Adding a version appends one hop and touches nothing behind it. That's O(n) edits per version versus one. (The same trap catches "each endpoint just calls one service directly": every endpoint names the current service signature, so they all break together, and the version semantics end up scattered across your routing table.)

So yes, it's mapping. The library is a chain, a lookup, and a validator wrapped around it. The value is entirely in the chain being adjacent rather than radial.

On MediatR (optional)

The core has no idea MediatR exists. But if you already dispatch through ISender, you keep your handlers and behaviours and add one line:

builder.Services
    .AddVersionary(cfg => cfg.RegisterFromAssemblyContaining<Program>())
    .AddMediatRPipeline();
Enter fullscreen mode Exit fullscreen mode

The one thing worth knowing: when a v1 request climbs two hops, which validators fire? SinglePass (the default) validates the arriving and the current contract. Reentrant re-dispatches each hop, so a validator written for the intermediate V2.GetOrder also fires, catching a bad migration where it happened rather than three hops later. Reentrant re-runs everything, though, so register idempotent behaviours (validation, logging) outermost and once-only ones (transactions, audit, outbox) inside the pipeline. Details are in the repo.

When this is the wrong tool

Changes come in three kinds. Additive (a new optional field) usually needs no migrator. Shape (renamed, split, merged, nested) is exactly what this exists for. Behavioural is the trap: the shape is identical, and the meaning moved underneath it. Cancelling used to refund immediately and now queues it. No transform can express that, because the data was never what changed.

One question settles it:

Can you write a function from the old shape to the new one that loses nothing a caller relied on?

Yes, write the migrator. No, pin the version to its own handler and take the second copy. Pinning is the right answer there, not a failure, but be honest: on that endpoint you've saved nothing. Two of ours are pinned.

A couple of sharp edges: every hop boxes (messages move as object), and assembly scanning isn't AOT-safe, though there are inline registration forms that are.

Once the chain exists, it fits things that aren't API calls. Event upcasting is the same forward walk with no handler on the end, because an event stored three years ago just needs bringing up to the current shape. That works because the graph has no idea what a version is, only that one type can become another. Working out which version a message belongs to stays your transport's job.

Was it worth it?

Two handlers became two migrators, so the file count didn't move. But there's now exactly one copy of the behaviour. A pricing fix is one edit. The Friday bug where v2 and v3 silently disagree can't happen, because there's nothing left to disagree. And IncludeChange: false is a better spec for v1 than the v1 handler ever was.

The asterisk: this reshapes data, not behaviour. If a version differs in what it does, you pin it, and this bought you nothing. If you've got two versions and no duplication yet, none of this is worth your afternoon. Come back when it starts to hurt. It will.


Try it

Versionary

Serve every version of your API from one handler.

License: MIT NuGet Downloads CI Targets


How you got here

You shipped CreateOrder. It was clean.

Then finance wanted tax broken out on the response, so you added a flag. That's v2. Europe happened and orders needed a currency, so that's v3. Payments asked for an idempotency key and you shipped v4.

Every one of those was the right call on the day you made it. That's what makes this so annoying.

You can't turn the old versions off. There's a mobile app whose release cycle you don't own. There's a partner who integrated in 2022 and has no budget to look at it again. There are terminals sitting in venues that get updated when somebody drives out there with a laptop.

So all four are live:

CreateOrderV1Handler  ─┐
CreateOrderV2Handler  ─┤   four handlers
CreateOrderV3Handler  ─┤   one actual behaviour
CreateOrderV4Handler  ─┘

Three of them are copies…


📦 Install

dotnet add package Versionary

MIT · net8.0 / net10.0

Issues and PRs are welcome. I'd especially like to hear from anyone who made the pin-versus-migrate call on a real behavioural change and regretted which way they went. 👇

Top comments (0)