Mihael Costa/migration log
← index
LEGACY MIGRATION

.NET Framework 4.8 to .NET 8, decision by decision

A freight back office built in 2011 — MVC 5, a 304-lineWeb.config, a 272-line controller action, and four dependencies with no successor. Strangled one route at a time, with the failures kept in.

  • .NET 8
  • EF Core
  • YARP
  • Strangler fig
00Whole system

The application we inherited

What Meridian Despatch actually was, measured rather than characterised, before anything moved.

Meridian Despatch books freight. A clerk takes a collection, the system prices it, a label prints in a depot, a manifest goes out overnight and an invoice follows at the end of the month. It has been in service since 2011 and it works, which is the first thing worth saying about it — nobody is migrating this because it is broken.

Before describing it, here is what it measures. Adjectives about legacy code are cheap; the numbers are not.

Meridian.Web at the commit we inherited it
C#
1,331 lines across 20 files
Web.config
304 lines
Binding redirects
18
appSettings keys
22 — of which 12 are business rules
NuGet packages
22, via packages.config
Compile entries
18, maintained by hand in the .csproj
Longest method
ConsignmentsController.Create — 272 lines
Tests
0

The method that is the migration

ConsignmentsController.Create is 272 lines. In one action it derives the chargeable weight, works out the delivery zone from a chain of postcode prefixes, opens a SqlConnection to look up a rate band, applies fuel, remote area, timed delivery and hazardous surcharges, applies a contract discount that behaves differently for three customers, calculates VAT, runs a duplicate check by concatenating the form values into a SELECT COUNT(*), saves through EF6, sends a fixed-width string to MSMQ, calls a SOAP service and writes two audit rows.

There is no service layer to move it into. There is no seam. It is not badly written so much as undistinguished — nine years of small correct decisions, each of which made sense on the day, none of which were ever consolidated.

That method is the migration. Everything else is preparation for being able to touch it.

Four dependencies with no successor

This is the part that decides whether a migration is a project or an adventure. Framework and language differences are work; a dependency that simply does not exist on the other side is a decision.

No .NET 8 equivalent
System.Messaging
MSMQ, the depot handoff. Never ported to .NET Core and never will be. No shim, no compatibility package.
Microsoft.ReportViewer
Twenty RDLC reports written in the Visual Studio report designer between 2011 and 2017. The format has no other renderer.
MachineKey
Forms authentication encrypts its cookie with it. ASP.NET Core has Data Protection instead, which is a different algorithm.
System.Drawing
Despatch labels, including a barcode drawn by hand from a Code 39 table because the licensed component was lost in 2012.

Two defects left in on purpose

The search box concatenates its contents into a WHERE clause. It was reported internally in 2019 as MER-2213 and it is still open. Reference allocation is MAX(...) + 1 with no locking, so two clerks booking in the same second collide and the second one gets a yellow screen.

Both were left exactly as found in this first commit. Fixing them here would mean the baseline is no longer the thing being migrated, and the parity harness that arrives in stage 03 would be comparing the new code against an oracle nobody ever ran in production.

They get fixed later, in the stages where that code was being rewritten anyway. That is not a virtue — it is how long-open tickets actually get closed.

What this is, and is not

This is a purpose-built system. A former client’s code is not mine to publish, so Meridian was written to carry the specific pathologies that make these migrations expensive, and then genuinely migrated.

The traps are real. System.Messaging really has no successor. MachineKey really cannot be read by Data Protection. EF Core really defaults decimal to (18,2) where EF6 was told (18,4), and really does so silently. Every one of those is written up below because it bit this codebase, in this repository, with a commit that fixed it.

What is not claimed anywhere in this log is a production incident. Nothing here says a customer rang. Where a stage gives a cost, it is the cost of finding it here.

01Routing

A gateway in front of everything, routing nothing

A reverse proxy went in first, sending 100% of traffic to the application it was supposed to replace.

The first commit of the migration migrates nothing. It puts YARP in front of the application with a single catch-all route pointing at the same IIS box that was already serving every request.

No user saw a difference. No code moved. This is a hard week to justify in a stand-up, and it is the right first move.

Request path, stage 01
Before
  1. Browserclient
  2. IISmeridian-app01
  3. Meridian.Web.NET 4.8
  4. SQL Serverdatabase
After
  1. Browserclient
  2. GatewayYARP · .NET 8
  3. IISmeridian-app01
  4. Meridian.Web.NET 4.8
  5. SQL Serverdatabase
unchangednew

One new box, and every route still ends in the same place. The only thing that changed is that there is now somewhere to put a decision.

Why this is first and not third

The strangler pattern is a sentence: move one route at a time to the new application. That sentence requires something that owns the routing table.

Without it, the first module to move has to move by DNS, by an IIS rewrite rule, or by a deployment window — and each of those moves everything at once for some class of user. A migration that cannot steer traffic per route does not have stages. It has one cutover, dressed up.

Doing it first has a second benefit that matters more in practice. This is the riskiest infrastructure change in the whole project — a new hop on every request, with its own timeouts, header handling and connection pooling — and it lands while precisely nothing depends on it. If the proxy is going to mangle something, the week to find out is the week when rolling back means deleting one container.

The routing table is the progress bar

stage 01stage 09−1  +14

