This is not a tutorial. There are plenty of those, and they all use clean example codebases that look nothing like the legacy applications we actually migrate.
This is a project diary from a migration we ran in 2024 — a .NET Framework 4.5 wealth management portal that had been in production since 2012, maintained by a series of developers over twelve years, and never touched with any intention of eventually migrating it to a modern runtime. We were brought in because the system was slowing down, third-party integrations were becoming impossible, and finding developers willing to work in the codebase had become genuinely difficult.
I'm the lead developer who ran the migration. Here's what we actually found.
Week 0: Reading someone else's twelve-year-old code
The first thing you do before quoting a migration timeline is read the code. Not skim it. Read it. You're looking for the things the client doesn't know are in there, because those are the things that will surprise you mid-project if you don't find them first.
The codebase was about the size you'd expect for twelve years of a financial services portal. Around 300 controllers, a service layer that had been added halfway through the application's life (so roughly half the business logic was in the service layer and the other half was still in the controllers from before the refactor), and a SQL Server database with 87 tables.
We spend two weeks on the audit before committing to any timeline. Here's what we found.
The things we expected
Raw ADO.NET SQL queries in controller methods. We found 34 of them. This was common practice in 2012 and there's nothing inherently wrong with it from a 2012 perspective, but migrating to Entity Framework Core means cataloguing every raw query and understanding what each one is doing before you replace it. Some of them had business logic embedded in the SQL — joins and filtering that existed nowhere in the application layer — which meant the migration wasn't just a mechanical translation from ADO.NET to EF Core. It was understanding what the query was computing before rewriting it correctly.
Inconsistent error handling. This is almost universal in legacy codebases. Some controllers had try-catch blocks. Some returned bare 500 errors. Some swallowed exceptions and returned misleading success responses. We catalogued these but didn't fix them during the migration — fixing error handling mid-migration is a good way to introduce new bugs while you're already managing migration risk. We flagged them for a post-migration cleanup pass.
A service layer that had been written by three different developers with three different opinions about what the service layer was for. Some services were thin wrappers over repository calls. Some contained significant business logic. Some were doing things that properly belonged in the controller or the model. We mapped the dependencies before touching anything.
The things we didn't expect
There were two.
The first was a third-party DLL that generated the FCA compliance reports. It had been supplied by a vendor in 2014, compiled for .NET Framework 4.0, with no source code and no documentation beyond a one-page integration guide. The vendor had gone out of business in 2018. There was no .NET Core equivalent. There was no way to call a .NET Framework 4.0 DLL from a .NET 8 application.
This was the most significant thing we found in the audit. The compliance report was a regulatory requirement — the FCA mandated a specific format, and the existing report output was what the compliance team had submitted without issue for ten years. We had to reverse-engineer what the DLL was doing from the output format and the FCA's published specification, rebuild it natively in .NET 8 using iTextSharp, and have the output validated by the client's compliance team against fifty historical reports before it went anywhere near production.
That piece of work alone added three weeks to the migration timeline. Finding it in week 0 meant we could plan for it. Finding it in week 8 would have been a crisis.
The second thing we didn't expect was Thread.Sleep() in the overnight report generation job. Not one Thread.Sleep(). Seven of them, scattered through a reporting job that ran at 2am, pausing between report sections for reasons that were commented only as 'rate limiting' — without any indication of what was being rate-limited or why the specific durations had been chosen. The job had last been touched in 2017. The developer who wrote the 2017 version was no longer at the company.
The migration approach: why we didn't do a parallel rebuild
The client had been told by two previous agencies that the right approach was to rebuild the portal in parallel — build a new .NET 8 version alongside the existing system, run both for a period, then cut over in a maintenance window. The client's CTO had seen this approach go badly at a previous employer and wasn't willing to do it again.
He was right to be cautious. A parallel rebuild with a hard cutover creates a data synchronisation problem. The longer the parallel period runs, the more real data has been written to the old system that needs to be reconciled before the new system goes live. And if something breaks on the cutover night — which it very often does — the rollback is never as clean as the documentation says it will be.
We proposed the strangler fig pattern instead. Place a routing layer in front of the existing application. Migrate module by module. The old system handles everything until a module is migrated and validated, then the router switches that module's traffic to .NET 8. The legacy application retires gradually. Users notice nothing. At no point is the system fully offline.
Week 1
└── Nginx deployed as the routing layer
Week 4
├── Authentication → .NET 8
└── Remaining modules → .NET Framework 4.5
Week 7
├── Authentication → .NET 8
├── Portfolio → .NET 8
├── Documents → .NET 8
└── Remaining modules → .NET Framework 4.5
Week 10
├── Core finance workflows → .NET 8
└── Legacy platform still active
Week 14
└── All traffic routed to .NET 8
Result:
✓ Zero downtime
✓ No big-bang release
✓ Legacy IIS server retired
The migration, sprint by sprint
Weeks 1–2: Infrastructure and the routing layer
Before migrating any code, we set up the infrastructure the migration would run on. Azure App Service for the .NET 8 application, running alongside the existing IIS server. SQL Server 2022 database — the same schema as the existing SQL Server 2014 database, populated with a full copy of the production data. The Nginx routing layer deployed and smoke-tested in staging.
The decision to run SQL Server 2022 alongside SQL Server 2014 (rather than migrating the database first) was deliberate. Database migrations and application migrations compound the risk. We migrated the application first, validated it against a copy of the production database, and migrated the database separately after the application was stable on .NET 8.
Authentication was the right place to start. It's a clean module with well-understood behaviour, it affects every user on every request, and getting it right early meant every subsequent module could build on a known-good foundation.
ASP.NET Forms Authentication to ASP.NET Core Identity. Session cookies to JWT tokens stored in Azure Cache for Redis. The migration was technically straightforward — the complexity was in validation. We ran the new authentication alongside the old for 48 hours in a canary deployment before switching all traffic. Three hundred users logged in through the new authentication stack on the first morning without a single reported issue.
The side effect we hadn't planned for: stateless JWT authentication meant the application could now scale horizontally on Azure App Service. The old Forms Authentication used server-side session state that required sticky sessions — every user had to hit the same server on every request. JWT with Redis-backed token storage removed that constraint entirely. Not the reason we made the change, but a meaningful infrastructure improvement.
This was the most-used part of the application. Financial advisers spent the majority of their working day in the portfolio view. Any regression here would be noticed immediately and reported loudly.
The 34 raw ADO.NET SQL queries we'd catalogued in the audit were concentrated here. We migrated them to Entity Framework Core 8 with a DbContext structured around the existing schema — no schema changes, same tables, typed access instead of raw queries. This took three weeks because several queries had business logic embedded in them that needed to be understood before it could be correctly expressed in LINQ.
The performance improvement was a surprise. Average portfolio page load time dropped from 4.1 seconds to 1.4 seconds — a 68% improvement — without any changes to the queries, the schema, or the caching strategy. Pure runtime improvement from .NET 8's garbage collector and EF Core's connection pooling and query batching. We'd told the client they might see some performance improvement. We hadn't told them it would be that significant because we hadn't expected it to be.
The document module was the most operationally complex migration. Eleven years of client documents — investment reports, compliance filings, correspondence — totalling 340GB, stored on an on-premise file share attached to the IIS server.
Moving 340GB of documents to Azure Blob Storage while the system stays live requires a careful migration sequence:
This took 9 nights of background sync jobs. Not a single document was unavailable at any point during the migration. The compliance team — who were monitoring this closely — reported no issues.
Three weeks to rebuild what a DLL had been doing for ten years.
The approach: reverse-engineer the output format from the FCA's published specification (the actual regulation document, not the DLL documentation which no longer existed), rebuild the report generation natively using iTextSharp for PDF generation and a clean service class, and validate the output against fifty historical reports that the compliance team provided.
The validation process was the longest part. The compliance team reviewed each report section by section — header, client details table, portfolio summary, transaction history, fee disclosure — comparing the new output against the historical report. There were three formatting discrepancies in the first validation pass. Two were minor (a currency symbol placement and a date format), one was substantive (the fee disclosure table was rendering differently for accounts with multiple fee structures). All three were fixed and re-validated before the module went live.
The module went into production in week 12. The compliance team's sign-off: 'identical to what we've been submitting.'
The overnight reporting job. The seven Thread.Sleep() calls that paused between report sections for undocumented reasons.
We made a decision here that I think was the right one but took some convincing to get approved: we refactored the job rather than migrated it. Thread.Sleep() is a synchronous blocking pattern that doesn't translate to .NET 8's async/await model cleanly — you can migrate it literally, but you end up with a job that's running on the modern runtime but using patterns that undermine the performance benefits. We rebuilt the job using IHostedService with Quartz.NET for scheduling, made every step properly async, and removed the Thread.Sleep() delays while adding proper rate-limiting where it was genuinely needed (we determined from the API documentation that the rate limit the 2017 developer had been working around was 100 requests per minute — something a proper Polly retry policy handles better than a fixed 500ms pause).
The full test suite ran against both the old and new job in parallel for 48 hours before we switched. Identical output.
Final step: the routing layer updated to send all traffic to .NET 8. The legacy IIS server left running and monitoring for 72 hours with zero traffic, then decommissioned. 14 weeks after we started, the application was fully on .NET 8.
What the application looks like now
| Runtime | NET 8 LTS on Azure App Service — active support until November 2026 (extendable to 2026 LTSQ) |
| Auth | ASP.NET Core Identity + JWT + Azure Redis cache — stateless, scalable |
| Data access | Entity Framework Core 8 — typed, tested, no raw SQL in controllers |
| Files | Azure Blob Storage — 340GB, CDN-backed, geo-redundant |
| Compliance | Natively rebuilt report generator — source code owned, FCA format validated |
| Scheduling | Quartz.NET + IHostedService — proper async, retry policies, no Thread.Sleep() |
| CI/CD | Azure DevOps pipeline — automated, gated, replaces manual FTP deploy |
| Page load | 1.4s average (portfolio view) — down from 4.1s, no caching changes |
| Downtime | Zero hours across the 14-week migration |
| Developer pool | Full .NET 8 market now accessible — the talent constraint is gone |
What I'd tell someone about to start a similar migration
Do the audit first, properly. Two weeks of paid codebase analysis before committing to a timeline. The discoveries that would have derailed us mid-project — the compliance DLL, the undocumented Thread.Sleep() rationale — would have been very expensive to find in week 8. Finding them in week 0 meant we could plan for them.
Don't migrate the database at the same time as the application. Compounding migrations compound the risk. Get the application stable on .NET 8 against a copy of the production database first. Migrate the database schema separately, after, when you have a .NET 8 codebase you trust.
Don't fix everything that's wrong. Every legacy codebase has things that should be fixed that are not causing the migration to fail. The error handling inconsistencies we found in week 0 were real problems. They were not migration problems. We flagged them for a post-migration cleanup pass and didn't touch them during the migration. Fixing things that aren't breaking the migration adds risk without reducing it.
The strangler fig pattern works. It's more work upfront (the routing layer, the canary deployment process, the module-by-module validation) than a parallel rebuild, but it eliminates the cutover risk that makes parallel rebuilds frightening. For a system in production with real users, it's the right approach.
Frequently Asked Questions
Have a legacy .NET Framework application you've been putting off migrating?
We do this work. .NET 4.x, .NET Framework 4.5, ASP.NET Web Forms, WCF services — we've migrated all of them to .NET 8 without downtime. Start with a codebase audit. ISO 27001. NDA from day one.