The decision to move off .NET Framework is usually easy. Framework 4.8 is the end of that line: still supported, still patched, but receiving nothing new while the ecosystem moves on without it. Packages drop their Framework targets. Hiring gets harder. The gap compounds.
What is not easy is the estimate, because the work divides into three very different kinds, and most of the risk sits in the smallest of them.
The third column is the whole problem
Business logic, domain models and plain C# usually move with far less friction than people expect. The language is the same language. A well-separated service class often compiles against modern .NET with no changes at all.
The right column is different in kind. Web Forms does not exist in modern .NET. There is no compatibility shim and no migration tool, because the postback and ViewState model has no equivalent. A Web Forms application has to be re-expressed, usually as MVC or Razor Pages, sometimes Blazor where the interaction model genuinely suits it. That is a rewrite of the presentation layer even when every line of business logic behind it survives intact.
Server-side WCF is also gone. Services move to ASP.NET Core as REST or gRPC. Where external consumers depend on the existing SOAP contract and cannot be changed on your timetable, CoreWCF can hold that interface steady while everything behind it is replaced. That is often the difference between a migration you can schedule and one that requires every client to move with you.
Estimating a migration without first classifying the codebase this way is how projects end up at three times their original number.
Find the blockers in week one
The single finding that can stop a migration dead is a Framework-only dependency with no modern equivalent and no source. Commercial reporting components, old payment SDKs and hardware drivers are the usual suspects.
This belongs at the very start, not in month six:
# every package in the solution, to be checked for a modern target
dotnet list package --include-transitive
For each one the question is narrow: is there a version targeting .NET Standard 2.0 or later, is there a supported replacement, or is this a wrapper somebody now has to write? The third answer is a project of its own and needs to appear in the plan rather than as a surprise.
The .NET Upgrade Assistant and try-convert will do a first pass and are worth running early, but treat their output as a survey rather than a result. They tell you where the work is. They do not do it.
HttpContext.Current is the largest mechanical task
In older codebases HttpContext.Current tends to be reached for from anywhere, including
deep inside business logic that has no business knowing it is running in a web request.
There is no equivalent in modern .NET, by design.
The fix is not a compatibility shim. It is passing what the code actually needs:
// Framework: a static reach into ambient state, from anywhere
public decimal GetDiscount(Order order)
{
var user = (User)HttpContext.Current.Session["CurrentUser"];
var market = HttpContext.Current.Request.Cookies["market"]?.Value;
return Pricing.For(user.Tier, market).Apply(order);
}
// Modern: the dependency is visible in the signature, and the method is testable
public decimal GetDiscount(Order order, UserTier tier, string market)
=> Pricing.For(tier, market).Apply(order);
This is tedious rather than difficult, and it is usually the largest single line item in the migration. It is also the most valuable: every call site you untangle becomes testable, and that coverage is what makes the rest of the work safe.
The defaults changed, and they changed quietly
This is where migrations produce bad data rather than exceptions, which is considerably worse.
String comparison. Framework on Windows used NLS for culture-sensitive comparison and
sorting. Modern .NET uses ICU. Sort order and casing behaviour can differ for the same
input. Code that compared identifiers without specifying StringComparison.Ordinal was
relying on behaviour that is no longer guaranteed to match.
Culture and number parsing. Parsing that depends on the ambient culture will follow whatever the host resolves to, and that may not be what the old environment resolved to.
Date and time. Round-trip behaviour and the handling of unspecified-kind values differ in edge cases that your production data will contain even when your tests do not.
Serialisation. BinaryFormatter is obsolete and disabled, correctly, because it is
dangerous. Anything persisted or cached with it needs a migration path for the existing
data, not only a code change. This one catches people, because the old data is still
sitting there whatever the new code does.
The defence is to make the previously implicit explicit, everywhere it touches data you care about:
// relying on ambient culture: behaviour depends on the host
if (code.ToLower() == "mk") { }
var amount = decimal.Parse(input);
// stated outright: behaviour is the same everywhere it runs
if (string.Equals(code, "MK", StringComparison.OrdinalIgnoreCase)) { }
var amount = decimal.Parse(input, CultureInfo.InvariantCulture);
Characterisation tests, not correctness tests
Older codebases often have little automated coverage, and the migration is exactly when you need it. But the tests worth writing here are a specific kind.
You are not trying to prove the code is right. You are trying to prove the new behaviour matches the old, including where the old behaviour is odd. Capture real inputs and the outputs the Framework build currently produces, then assert the modern build agrees:
[Theory]
[MemberData(nameof(ProductionSamples))] // real inputs, captured from the Framework build
public void Pricing_matches_legacy(PricingInput input, decimal expectedFromFramework)
=> Assert.Equal(expectedFromFramework, _modern.Calculate(input));
Where this differs from ordinary testing: a failure is not automatically a bug in the new code. It might be the rounding behaviour the finance team has been reconciling against for nine years. Those cases need a decision from somebody who knows the business, not a fix from whoever is holding the keyboard.
IIS can stay
A detail that changes the shape of a plan more than people expect: modern .NET hosts perfectly well in IIS, behind the ASP.NET Core Module. You do not have to containerise, move to Linux, or change your hosting arrangement in the same project.
Doing all of it at once is a common and expensive instinct. If the application runs on IIS on Windows today and that is working, leave it there. Migrate the framework, prove it in production, and treat hosting as a separate decision with its own justification.
The order that de-risks
Each step exists to make the next one safer, which is why the sequence matters more than the pace.
- Upgrade in place. Latest Framework version, packages current, build reproducible. Change nothing structural until the starting point is stable.
- Extract what is portable. Business logic, domain models and data access into projects targeting .NET Standard or modern .NET. This shrinks what is left, and it is where the characterisation tests get written.
- Replace the seams. Configuration, dependency injection, logging and the
System.Webdependencies, so the remaining code stops assuming Framework. - Re-express the right column. Web Forms and WCF last, when everything beneath them is already modern, tested and running.
A routing layer in front throughout means migrated capability goes live in slices, and any slice can be sent back to the old application by changing a rule rather than by rolling back a release.
What we would ask before estimating
Which Framework version you are on. Whether Web Forms or WCF are in scope, and whether external consumers depend on those SOAP contracts. What your third-party dependencies are, and whether any are Framework-only. Whether you must stay on IIS. And how long you can tolerate running both versions side by side.
That last answer shapes the plan more than any of the technical ones, because it decides whether this is a sequence of small releases or one large one.