The same file, eight stages apart. Every route naming the modern cluster is a piece of the application that has moved; the catch-all at order 100 is everything that has not.


          
          "Routes": {
        
          
            "ratecards-new": {
        
          
              "ClusterId": "modern",
        
          
              "Match": { "Path": "/ratecards/{**catch-all}" },
        
          
              "Order": 1
        
          
            },
        
          
            "consignments-api-new": {
        
          
              "ClusterId": "modern",
        
          
              "Match": {
        
          
                "Path": "/consignments/{**catch-all}",
        
          
                "Headers": [ { "Name": "Accept", "Values": [ "application/json" ] } ]
        
          
              },
        
          
              "Order": 2
        
          
            },
        
          
            "everything-legacy": {
        
          
            "everything-else-legacy": {
        
          
            "ClusterId": "legacy",
        
          
            "Match": { "Path": "{**catch-all}" },
        
          
            "Order": 100
        
          
          }
        
          
          }
        

Low order numbers are the migrated routes, so new ones are added at the top and the catch-all never has to move. Reading this file top to bottom is reading the migration in order.

What broke

What brokeTwo days, most of it spent looking at the proxy
Symptom
Log in, submit any form, and the browser lands on a URL containing the internal port — meridian-app01:52341 — which is unreachable from outside. Every GET worked.
Cause
Meridian.Web builds absolute URLs from Request.Url. Behind a proxy that is the address of the internal hop, not the address the client asked for. Only redirect-after-POST exposes it, because only redirects put a server-built absolute URL into a Location header.
Caught by
Manually, by using the application. No test covered a redirect target, and no log line was wrong — the application was behaving exactly as written.

The instinct was that a new proxy had broken something, so two days went into YARP’s header forwarding before anyone read Request.Url. The proxy was correct throughout. The application had always been wrong about its own address; nothing had ever asked it.

The fix on the 4.8 side is a X-Forwarded-* aware base URL. On the .NET 8 side it is UseForwardedHeaders, which is in the stage 04 commit and cost nothing, because by then it was a known problem rather than a discovered one.

Cost

Every request now has an extra network hop, an extra set of timeouts to tune, and a second place to look when something is slow. The gateway also becomes a single point of failure for an application that previously had one fewer.

Bought

Traffic can be steered per route. Every later stage is a config change to one file, reversible in seconds, instead of a deployment window.

02Pricing

The rating rules, lifted out of the controller

A multi-targeted project both halves reference, so the pricing rules exist once while two applications are live.

A strangler migration means two applications run at once, for months. Both of them price freight. That leaves three options, and only one of them is survivable.

Copy the rules into the new application and maintain both: every rate change now has to be made twice, correctly, by whoever picks up the ticket. Call back into the old application over HTTP: the new code depends on the thing it is replacing, and the dependency points the wrong way for the whole migration.

Or put the rules in one project that both runtimes can reference.

Meridian.Web.csproj (fragment)Meridian.Domain.csproj (whole file)−18  +7

The old project lists every source file by hand and every reference by HintPath into ..\packages\. The new one is nineteen lines and multi-targets both runtimes.


          
          <Project ToolsVersion="15.0" DefaultTargets="Build"
        
          
                   xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
        
          
            <PropertyGroup>
        
          
              <TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
        
          
            </PropertyGroup>
        
          
            <ItemGroup>
        
          
              <Reference Include="EntityFramework, Version=6.0.0.0, Culture=neutral, ...">
        
          
                <HintPath>..\packages\EntityFramework.6.4.4\lib\net45\EntityFramework.dll</HintPath>
        
          
              </Reference>
        
          
              <Reference Include="System.Web.Mvc, Version=5.2.9.0, ...">
        
          
                <HintPath>..\packages\Microsoft.AspNet.Mvc.5.2.9\lib\net45\System.Web.Mvc.dll</HintPath>
        
          
              </Reference>
        
          
            </ItemGroup>
        
          
            <ItemGroup>
        
          
              <Compile Include="Controllers\ConsignmentsController.cs" />
        
          
              <Compile Include="Data\ConsignmentRepository.cs" />
        
          
            </ItemGroup>
        
          
          </Project>
        
          
          <Project Sdk="Microsoft.NET.Sdk">
        
          
            <PropertyGroup>
        
          
              <TargetFrameworks>net48;net8.0</TargetFrameworks>
        
          
              <LangVersion>latest</LangVersion>
        
          
              <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        
          
            </PropertyGroup>
        
          
          </Project>
        

One .csproj, two targets. The 4.8 application references it on net48, the .NET 8 API references the same file on net8.0. Not a copy, not a NuGet package cut from it — the same project.

The price of the bridge

Everything in that project has to compile on both. No nullable reference annotations, no records, no System.Text.Json, no init-only setters, no file-scoped namespaces. It reads like 2015 C# because it has to.

That is a real cost and it is worth naming: the shared project is the least modern code in the new application, and it is the code doing the most important work. Every reviewer who reads it asks why it looks like that.

It is still the right trade. The alternative is two copies of the rate card arithmetic diverging quietly for the length of the migration, and the failure mode of that is not a compile error — it is two invoices for the same consignment with different totals.

Moved verbatim, then made presentable

The first version of RatingEngine was uglier than the controller it came from: one static method with eleven parameters and the same nested conditionals, because verbatim was the requirement and shape was not.

Only once stage 03’s harness could prove it agreed with the original on four thousand inputs was it allowed to become a class with named private methods. The refactor is safe because the test is real; the test is real because the code was moved without being improved first.

Doing those two things in one step is the single most common way a migration introduces a defect nobody can find, because when the output changes there is no way to tell whether the move or the tidy-up did it.

The oddities that survived

Three rules look like bugs and are not. Each one is a price somebody is charged today.

Preserved deliberately, with the reason attached
Half-kilo rounding upwards
Chargeable weight rounds up to the next half kilo. Introduced in 2014 after a dispute with a customer over 0.3kg. Accounts sign off every rate change, and this is a rate change.
Hazardous surcharge triples for Ireland
£15 becomes £42.50. It is the customs broker fee passed through, not our margin, and it was added in 2018 when the broker started charging it.
Discount on net for three accounts
A 2017 bug applied the contract discount to the whole net rather than to carriage. The customers noticed the correction and the commercial team agreed to honour it. It is now a contract term.

A migration is the wrong moment to correct a price. Correcting one here means the parity harness fails, and then there is no way to distinguish “we changed this on purpose” from “we broke something” for the rest of the project.

The one thing that did change shape: the three accounts were an if on customer.Id. They are a boolean on the request now, and where that boolean comes from is stage 05’s problem.

Cost

The most important code in the new application is written to a 2015 language standard, and will stay that way until the 4.8 side is switched off.

Bought

One implementation of the pricing rules for the whole length of the migration. A rate change is one edit, in one place, tested once.

03Test harness

A transcription of the old arithmetic, kept on purpose

The 4.8 pricing logic, copied without improvement into a test fixture, so every later stage has something to be wrong against.

The baseline had zero tests. That is normal and it is the actual problem: there is no definition of correct behaviour anywhere except the behaviour itself.

So the definition gets written down. LegacyRating.cs is a transcription of the arithmetic inside ConsignmentsController.Create, with the database, HttpContext and MSMQ cut away and nothing else touched. Same nesting, same ordering, same two separate rounding points.

It is not a tidier version. Its entire value is that it is not an improvement.

Sweeping, not sampling

Four thousand generated inputs, compared field by field, failing on a penny. The seed is fixed — a harness that generates different inputs on every run cannot tell you whether you fixed something.

Hand-picked examples would have missed all three of the interesting disagreements found so far, because every one of them was at a boundary nobody would have thought to write a test for.

Found by sweeping, not by review
A Dublin postcode beginning PA
Country has to beat postcode prefix. In the original it did, but only because the country check ran last and overwrote what the prefix checks had already set. Rewritten as a lookup, the natural reading reverses it and Dublin prices as Scottish Highlands.
Exactly 10kg on a card whose bands meet at 10
The old application had two rate lookups that disagreed on the boundary — inline SQL using BETWEEN in the booking screen, a LINQ query in the rate card screen. Nine years, nobody knew.
A net that rounds up while its VAT rounds down
The subject of the second commit in this stage, below.

The commit that fixed a bug the tests could not see

The engine took VAT from the rounded net. The 4.8 controller takes VAT from the unrounded net and rounds the net separately, on the next line.

All four thousand parity cases passed either way.

RatingEngine.cs — as writtenRatingEngine.cs — a227e30−2  +2

Two lines swapped, no observable difference across the entire corpus, and worth a commit of its own.


          
          var roundedNet = Round(net);
        
          
          var vat = Round(roundedNet * vatRate);
        
          
          var vat = Round(net * vatRate);
        
          
          var roundedNet = Round(net);
        

Passing is not equivalence. Telling those two apart needs a net whose third decimal place carries its VAT across a half-penny boundary, and a corpus that never produces one has not proved anything — it has failed to disprove.

I went looking for a divergent input with a targeted sweep of four million values and did not find one either. That is still not equivalence; it is a narrower search with the same shape of answer.

What brokeTwenty minutes to find, five to fix, and it would have been a very long afternoon later
Symptom
Nothing. Every test passed, and a four-million-value sweep for a counterexample also came up empty.
Cause
The engine and the original take VAT from different quantities — one rounds before multiplying, the other after. The difference is structural and the test corpus happened never to produce an input that exposed it.
Caught by
Reading the two implementations side by side while writing this entry. No test would have.

The fix is to match the original’s structure rather than its observed output. Cost of doing that: nothing. Cost of being wrong: a systematic penny across 41,000 consignments, and a conversation with finance, who reconcile the new totals against the old ones.

That became the rule for the rest of the migration. Where the old code’s shape is arbitrary, improve it. Where the shape is load-bearing and the test corpus cannot tell, copy the shape.

The test that was wrong

One test asserted that half a kilo bills as half a kilo. It fails: half a kilo bills as one, because MinimumChargeableWeightKg applies before the rounding.

The engine was right and the test was wrong. It is kept, with the corrected expectation and a comment explaining the trap, because the next person to read the volumetric formula will make exactly the same assumption.

Cost

A file in the test project that is a deliberate copy of code being deleted, which every reviewer flags as duplication, and which has to be maintained if the old rules change during the migration.

Bought

Every later stage can be checked against something instead of reviewed for plausibility. Two of the three defects above would have shipped.

LegacyRating.cs gets deleted the day the 4.8 application is switched off, and not one commit before.

04Authentication

The cookie that had to be readable by both stacks

Forms authentication encrypts with MachineKey and ASP.NET Core cannot read it. Nothing could ship until this was solved.

This is the stage that decides whether a strangler migration is possible at all, which is why it comes before any module moves.

Forms authentication encrypts its ticket with MachineKey. ASP.NET Core has no MachineKey — it has Data Protection, a different algorithm with a different key derivation and a different payload format. Out of the box the two stacks cannot read each other’s cookie.

The consequence is not subtle. A user follows a link from a page served by the old application to a page served by the new one and is simply logged out. Every route that moves takes the user’s session with it.

Authentication, stage 04
Before
  1. Meridian.Web.NET 4.8
  2. FormsAuth ticketMachineKey
  3. .MERIDIANAUTHcookie
After
  1. Meridian.Web.NET 4.8
  2. Meridian.Api.NET 8
  3. Data Protectionshared key ring
  4. .MERIDIANAUTHone cookie
unchangedadaptednew

Both applications write and read one cookie, through one key ring on a shared path. The 4.8 side gets there through Microsoft.AspNetCore.DataProtection.SystemWeb and Owin's interop ticket formatter.

Why not just log everyone in twice

The obvious alternative — issue a second cookie from the new application and let both exist — was tried and abandoned in an afternoon.

Two cookies means two expiry clocks, two sliding-expiration renewals that drift apart, and a logout that has to reach both applications or leaves one session alive. It also means the moment of truth moves to whichever request first needs the other application’s cookie, which is unpredictable and therefore untestable.

One cookie is more work up front and a smaller thing to reason about afterwards.

Two details that are not details

Web.configProgram.cs−9  +15

The machineKey element goes away entirely. What replaces it is not a config key but two lines of startup, and both of them cost a day.


          
          <machineKey
        
          
            validationKey="C1B4E5F3A0D8827E4C6B19A2F70D5E8C3B6A94F1D27E0C58B..."
        
          
            decryptionKey="8F3A0D5C7E92B4160A8D3F5C7E92B4160A8D3F5C7E92B416"
        
          
            validation="HMACSHA256"
        
          
            decryption="AES" />
        
          
          <authentication mode="Forms">
        
          
            <forms name=".MERIDIANAUTH" loginUrl="~/Account/LogOn" timeout="480"
        
          
                   slidingExpiration="true" protection="All" path="/" />
        
          
          </authentication>
        
          
          builder.Services
        
          
              .AddDataProtection()
        
          
              .SetApplicationName("Meridian")
        
          
              .PersistKeysToFileSystem(new DirectoryInfo(
        
          
                  builder.Configuration["DataProtection:KeyRingPath"]
        
          
                  ?? throw new InvalidOperationException("DataProtection:KeyRingPath is required.")));
        
          
           
        
          
          builder.Services
        
          
              .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        
          
              .AddCookie(o =>
        
          
              {
        
          
                  o.Cookie.Name = ".MERIDIANAUTH";
        
          
                  o.ExpireTimeSpan = TimeSpan.FromMinutes(480);
        
          
                  o.SlidingExpiration = true;
        
          
              });
        

SetApplicationName must match on both sides. Data Protection derives its purpose chain from the application name. Two applications with different names produce keys that cannot read each other’s payloads while looking perfectly configured — same directory, same files, same permissions.

KeyRingPath has no default and startup throws without it. That is deliberate, and it is the more important of the two.

What brokeA day, and it would have been longer without the directory listing
Symptom
A user logs in successfully — the credentials are checked, the ticket is written — and is immediately redirected back to the login page. Repeat forever. No error, no exception, nothing in either application's log above Information.
Cause
The new application could not find a configured key ring path, so Data Protection did what it is designed to do and generated an ephemeral one in memory. It then issued a cookie the 4.8 application could not decrypt, which redirected to log on, which issued a cookie the new application could not decrypt.
Caught by
By noticing that the key ring directory on disk had one XML file and a timestamp older than the deployment.

The failure mode is what makes this worth the paragraph. An absent key ring path is invisible in development, where there is only one application and it happily talks to itself. It fails only where two applications have to agree, which is the one environment nobody runs on their laptop.

So the code throws at startup rather than defaulting. A configuration value whose absence produces a working-looking application and a broken system should not have a default.

What this stage also fixed for free

UseForwardedHeaders is in this commit. It is the same problem stage 01 spent two days on — an application building absolute URLs from its own internal address — and it cost nothing here, because by this point it was a known property of running behind the gateway rather than a discovery.

That is most of the argument for doing infrastructure first.

Costnot temporary

Authentication for both applications now depends on one directory on one filesystem. It is a new single point of failure, it has to be backed up, and its permissions are load-bearing in a way no config file's were before.

Bought

One cookie, one session, one logout. A user moving between the old and new halves of the application cannot tell the boundary exists — which is the entire premise of a strangler migration.

05Rate cards

Rate cards, the smallest thing worth moving

A read-only screen with real domain logic and no ability to cost anyone money — and the EF Core default that silently truncated every decimal.

The first module to move is chosen almost entirely for what it cannot do.

Rate cards are read-only. No writes, no queue, no report, no money moves. What they do have is real domain logic — band lookup, effective dating — and a path that touches every part of the new stack: gateway routing, the shared cookie, EF Core against the live schema, the shared rating project.

Maximum coverage of the new path, minimum blast radius. That is the whole selection criterion.

The instinct that was wrong

The instinct was to start with the data layer, because it feels like the foundation and everything else sits on it.

It is the worst possible first choice. The data layer is the most coupled to Framework-only APIs — ConsignmentRepository reads its tenant off HttpContext.Current and cannot be called from anything that is not a web request, which the nightly manifest job discovered in 2018 and works around by faking a request context. Starting there puts the single hardest piece of work before anyone has any confidence in the new stack, and before there is any evidence the approach works at all.

Starting with a screen that displays numbers produces something deployable in days. That matters more than architectural tidiness in the first month of a migration, because the thing most likely to kill it is not a technical problem.

Same database, no data migration

Same tables, same rows, same schema the 4.8 application is still writing to. The migration moves code, not data.

Nothing in the new DbContext creates or migrates anything. Two code-first models racing to own one schema is how you lose a weekend and then a Saturday restoring from backup.

What broke

What brokeMinutes once the harness failed. Indefinite without it.
Symptom
Every screen displayed correct figures. Reads were perfect. Writes silently dropped the fourth decimal place of every money column, and nothing anywhere reported an error.
Cause
EF6 was explicitly told decimal(18,4). EF Core defaults decimal to (18,2). Because the application has always rounded to two places before writing, every value already in the table ended in two zeros — so the truncation had nothing to truncate and stayed invisible until a rate band with a genuine four-place rate went through.
Caught by
The parity harness. Nothing in the application would have — no exception, no warning, no visibly wrong number on any screen.

This is the one to take away from the whole log. It is invisible, it is silent, it affects money, and it is caused by a default rather than by anything anybody wrote.

MeridianContext.cs — EF6MeridianDbContext.cs — EF Core−6  +6

The fix is a loop over the model rather than an annotation per property, because the failure mode of the annotation approach is forgetting one.


          
          mb.Entity<Consignment>().Property(x => x.Carriage).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.FuelSurcharge).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.Net).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.Vat).HasPrecision(18, 4);
        
          
          mb.Entity<Consignment>().Property(x => x.Gross).HasPrecision(18, 4);
        
          
          mb.Entity<RateBand>().Property(x => x.RatePerKg).HasPrecision(18, 4);
        
          
          foreach (var property in b.Model.GetEntityTypes()
        
          
                       .SelectMany(t => t.GetProperties())
        
          
                       .Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?)))
        
          
          {
        
          
              property.SetColumnType("decimal(18,4)");
        
          
          }
        

