My experience migrating a legacy .NET application to .NET 6
My experience migrating a legacy .NET application to .NET 6 started with a twelve-year-old internal platform built on ASP.NET MVC, .NET Framework 4.7.2, Windows Authentication, and SQL Server. It handled quotations, customer accounts, and approvals for staff in Sydney, Melbourne, and Brisbane. Although the application had become difficult to extend, it was stable and deeply integrated, so replacing it wholesale would have created more risk than the old runtime itself.
The business wanted modern deployment, current security patching, and a path toward cloud hosting. At the time, .NET 6 was a supported long-term release, which gave us a stable target rather than chasing the newest framework. I treated the work as a controlled port: preserve business behaviour, make hidden assumptions visible, and release improvements in small slices that operations could observe and support. This kept a familiar workflow in front of users while the engine underneath it changed.
The hard parts were not the C# compiler changes. They were request pipeline differences, package incompatibilities, machine-specific configuration, identity providers, scheduled jobs, and an integration endpoint nobody remembered owning. The legacy solution also mixed unit tests with a large collection of manual checks. My definition of success was therefore practical: the same tasks completed, the same audit records appeared, and support could explain every difference.
Eventually, the application ran on ASP.NET Core under .NET 6 while SQL Server and several external integrations remained in place. The effort produced a clearer boundary around services, faster local startup, and repeatable deployments. It also gave us confidence to modernise incrementally. This is the approach that worked for me, and the details below cover the decisions, dead ends, and verification that made the migration survivable.
Why the legacy application needed a new runtime
The original platform had accumulated several generations of work. A Web Forms page called into an MVC controller, while a reporting console used a different framework version. Configuration lived across Web.config, app.config, environment variables, and a database settings table. Some dependencies were commercial; others had been copied into the solution without clear ownership. Before changing code, I froze feature work and inventoried every project, package, executable, scheduled task, and integration.
I recorded why each part existed as well as what it did. A repository named Accounts.Business sounded disposable, but it contained validation rules used by three front ends. Another package supplied a printer driver and could not run on the target operating system. Finding these constraints early gave us a practical order of work: isolate the unportable pieces, decide whether to replace or wrap them, and avoid assuming that every class had to move immediately.
Support dates shaped the business case. The old framework and several libraries were approaching end of life, while .NET 6 offered a long support window and more flexible hosting options. I did not promise that .NET 6 would be the final destination. The goal was an application that could reach later supported .NET releases with less friction, without asking operations to rewrite the customer process at the same time.
Mapping the system before changing it
I began with a clean build on a current Windows development machine, then worked through compiler and package errors in dependency order. I converted the class library projects to the SDK format, set a consistent target framework, and removed references that were no longer required. Where a library offered a compatible newer version, I upgraded it; where it did not, I created a temporary adapter instead of forcing a risky rewrite into the main migration.
Package compatibility produced the longest tail of surprises. Older MVC libraries expected System.Web, while charting, PDF, and identity packages carried transitive dependencies with different framework assumptions. dotnet list package --outdated helped reveal age, but it did not prove runtime behaviour. For each critical dependency, I checked the supported target framework, release notes, licensing, and whether it could be tested independently. This avoided mistaking a successful restore for a completed port.
Before editing the application, I captured a baseline: build duration, test results, key page timings, batch job totals, database query counts, and the exact responses produced by important APIs. I also saved representative payloads and error messages. Later, those artifacts made it possible to distinguish a harmless framework difference from a regression. They were more useful than a vague ambition to make the new version faster, and they gave product owners evidence when they asked why a screen behaved differently.
Moving the web application to ASP.NET Core
The web application needed more than a new project file. I recreated the MVC structure with controllers, views, filters, model binding, and validation, then moved shared code into class libraries that did not depend on HttpContext. Dependency injection replaced service locators, and logging moved to the standard logging abstractions. I kept the first change narrow: load one low-risk read-only area, compare its output with production, and resolve the surrounding infrastructure before moving a write path.
Configuration and startup changed significantly. Web.config sections were translated into strongly typed options, secrets were removed from source control, and environment-specific values were supplied by deployment configuration. Startup tasks that previously relied on Application_Start or HttpContext.Current were moved into explicit hosted services or command-line jobs. Cookie settings, request limits, compression, and forwarded-header rules were checked against the original IIS configuration rather than copied blindly.
The strangest bugs came from assumptions about process lifetime and thread context. Legacy code sometimes cached a user principal or used static state that happened to survive between requests. ASP.NET Core’s asynchronous pipeline exposed those mistakes. I replaced ambient state with scoped services and passed required values through method arguments. Where business behaviour was obscure, I added a focused test before altering it. The aim was not to redesign the domain; it was to make the runtime’s boundaries explicit without changing the customer’s result.
Separating data, identity, and infrastructure
Keeping SQL Server reduced the scope, but it did not mean keeping every data-access habit. I moved connection handling to Microsoft.Data.SqlClient, reviewed parameterisation and command timeouts, and separated database credentials from connection strings. EF6 could be retained for a time, yet I placed it behind repositories so a later move to EF Core would not infect the entire application. Before any schema change, I compared indexes, transaction boundaries, locking behaviour, and generated SQL against production-sized data.
Windows Authentication also required deliberate testing. The application moved from classic ASP.NET to ASP.NET Core’s authentication middleware, and service identities were not interchangeable with browser identities. I verified that IIS impersonation, database access, and service-to-service calls still carried the correct claims. The audit trail mattered as much as the screen: operators needed to know which person or service performed an action. Changes to cookie names, key rotation, and session lifetime were scheduled to avoid signing users out unexpectedly.
Infrastructure was another boundary. If a component still ran inside a virtual machine, I checked its storage placement and failover behaviour before moving the application tier; the vSphere storage guidance was useful for understanding those dependencies. We kept customer data in Australian hosting regions and reviewed the solution against the Privacy Act and Australian Privacy Principles rather than treating cloud migration as automatically compliant. These checks were more demanding than copying a server image, but they prevented portability from becoming an unbounded commitment.
Testing for business parity
Parity testing gave the migration a definition of done. Existing unit tests were repaired, high-value service rules gained integration tests, and critical workflows received end-to-end coverage through Selenium or Playwright. I compared rendered fields, validation messages, permissions, generated documents, and side effects in the database. Contract tests covered each external integration because a successful page load could still conceal malformed data sent to a partner. Tests ran in CI on every pull request and used secrets supplied outside the repository.
Performance testing used realistic volumes rather than small demonstration datasets. I replayed common searches, quotation calculations, and month-end reporting, then compared response times, CPU use, memory pressure, and SQL execution plans with the baseline. Because Brisbane remained on AEST while Sydney and Melbourne observed AEDT during daylight saving, scheduled jobs had to respect both offsets and the changing daylight-saving boundary. I also checked behaviour when dependencies were slow or unavailable, since graceful failure is often more valuable to customers than shaving a few milliseconds from a normal response.
The release used a shadow-read and canary approach where possible. The new service handled non-critical reads beside the legacy application, and selected users or offices were enabled for new write paths after each comparison. The local Sydney and Melbourne market for experienced .NET engineers is competitive, so our handover custom was to brief the named local support lead, explain the changes in plain language, attach a rollback note, and agree the cutover window with operations. Each release had a short observation period, a clear owner, and a documented decision to continue or revert.
Operating the migrated application with confidence
On Windows, production hosting moved to the .NET 6 ASP.NET Core runtime through IIS and the ASP.NET Core Module, with health checks and structured logs feeding the existing monitoring platform. Deployment packages became versioned artifacts, and the release pipeline applied migrations separately from application startup when that gave us a safer rollback path. Database permissions, outbound certificates, and service accounts were automated rather than left as undocumented steps on one server.
The most valuable outcome was not the framework version itself. Teams could develop on any supported operating system, build the application in CI, and see dependencies clearly in one project file. The code was easier to test because business services no longer depended on web-specific classes. Since .NET 6 reached end of support in November 2024, I would now use this experience to plan an upgrade to a supported LTS release, but the same separation of dependencies, data access, and deployment makes that next step far less risky.
Use the migration to force clarity before asking anyone to change. Document the smallest valuable slice, capture parity evidence, and ship it behind a rollback path. Start your own effort with a one-page system inventory, a testable checklist, and one read-only workflow in production; then expand only after real users and support staff have confirmed the result. That sequence turns a daunting platform replacement into a series of observable decisions.