Six hand-written lines become one loop, and the property added next year is covered without anybody remembering.

The behaviour that deliberately changed

The old application had two rate lookups. Inline SQL in the booking screen used BETWEEN and ordered by FromKg descending. A LINQ query in the rate card screen used >= and <= and ordered the same way. On an exact boundary weight — 10.0kg on a card whose bands are 0–10 and 10–25 — they returned different bands.

Both had been live for nine years. Nobody knew.

Choosing between them is a commercial question wearing a technical hat, and it was resolved on that basis: the SQL version wins, because it is the one that priced nine years of invoices. The LINQ version was only ever a preview on a screen nobody bills from.

One schema change

The three hard-coded customer ids from ConsignmentsController.Create became a column.

ConsignmentsController.csdb/001_discount_applies_to_net.sql−4  +5

          
          if (customer.Id == 1104 || customer.Id == 1187 || customer.Id == 1203)
        
          
              net = net - (net * customer.DiscountPercent / 100m);
        
          
          else
        
          
              net = net - (carriage * customer.DiscountPercent / 100m);
        
          
          ALTER TABLE dbo.Customer
        
          
              ADD DiscountAppliesToNet BIT NOT NULL
        
          
                  CONSTRAINT DF_Customer_DiscountAppliesToNet DEFAULT (0);
        
          
           
        
          
          UPDATE dbo.Customer SET DiscountAppliesToNet = 1 WHERE Id IN (1104, 1187, 1203);
        

It is the only change this migration made to an existing table. It earns its place twice: the rating engine can stay pure, and a fourth account on the same contract term becomes a row rather than a deployment.

Cost

Two implementations of the rate card screen now exist and both are reachable — the 4.8 one directly, the .NET 8 one through the gateway. Until the old route is deleted, a bug report has to establish which one the reporter was looking at.

Bought

A complete, deployable path through the new stack, proven end to end, on a module where being wrong costs nothing. Everything after this is a variation on a route that already works.

06Despatch handoff

MSMQ, an outbox, and a process that stays on 4.8 forever

The dependency that cannot be migrated at all — so it was made as small as possible and quarantined instead.

System.Messaging was never ported to .NET Core and will not be. There is no shim, no compatibility package, no community port, no supported way to put a message on an MSMQ queue from .NET 8.

So the question is not how do we migrate MSMQ. It is what do we do about the fact that we cannot.

What was rejected

Rewrite the depot integration onto RabbitMQ or Service Bus. The queue is read by software owned by a third party who were acquired in 2020. Changing the transport means a negotiation with another company about a contract-bearing interface, on their timetable, with their testing.

That is a real project with a real budget, and it is not this one. A migration that quietly absorbs a third-party integration rewrite is a migration that does not finish — and the way it fails is that it stops being possible to say what is left.

Keep the whole booking flow on 4.8 until the depot changes. This makes the one thing that cannot be migrated into a blocker for everything that can. The consignment write path is the core of the application; freezing it freezes the migration.

What was chosen

The dependency does not go away. It gets made as small as it can be, and a line gets drawn around it.

Despatch handoff, stage 06
Before
  1. Create action.NET 4.8
  2. SaveChangesEF6 · committed
  3. MessageQueue.SendSystem.Messaging
  4. Depotthird party
After
  1. POST /consignments.NET 8
  2. SaveChangesEF Core · one transaction
  3. DespatchOutboxtable
  4. Sidecar.NET 4.8
  5. Depotthird party
unchangedadaptednewgone

The 4.8 dependency is still there. It is now a 200-line process with no web surface and no business logic, whose entire job is to read a table and write to a queue.

The sidecar is the smallest hostage the migration could leave behind. When the depot system is eventually replaced, deleting sidecar/ is the whole change.

A correctness fix that was not the point

The old code sent to MSMQ after SaveChanges() had already committed, outside any transaction:

ConsignmentsController.csConsignmentEndpoints.cs + DespatchOutbox.cs−7  +3

Two writes to one database cannot half-happen. A database write and a queue send can, and did.


          
          _db.Consignments.Add(consignment);
        
          
          _db.SaveChanges();
        
          
           
        
          
          using (var queue = new MessageQueue(queuePath))
        
          
          {
        
          
              // No transaction. If SaveChanges above succeeded and this throws, the
        
          
              // consignment exists and the depot never hears about it.
        
          
              queue.Send(msg);
        
          
          }
        
          
          db.Consignments.Add(consignment);
        
          
          outbox.Enqueue(consignment.Reference, DespatchMessage.Format(...));
        
          
          await db.SaveChangesAsync(ct);
        

When the send threw, the consignment existed and the depot never heard about it. The only way anyone found out was a customer asking where their pallet had got to.

Despatch is asynchronous by a few seconds now, which the depot does not care about, and it cannot be silently skipped, which everybody does.

What broke

What brokeForty minutes to reproduce once the failure was injected
Symptom
The depot booked the same collection twice. Two vans, one pallet, one very unimpressed customer contact at the receiving end.
Cause
The drain retries on a failed send. A send that had actually succeeded and then timed out waiting for the acknowledgement was retried, and MSMQ had already accepted the first one. There was no way for the depot to tell the two apart.
Caught by
A soak run against a queue with an artificial 5% acknowledgement timeout. It does not reproduce at all under normal conditions, which is why it would have reached production.

The fix is IdempotencyKey and a unique index. The key is derived from the message body, so a genuine re-send of changed content is a different row and a blind retry is not.

The ordering that was wrong first

OutboxDrain claims a batch, sends each message, then marks it sent. The first version marked then sent, which reads more naturally and never leaves a row looking unsent when it was.

What it does instead is lose messages when the send throws.

The general shape: sending twice is recoverable, sending never is not. Given a choice between the failure mode that duplicates and the failure mode that drops, take the one that duplicates, and then spend the effort on making duplicates detectable.

Costnot temporary

There is now a .NET Framework 4.8 process in the estate that will outlive the migration. It needs a Windows host, its own deployment, its own monitoring, and it is a second thing that can be down when despatches stop moving.

Bought

The .NET 8 application can own the consignment write path — which is the core of the system — without being able to talk to MSMQ at all. And the handoff became transactional on the way past.

07Reporting

The stage that produced no code

Twenty RDLC reports with no .NET 8 renderer. The decision was to move none of them, and to write down the rule for when that changes.

Microsoft.ReportViewer.WebForms is a .NET Framework assembly. There is no .NET Core or .NET 5+ successor, and RDLC has no other renderer — the format is tied to the one control that reads it.

There are twenty of these files, written in the Visual Studio report designer between 2011 and 2017. One has a subreport that fetches its own rows. Several bind directly to a DataTable returned by a stored procedure.

The estimate that killed the rewrite

Rewriting twenty reports in QuestPDF is somewhere between three and six weeks, and the estimate is wide because RDLC reports are laid out visually and their behaviour at a page break is emergent rather than specified. You do not know what a report does at the bottom of page two until you render one.

So the useful question is not what it costs. It is what it buys.

Twenty reports, by who reads them and how often they change
Two customer-facing documents
Invoice and manifest. Seen by customers, printed on letterhead, and the ones anybody would notice a change to. Changed three times between them since 2017.
Eighteen internal operational reports
Depot throughput, exception lists, month-end reconciliation. Read by four people. Nineteen of the twenty files have not been edited since 2017.

Migrating something that has not changed in eight years buys nothing except the satisfaction of having migrated it. The reports work. They render the same PDF they rendered in 2017, on a runtime that is supported until 2031.

What was actually decided

All twenty stay. No report code moved, and the diff shows it — there is no PDF generation anywhere in modern/.

The rule agreed instead: a report gets rewritten the next time somebody asks for a change to it, and not before. The rewrite is then paid for by the change that triggered it, at a moment when someone actually cares about the output and can tell you whether it is right.

On current evidence that is close to never for eighteen of them, and that is the correct answer for eighteen of them.

The commit this stage produced

None. The metadata panel says so rather than showing a zero, because those are different facts and the site is built not to be able to confuse them.

The only commit that touched reporting in the whole history is ecb860b, and it does not change any report — it corrects this log. An earlier draft of the repository README claimed two of the twenty had been rewritten. They had not.

What brokeTwo commits to correct — and it is the most dangerous thing in this log
Symptom
The README's dependency table said '18 frozen, 2 rewritten'. The commit history contains no PDF generation of any kind.
Cause
I wrote the plan into the README as though it were the outcome. It was written before the estimate, and never revised after the decision went the other way.
Caught by
Re-reading the inventory against the actual diff while writing this page.

That correction is the reason this stage is here at all rather than being a paragraph inside stage 06. If the inventory of what is still on 4.8 cannot be trusted, none of the rest of the log can be either — every other claim in it is of exactly the same kind, and a reader has no independent way to check any of them.

A migration log’s only real asset is that its bad news is reliable.

Costnot temporary

Twenty RDLC reports and a ReportViewer dependency remain on .NET Framework 4.8 indefinitely, which means the 4.8 web application cannot be switched off, which means the shared key ring and the two-runtime estate stay too.

Bought

Three to six weeks not spent re-drawing documents that four people read and nobody has changed since 2017.

08Consignments

Consignments, moved last

The 272-line action. Everything hard converges here, which is why every other stage came first.

ConsignmentsController.Create is 272 lines and does nine things. Money, the queue, the tenant, the duplicate check and the audit trail all meet inside it.

It moves last, and the ordering of every earlier stage was chosen so that it could. By the time this endpoint was written, the rating rules were extracted and proven (02, 03), the cookie was shared (04), the new stack had a working end-to-end path (05), and the queue had an answer that did not involve MSMQ (06). What was left was orchestration.

The same booking, before and after
Before
One action method, 272 lines, 0 tests
After
One endpoint, 215 lines, delegating to 4 named collaborators
Rating
Meridian.Domain.RatingEngine — 4,011 assertions
Rate lookup
IRateLookup — one implementation, was two
Despatch
IDespatchOutbox — transactional, was fire-and-forget
Audit
Not moved. Still an HTTP module on the 4.8 side.

Three things that are worse here

Stated rather than hidden, because a stage where everything improved is a stage that has not been looked at properly.

The rating engine is called twice. Once to get the chargeable weight, once to price it — because the rate lookup needs the weight, and the weight is the engine’s business. The alternative was to duplicate the volumetric formula at the call site, which is precisely how the old application ended up with two of them. A redundant call is cheaper than a second copy of a rule, but it is genuinely redundant and the next reader will wonder.

Reference allocation is still MAX + 1. Two clerks booking in the same second still collide.

ConsignmentsController.NextReferenceConsignmentEndpoints.NextReferenceAsync−4  +7

Ported, not fixed. The bug is preserved exactly, with a comment saying so.


          
          var cmd = new SqlCommand(
        
          
              "SELECT ISNULL(MAX(CAST(SUBSTRING(Reference, 4, 10) AS INT)), 0) FROM Consignment", conn);
        
          
          var next = (int)cmd.ExecuteScalar() + 1;
        
          
          return "MER" + next.ToString("D8");
        
          
          var last = await db.Consignments
        
          
              .OrderByDescending(c => c.Id)
        
          
              .Select(c => c.Reference)
        
          
              .FirstOrDefaultAsync(ct);
        
          
           
        
          
          var next = last is { Length: > 3 } && int.TryParse(last[3..], out var n) ? n + 1 : 1;
        
          
          return $"MER{next:D8}";
        

Fixing it properly means a sequence, a schema change and a conversation with the depot about reference formats, because the format is on the wire in that fixed-width message. That is a real piece of work and it is not this migration’s. It is recorded as debt in a comment rather than smuggled in under cover of a migration — which is how migrations acquire scope nobody agreed to, and how a rollback stops being possible.

The WCF customs declaration did not move. It is still on the 4.8 side, still swallowing its exceptions from a bad fortnight in 2019, and this endpoint does not make the declaration at all. That is a gap rather than a decision. Client-side WCF does have a .NET 8 story, so this one is simply not done yet.

The ticket that closed as a side effect

MER-2213, open since 2019: the search box concatenates into the WHERE clause.

ConsignmentRepository.SearchGET /consignments−3  +3

          
          sql += "AND (c.Reference LIKE '%" + q + "%' " +
        
          
                 "  OR cu.Name LIKE '%" + q + "%' " +
        
          
                 "  OR c.DeliveryPostcode LIKE '%" + q + "%') ";
        
          
          query = query.Where(c =>
        
          
              EF.Functions.Like(c.Reference, $"%{q}%") ||
        
          
              EF.Functions.Like(c.DeliveryPostcode, $"%{q}%"));
        

It stayed open for six years not because it was hard but because fixing it meant retesting a screen nobody wanted to retest. Rewriting the screen was going to happen anyway, so the fix cost nothing.

That is worth being honest about rather than claiming as a win. The migration did not prioritise a security defect. It reached one on its way past.

The tenant stops being ambient

ConsignmentRepository read its tenant from HttpContext.Current.Items, which meant it could only be called from inside a web request. The nightly manifest job found that out in 2018 and works around it by constructing a fake HttpContext.

The tenant arrives as a claim now. That single change is what will let the manifest job stop pretending to be a web request when it moves — which it has not yet.

Cost

Bookings can now arrive through two code paths against one database — the 4.8 screen for humans, this endpoint for JSON clients. Until the Razor screens move, any investigation into a wrong price starts by establishing which half priced it.

Bought

The 272-line method is no longer the only place the pricing rules exist, and the rules it used to own are now covered by 4,011 assertions that run in a third of a second.

09Routing

Routing by Accept, not by path

The depot scanners and the human screens share a URL. Splitting on content type moved one without moving the other.

Stage 08 built a JSON consignments endpoint. The problem is that the 4.8 application already serves /consignments — as Razor views, to people, in a browser.

Both audiences want the same path. The depot terminals and the mobile app POST to /consignments wanting JSON; a clerk opens /consignments wanting a screen. The URL space does not distinguish them, and there is no version prefix to hide behind because these clients have been calling this path since 2014.

The options

Rewrite the screens too. Triples the size of the stage, and produces nothing for the person holding a scanner. The Razor views work.

Give the API a new path — /api/consignments. Clean, and it means changing firmware on depot terminals that are updated by physically visiting depots. That is a fleet operation, not a deployment.

Split on the header the clients already send.

appsettings.json — stage 01appsettings.json — stage 09−0  +7

Same path, same verbs, different clients. Route matching on Accept is the only thing that separates them.


          
          "consignments-api-new": {
        
          
          "ClusterId": "modern",
        
          
          "Match": {
        
          
            "Path": "/consignments/{**catch-all}",
        
          
              "Headers": [
        
          
                {
        
          
                  "Name": "Accept",
        
          
                  "Values": [ "application/json" ],
        
          
                  "Mode": "Contains"
        
          
                }
        
          
              ]
        
          
          },
        
          
          "Order": 2
        
          
          }
        

The machine clients moved to .NET 8 and nobody visited a depot. The screens stayed exactly where they were.

Why this is written down twice

A route that matches on a header is invisible in a stack trace. A request that lands on the wrong half looks like a bug in whichever half received it, and neither half knows the header exists — the 4.8 application has no idea it is being bypassed for some callers, and the .NET 8 one has no idea it is only seeing some of the traffic.

So it is in the gateway README as well as in the config. Configuration is not documentation: it says what happens, never why, and this is a rule whose why is the only thing that makes the next incident tractable.

What brokeHalf a day, almost all of it spent in the wrong application
Symptom
A depot terminal that had worked all morning started receiving HTML. Its requests were reaching the 4.8 application, which rendered a Razor view into a parser expecting JSON.
Cause
The terminal sends Accept: application/json, */* on most calls but Accept: */* on one retry path in its HTTP client. Mode: Contains matches the literal string, and */* does not contain application/json.
Caught by
By diffing the working and failing requests. Neither application logged anything unusual — both served exactly what they were asked for.

The fix was on the client side, because the alternative — treating */* as JSON at the gateway — would route a browser’s fallback request to the API and break the screens instead. A header split only works when the header is actually discriminating, and finding out which of your clients are sloppy about Accept is part of the cost of choosing it.

Cost

One URL is now served by two applications depending on a header, which is genuinely surprising and will surprise someone again. It also means the two halves must keep agreeing about what a consignment is for as long as both exist.

Bought

An entire class of client moved runtimes with no change on the client, no coordinated release, and no depot visit.

10Despatch labels

The one that compiles and then fails in production

System.Drawing is still on 4.8, and the reason it is stuck is worse than "no .NET 8 equivalent".

Label.ashx renders the 4x6 despatch label the depot printers expect, through GDI+, including a barcode drawn by hand from a Code 39 bar-width table because the licensed component was dropped in 2012 when nobody could find the licence file.

It has not moved, and it is worth being precise about why, because “System.Drawing does not work on .NET 8” is only half true and the half that is false is the dangerous one.

It compiles. That is the problem.

System.Drawing.Common still exists as a package. The API is all there. Code using it builds without a warning, and it runs perfectly on a developer’s Windows machine.

From .NET 6 onwards it throws PlatformNotSupportedException at runtime on anything that is not Windows. So the first time it fails is inside a Linux container, in whatever environment first runs one — which is a strictly worse failure mode than not compiling, because it passes every gate that would have caught it earlier.

Why this is a rewrite rather than a package swap
The barcode is geometry, not a library call
Bar widths come from a Code 39 table and are drawn as filled rectangles at a computed narrow-bar width. Porting it means re-deriving that geometry against SkiaSharp or ImageSharp, not changing an import.
The output has a physical acceptance test
The depot scanners are fussy about DPI and quiet zones. The only way to know a ported label is correct is to print one and scan it, in a depot, with the actual hardware.
The endpoint is anonymous
A <location> block in Web.config allows it without a cookie, because the scanner firmware cannot present one. Moving it means solving that too, and the current answer is that the reference is hard to guess.

Why it is listed at all

Because an inventory that only contains solved problems is not an inventory.

This stage produced one commit and that commit only edits a README — it removes a line claiming the label renderer had been migrated. It had not. The claim was written from the plan rather than from the diff, in the same draft that overstated the reports.

Two of these in one document is a pattern rather than a slip, and the pattern is that the summary was written before the work and never re-checked against what actually landed. The correction is cheap. The habit that produced it is not, and it is the reason every stage in this log now carries its commit hashes in the panel on the right: a claim with a hash beside it can be checked in about four seconds, and one without cannot be checked at all.

Cost

The 4.8 web application has to keep running to serve label requests, which contributes to the same estate cost as the reports. It is also the only anonymous endpoint in the system and it is on the old side, where the new authorisation policies do not reach.

Bought

Nothing yet. This is work not done, recorded as work not done.

11Estate

What is still running on .NET Framework, and why

The honest inventory. A migration log that stops at the last successful stage is a sales document.

The migration is not finished, and on current plans parts of it never will be. That is the normal outcome and it is worth writing down plainly, because the version of this document that ends at stage 08 with everything green would be useless to anyone deciding whether to start one.

Still .NET Framework 4.8
The despatch sidecar
MSMQ has no successor. Bounded to ~200 lines, no web surface, no business logic. Deletable the day the depot system changes, and not before. See stage 06.
Twenty RDLC reports
Microsoft.ReportViewer is Framework-only. Frozen by decision, rewritten individually when one changes. Nineteen have not changed since 2017. See stage 07.
Label rendering
System.Drawing. Blocked on a barcode rewrite with a physical acceptance test. Known, scoped, not started. See stage 10.
The consignment screens
Razor views served by IIS. The JSON clients moved in stage 09; the screens had no reason to follow and no budget to.
The customs declaration
A WCF call, still on the old side, still swallowing its exceptions. Client-side WCF does have a .NET 8 story, so this one is genuinely just not done.
The audit modules
Two IHttpModules writing a row per request, synchronously, on the request thread. They cover both halves today only because every request still passes through IIS first.

What got worse and stayed worse

Every stage above has a cost panel. Several of them are marked not temporary, and this is what those add up to.

There are two runtimes, two deployment pipelines, and two places to look when something is slow. Authentication depends on one directory on one filesystem that both applications must be able to read, which is a single point of failure that did not exist before. One URL is served by two applications depending on an HTTP header. The rating rules are written to a 2015 language standard because they have to compile on net48.

None of that is a transitional tax that disappears at the end. It is the running cost of having chosen a strangler migration, and it lasts as long as the sidecar and the reports do — which is to say, indefinitely.

That is the thing to weigh against a big-bang rewrite. Not “strangler is safer” in the abstract, but: are you willing to run two of everything for years, in exchange for never having a weekend where the whole company cannot book freight?

For a system that takes bookings all day, that trade is worth making. For an internal tool with forty users and a maintenance window, it very often is not, and the strangler pattern gets applied to things that would have been better served by a rewrite and a bank holiday.

The rule that produced this shape

One rule, held for every commit: no commit may leave the application unshippable.

That is why the shape above is untidy. A tidy migration — one where the inventory at the end is empty and the architecture diagram has no dotted lines — is a migration that took the system down to get there.

Fourteen commits, and at every one of them Meridian Despatch could take a booking.

What I would do differently

Nothing about the ordering. Gateway first, auth second, smallest module third was right, and the two days lost in stage 01 were the price of learning what the proxy does to an application that thinks it knows its own address.

The thing I would change is the writing. Twice in this log — the reports in stage 07, the labels in stage 10 — the summary was written from the plan rather than from the diff, and claimed work that had not happened. Both were caught by re-reading the inventory against git log, which means the check works, but it was a check applied at the end rather than a habit applied throughout.

The fix is the panel on the right of every stage on this page. A claim with a commit hash beside it can be checked in four seconds. One without it cannot be checked at all, and in a document whose only real asset is that its bad news is reliable, that difference is the whole thing.