This is a checklist for modernizing legacy .NET applications: moving a .NET Framework codebase to modern .NET without a big-bang rewrite. Steps 2 to 8 cover that incremental path. An application already on modern .NET needs none of the coexistence work here and takes an in-place version upgrade instead. WebForms, MVC 5, and Web API 2 all run the same .NET modernization process; what changes between them is how much of the work lands on the UI. Every step ends in its outputs and a Done when condition. Rearchitecting into microservices is a separate project.
The same eight steps run on every route; only the volume of work changes.
Step 1. Choose the migration route
Goal: place each application on one route and fix the target version before anyone writes an estimate.
| Current stack | Typical route | Main migration work |
|---|---|---|
| Out-of-support modern .NET (Core 2.x–7) | In-place version upgrade | Packages, hosting model, routing, serialization |
| .NET Framework + Web API 2 | Incremental API migration | Controllers, filters, configuration, HTTP contracts |
| .NET Framework + MVC 5 | Incremental ASP.NET Core migration | Controllers, views, filters, System.Web dependencies |
| .NET Framework + WebForms | UI rebuild plus business-logic extraction | ViewState, code-behind, third-party controls, state |
Three inputs decide the row: the current target framework and its support status, the web framework, and the data-access stack with its database dependencies.
Upgrading inside modern .NET
This route needs no coexistence layer. The work is a version upgrade:
- Set the target version.
- Update the packages.
- Resolve the breaking changes.
- Check the hosting model, routing, and serializer behavior. These change between releases.
- Repeat the same steps for each intermediate version on the upgrade path.
Microsoft publishes an ASP.NET Core migration guide for each version step; the links are in the References.
For a migration starting now, target .NET 10 unless a dependency, a platform constraint, or a vendor requirement points to another supported version. That buys support into November 2028. .NET 8 and .NET 9 reach end of support on 10 November 2026 (Microsoft).
What the runtime change touches
Retargeting is only the starting point. Even inside modern .NET, an upgrade can change packages, hosting, configuration, and runtime behavior. A .NET Framework to modern .NET migration goes further: it also has to replace or bridge runtime-specific APIs. System.Web is the main one. It carries MVC 5 and WebForms, and ASP.NET Core has no counterpart for it.
| Legacy area | Required action |
|---|---|
| System.Web (HttpContext.Current, session, ViewState) | Replace the dependency, or bridge it temporarily with System.Web Adapters |
| IHttpModule / IHttpHandler / Global.asax | Move to middleware, filters, endpoints, or hosted services |
| web.config and its transforms | Move to modern configuration and a secret store |
| Session, cookies, and ViewState | Define the coexistence approach, and rebuild the UI state model explicitly |
| Legacy NuGet packages and Windows-only APIs | Upgrade, replace, or isolate behind an interface; confirm the hosting model supports what stays |
Outputs: one route per application, the target version with its reason, and the list of blocking dependencies.
Done when: every application in scope has a route, and each blocking dependency has an owner and a rough cost.
The example used below
- ASP.NET MVC 5 on .NET Framework 4.8.1,
- Razor views,
- Entity Framework 6,
- SQL Server.
Two external integrations depend on fixed URLs, and part of the business logic sits in stored procedures. EF6 and the existing schema stay through the runtime migration; controllers, filters, authentication, configuration, and System.Web dependencies move incrementally.
Step 2. Assess the system
Goal: replace assumptions with an inventory. Nothing else in .NET application modernization can be estimated until this is done.
Code and packages
- all projects, their target frameworks, and the libraries shared between applications;
- packages with no modern build, plus dotnet list package --vulnerable output;
- every System.Web call site and every BinaryFormatter usage, in code and in stored data;
- Windows-only APIs (COM interop, registry, System.Drawing.Common);
- custom serializers, binders, filters, modules, and handlers.
Integrations and data
- external APIs, webhooks, cross-application calls, and direct database access from other systems;
- identity providers, outbound email, file shares, and reporting systems;
- queues, scheduled jobs, and Windows Services;
- ORM versions, stored procedures, triggers, and transaction boundaries;
- serialized data and manual database scripts.
UI, on the WebForms and MVC 5 routes
- pages, ViewState usage, and business logic in code-behind;
- bundling, localization, and the frontend build;
- third-party UI and reporting controls, inventoried by vendor and version, each checked for modern .NET support and for the licence cost of the supported release.
Controls that have to be replaced or rebuilt are their own estimate line, not a detail inside a screen. On a WebForms application, licences and unsupported versions can swallow a large part of the budget, which is why this number comes before any date is agreed.
Test baseline
- the business-critical workflows and the production inputs that exercise them;
- recorded outputs for calculations, generated files, reports, integrations, and error handling;
- characterization tests (Verify or ApprovalTests) wherever coverage is missing.
Those tests capture current observable behavior, defects included, which is what makes a regression visible later. Document the known defects separately, so an accidental change is distinguishable from an approved fix.
Two sources of assessment
- Automated analysis finds incompatible APIs, unsupported packages, target-framework issues, and build errors. Use the current Microsoft tooling for it; the References list which tool that is today.
- Manual review finds what a solution scan cannot reach: shared databases, external systems, scheduled jobs, deployment scripts, infrastructure dependencies, and operational procedures.
Outputs: a system and dependency map, a module and integration inventory, a blocker list, the coverage gaps, the recommended module order, and an estimate range per module.
Done when: critical workflows have a recorded baseline, each module has its dependencies and an owner, high-risk blockers are separate work packages, and the estimate is per module rather than one total.
Step 3. Prepare shared code and infrastructure
Goal: get the shared libraries, authentication, configuration, database tooling, and pipeline ready while the legacy application is still alone in production.
The empty application first
The new application ships before any business logic moves into it. At that point it holds:
- an ASP.NET Core project with dependency injection, global error handling, and health checks;
- structured logs with correlation IDs;
- configured environments and a pipeline that deploys them;
- the reverse proxy in front of the legacy application, on the Strangler Fig route.
It is ready when it builds, deploys, starts on staging, and accepts test traffic while doing nothing else.
Project preparation
- Create a migration branch and a CI build for it.
- Retarget out-of-support .NET Framework applications to 4.8.1 and revalidate.
- Convert the shared libraries the new application will reuse to SDK-style projects, replacing packages.config with PackageReference.
- Build, test, and deploy once before anything touches the runtime, because the conversion changes how MSBuild restores packages.
- Remove or isolate the System.Web dependencies inside those shared libraries, since they block reuse from the modern application.
- Keep unrelated refactoring out of this branch.
A shared library that needs only APIs both runtimes have targets netstandard2.0. One that needs different APIs per runtime multi-targets, and only if both applications actually use it:
<PropertyGroup> <TargetFrameworks>net48;net10.0</TargetFrameworks> </PropertyGroup>
System.Web Adapters can carry shared libraries through coexistence, but it is a temporary layer with a removal date, and session and authentication sharing still take configuration on both sides.
Authentication
Choose one authentication bridge:
- Shared cookies, when the legacy application already runs Katana cookie authentication and both can share data-protection keys. Cookie name, application name, scheme, domain, path, SameSite, and Secure all have to match, or only one application reads the cookie (Microsoft Learn).
- Remote authentication, when the legacy application keeps sign-in for the length of the migration and System.Web Adapters delegates back to it.
Stored credentials are a separate work package. ASP.NET Membership hashes do not verify against ASP.NET Core Identity. Establish the legacy password format first. Then add a verifier for that format, or rehash each password on first login and keep the legacy check as a fallback. Test login, logout, expiry, and rollback in both directions before the first module moves.
Configuration
- every web.config setting mapped to a modern source;
- transitional settings read from one place, not two files synced by hand;
- secrets in a secret store, environment overrides documented;
- settings that cannot be shared listed, each application testable alone.
Database
Both applications write to the same database, so no schema change may break the legacy one. Expand/contract is the rule:
Add backward-compatible schema → deploy while legacy code still works → backfill data → move reads and writes → verify rollback → remove old schema after legacy retirement
Every change ships as a versioned migration committed with the application code, applied through the pipeline, and validated against both application versions, with rollback tested against each transitional schema version. Use EF6 migrations while the application runs on EF6, EF Core migrations after the switch, and DbUp or FluentMigrator for schema outside the ORM.
Delivery and telemetry
One pipeline covers both applications and the schema-migration step. Add a vulnerability gate (dotnet list package --vulnerable, Dependabot or Renovate), central log storage with redaction rules, and a rollback procedure that has been executed rather than only written.
Outputs: SDK-style shared libraries, shared authentication and configuration, versioned migrations, and one pipeline with a tested rollback.
Done when: a user signs in once and both applications accept the session, both read transitional settings from one source, schema changes stay backward-compatible, and a request can be traced across both.
Step 4. Choose the migration pattern
Goal: decide how the two implementations coexist, and record the feature policy that applies while both are live.
| Pattern | Use when | Avoid when |
|---|---|---|
| Strangler Fig | Production must stay available and routes can be isolated | Routing or data boundaries cannot be separated safely |
| Branch by abstraction | Internal logic has a stable contract and both implementations must coexist | Behavior cannot be put behind a clear boundary |
| Big bang | Small isolated application, controlled downtime, strong coverage | Production-critical or poorly understood systems |
Setting up incremental routing
The Strangler Fig pattern puts a reverse proxy in front of both applications: migrated endpoints go to the new one, everything else still reaches the legacy one. YARP and System.Web Adapters provide one Microsoft-supported implementation path.

Both applications stay live behind one entry point; the route table decides which answers.
- Put the proxy in front of the legacy application, routing everything to it, then confirm URLs, redirects, headers, status codes, cookies, authentication, and error handling behave as they did without it (Tales from the .NET Migration Trenches).
- Add structured logging and correlation IDs through the proxy.
- Catalogue every active route, mark the dead ones, give each live route an owner.
- Pick one low-risk route, keep its external contract unchanged, and send it test or shadow traffic, with the legacy route still in place for rollback.
Routing rules for every migrated endpoint:
- preserve the route templates external clients, bookmarks, and search engines already use;
- move the IIS rewrite rules that are still required into ASP.NET Core rewrite middleware;
- port route-specific model binders, filters, and serializers before the endpoints that depend on them;
- compare parsed parameter values, not only response payloads, and put culture-sensitive dates and composite identifiers in the regression tests.
Logic that no route points at, such as a rating engine, moves under branch by abstraction instead: define the contract, pin the legacy implementation with tests, build the modern one beside it, compare outputs, then move callers over in batches before deleting the old code.
Feature policy during the migration
New features land in the modern implementation. The freeze covers the module being migrated, not the whole product, and nothing gets built twice except for compatibility or a critical release, with every exception written down.
Outputs: an approved pattern, a written feature policy, a route inventory with owners, and the proxy running as a pass-through.
Done when: the proxy has carried production traffic, route ownership is documented, and the first route has a tested rollback.
Step 5. Select and migrate the first module
Goal: get one module through the whole sequence, so the team's real pace on this system becomes measurable.
Score each candidate from 1 to 5, where 5 is the better first candidate:
| Criterion | Score 1–5 |
|---|---|
| Low dependency and integration complexity | |
| Few side effects | |
| Stable external contract | |
| Cheap rollback | |
| Representative of the modules that follow | |
| Clear business value | |
| Existing test coverage | |
| Low data-migration effort | |
| Easy to monitor in production |
An isolated API endpoint or a single reporting screen scores well here. Defer highly coupled core modules until the migration workflow, telemetry, and rollback procedure have been validated on lower-risk ones.
Migrate in vertical slices
A module moves as one working feature, not as a layer of the whole system. Record its dependency chain before anything is ported:
Endpoint or screen → business services → data access → external integrations → queues, cache, files, and background jobs
The slice then moves dependency-first, and each item covers only what this feature needs:
- Record the current behavior: the external contract, the dependent modules and integrations, the database reads and writes, plus the characterization tests that pin all of it.
- Prepare the packages and shared libraries this slice needs, updating, replacing, or isolating each behind an interface. Packages the rest of the solution uses are left for their own slice.
- Move the configuration it reads: connection strings, external URLs, queue names, timeouts, certificates, feature flags, and secrets, all validated at startup.
- Set the cross-cutting behavior before the first endpoint arrives: model binding, JSON serialization, error responses, CORS, caching, localization, and culture and time-zone handling. The serializer and the error contract stay compatible with the legacy response.
- Port the data access and the integrations this feature touches: its queries, stored procedures, and transaction boundaries, then the external APIs and the broker it calls. The broker itself stays where it is while the message contracts hold.
- Move the business logic for this use case: services, calculations, validation, mapping, and authorization rules, extracted out of the controller where a test or an independent migration needs it.
- Port the endpoint or screen with its route template, filters, binders, validation, response mapping, status codes, and headers.
- Move the background work the feature depends on to a Worker Service, an IHostedService, or an existing consumer, rather than hosting it inside the web application.
- Add the telemetry and run the tests: structured logs and metrics on the new path, then unit, integration, and contract tests, with old and new outputs compared.
- Send test or shadow traffic and rehearse the rollout: load-test the module if it sits on a hot path, execute the rollback once, then enable limited traffic and increase it in steps.
Keep unrelated architectural changes outside the scope of the vertical slice. CQRS, new messaging patterns, and a redesigned domain model are separate decisions with a separate budget.
Architecture rules that apply during a migration
- keep the runtime change and the ORM change in separate phases;
- keep the existing serializer and external contracts until every consumer has moved;
- introduce an abstraction only where old and new implementations coexist;
- ship background jobs as their own deployable units.
Validate dependency-injection lifetimes during the port, especially singleton dependencies that capture scoped services. Scope each DbContext to a unit of work, and run concurrency tests on migrated request paths.
Outputs: a scored module list, one module running on the modern stack behind a traffic flag, with its tests and telemetry.
Done when: the module serves traffic from the modern stack, its tests run in CI, and the legacy path is still available. The same sequence then runs again for the next slice.
Worked example: moving one API endpoint
This example takes the system from Step 1 and shows the application components required to migrate one endpoint: Global.asax, App_Start, web.config, and packages.config on the startup side, then AutoMapper, FluentValidation, RabbitMQ, and GET /api/orders/{id} on the feature side. The target is that endpoint with its dependencies, not the application.
Global.asax, App_Start, web.config, packages.config → Program.cs, appsettings.json, DI, PackageReference → data access → integrations → business service → API endpoint
1. Stand up the new project
A separate project joins the solution next to the legacy one: LegacyApp.Web, LegacyApp.ModernApi, LegacyApp.Shared. LegacyApp.ModernApi targets net10.0, starts in Program.cs, and reads appsettings.json per environment, with logging, health checks, and exception handling in from the first commit.
2. Move the startup configuration
| Legacy | Modern .NET |
|---|---|
| Global.asax / Application_Start() | Program.cs |
| App_Start/RouteConfig.cs | app.MapControllerRoute(...) |
| App_Start/WebApiConfig.cs | builder.Services.AddControllers() plus app.MapControllers() |
| App_Start/FilterConfig.cs, IHttpModule, IHttpHandler | MVC filters or middleware |
| Autofac or Ninject configuration | built-in DI, or that container's current integration package |
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHealthChecks();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
UseExceptionHandler with no arguments needs an error-handling path, a handler delegate, or a registered IProblemDetailsService, which AddProblemDetails() supplies. Authentication and authorization middleware joins once the scheme chosen in Step 3 is registered, not before.
3. Move web.config
| web.config | Modern .NET |
|---|---|
| <appSettings> | appsettings.json with IOptions<T> |
| <connectionStrings> | ConnectionStrings in appsettings.json |
| environment transforms | appsettings.{Environment}.json |
| API keys and passwords | environment variables or a secret store |
| <system.web> authentication | AddAuthentication() in Program.cs |
{
"ConnectionStrings": { "MainDatabase": "Server=...;Database=..." },
"RabbitMq": { "Host": "localhost", "Queue": "orders" },
"CarrierApi": { "BaseUrl": "https://api.example.com", "TimeoutSeconds": 30 }
}
4. Check the NuGet packages
Each entry in packages.config gets the same treatment: confirm the current version supports the target framework, read its breaking changes, and add it as a PackageReference, or replace the package where no modern version exists.
| Legacy package | Action |
|---|---|
| AutoMapper | Update, move mappings into a Profile, register with AddAutoMapper(...), and check the licence terms |
| FluentValidation | Register validators through DI; new code calls IValidator<T> explicitly |
| Newtonsoft.Json | Keep for contract compatibility, or switch to System.Text.Json as a separate change |
| System.Web.* | Replace with an ASP.NET Core API, or bridge temporarily with System.Web Adapters |
| Microsoft.AspNet.WebApi.* | Replace with ASP.NET Core controllers |
| RabbitMQ.Client | Update the client, then verify both the API and the message contracts |
AutoMapper moves out of Global.asax: Mapper.Initialize(cfg => cfg.AddProfile<OrderProfile>()) becomes builder.Services.AddAutoMapper(cfg => { }, typeof(OrderProfile).Assembly), with the profiles and custom converters. FluentValidation registers through AddValidatorsFromAssemblyContaining<GetOrderValidator>().
5. Choose the ORM path
| Current ORM | Practical path |
|---|---|
| EF6 | Update to EF 6.3 or later, since earlier versions do not run on modern .NET, and keep it through the runtime migration |
| EF6 to EF Core | A separate phase: new DbContext, this feature's mappings and queries only, SQL and results compared |
| Raw ADO.NET | Keep it; update connection handling and async calls |
| Stored procedures | Keep them, and move only the calling code and the result mapping |
For GET /api/orders/{id}, that scope covers the Order entity with the navigation properties the response needs, the query and any stored procedure it calls, and lazy loading, which differs between the two.
6. Move the integrations the feature needs
The outbound API call becomes a typed HttpClient registered with AddHttpClient, its URL and timeout in configuration, and its authentication, retries, and error mapping checked against the legacy behavior.
RabbitMQ stays where it is: update RabbitMQ.Client or MassTransit, move exchange, queue, and routing key into configuration, and leave the message contract unchanged through the first cutover.
7. Move the service and the endpoint
Request → FluentValidation → IOrderService.GetAsync(id) → data access and integrations → AutoMapper → OrderResponse
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderResponse>> Get(
int id,
IOrderService service,
CancellationToken cancellationToken)
{
var order = await service.GetAsync(id, cancellationToken);
return order is null
? NotFound()
: Ok(order);
}
}
The slice is now code-complete. The endpoint answers on the modern stack behind a traffic flag, and the legacy route is still live. Step 6 compares the two. Step 7 moves the traffic.
Step 6. Validate side by side
Goal: prove the modern path behaves like the legacy one before production traffic depends on it.
| Area | Compare |
|---|---|
| Behavior | outputs, null handling, rounding, sorting, dates and time zones, localization, generated files, reports, authorization decisions |
| HTTP contract | routes, status codes, headers, content types, JSON field names and casing, null values, date and number formats, redirects, error payloads |
| Data | query results and their order, transactions, concurrency, lazy loading, stored-procedure parameters, multiple result sets, side effects |
| Operations | latency, throughput, error rate, CPU, memory, database load, outbound API calls, background-job runs |
The error payload needs attention beyond a field-by-field diff. In ASP.NET Core, [ApiController] turns client error status codes into ProblemDetails, and AddProblemDetails() extends that to unhandled exceptions. A legacy client parsing the old error shape breaks on both.
The tooling depends on the interface: OpenAPI diff for REST APIs, consumer-driven contract tests for known clients, WSDL comparison for SOAP endpoints, and distributed tracing across the proxy and both applications.
Shadow traffic answers whether behavior and contracts match; load testing (k6, JMeter, or NBomber) answers whether capacity and performance thresholds hold. Shadow traffic needs a safety list:
- side effects removed or isolated, outbound email and messaging disabled;
- writes sent to an isolated database copy;
- duplicate downstream calls prevented;
- sensitive fields redacted, and the compared fields defined exactly.
Outputs: a comparison report per module, a documented list of accepted differences, and load-test results.
Done when: critical workflows return equivalent results, contracts stay compatible, performance thresholds are met, and every remaining difference is approved.
Step 7. Cut over and roll back
Goal: shift production traffic in controlled increments, with a tested way back at each one.
Entry criteria
- the regression suite is green and the contract comparison has stayed clean across an agreed window of real traffic, not a single run;
- error rate, latency, and throughput sit inside the agreed thresholds, and the load test passed;
- rollback has been executed on staging, and support and operations know the procedure;
- external side effects are idempotent or otherwise controlled.
Rollout
Internal users → limited production traffic → monitor technical and business metrics → increase traffic in predefined steps → full traffic → observation window → approve legacy removal
A threshold violation at any stage stops the rollout and sends traffic back to legacy. That decision is made against the numbers agreed beforehand, not renegotiated during the incident.
Rollback prerequisites
- the transitional schema stays compatible with both versions;
- repeated requests do not duplicate external side effects;
- session and authentication keep working after rerouting;
- rollback ownership and decision thresholds are documented.
Dual writes are needed only where the old and new implementations use different stores or schemas. Each one adds a synchronization path that has to be monitored and reversible, so it is a decision to justify rather than a default.
Outputs: a rollout plan with named thresholds, a rehearsed rollback, and a cutover record per module.
Done when: full production traffic runs on the modern route, no material behavior differences remain, and the observation window has closed with legacy removal approved.
Step 8. Remove legacy dependencies
Goal: remove the coexistence layer after no active consumer depends on it.
- Code and routing: System.Web Adapters, compatibility wrappers, retired routes, obsolete feature flags, and unused net48 targets.
- Data and integration: dual-write logic, synchronization jobs, and deprecated schema fields.
- Operations: temporary authentication and configuration bridges, legacy deployment steps, old credentials, and obsolete monitoring and alerts.
Outputs: a solution with no compatibility layer, documentation that matches what runs, and a retired rollback path.
Done when: no active consumer depends on the legacy path, monitoring covers the modern implementation, and rollback-only infrastructure is formally retired.
Technical blockers
Each row is its own budget line in the Step 2 estimate. A blocker discovered after that stops the module it affects until it is resolved.
| Risk | How to detect | Required action | Validation |
|---|---|---|---|
| BinaryFormatter data | Search code, databases, session stores, caches | Convert stored data on the legacy runtime; the built-in implementation throws from .NET 9 | Read historical and converted data |
| WebForms and ViewState | Inventory pages, controls, state dependencies | Rebuild the UI with explicit state handling | Workflow regression tests |
| Third-party UI and reporting controls | Vendor and version inventory | Upgrade, replace, or rebuild; re-check licences | UI and report comparison |
| WCF services | Endpoint, binding, and consumer inventory | CoreWCF for supported bindings, or keep the host on .NET Framework; verify authentication compatibility before choosing gRPC | Contract tests per binding |
| EF6 to EF Core | Query and mapping inventory | Separate phase; check provider support first | Query-result comparison |
| Newtonsoft.Json to System.Text.Json | Contract comparison | Keep Newtonsoft while the contracts depend on it | JSON regression tests |
| Microsoft.Data.SqlClient encryption | Connection test per environment | Install trusted certificates; TrustServerCertificate is a temporary, risk-accepted workaround with an owner and a removal date | Environment validation |
| Windows-only APIs | Platform compatibility scan | Keep Windows hosting, or replace the dependency | Tests on the target host |
| Culture, ICU, and time zones | Locale and cross-platform test cases | Normalize time-zone identifiers and culture config | Cross-environment comparison |
| Blocking I/O on request paths | Review synchronous calls in controllers and services | Replace blocking waits such as .Result and .Wait(), converting from the data layer up | Validate throughput and thread-pool behavior under load |
Less common blockers get their own line the same way: ASMX services re-exposed as REST or CoreWCF with a versioned contract, .NET Remoting replaced by an explicit gRPC or REST contract, MSMQ kept or migrated as a separate messaging phase, MSDTC and TransactionScope checked against the target hosting environment first and moved to local transactions with an outbox only where distributed transactions are unavailable or unwanted there, and SignalR 2.x hubs moved with their clients as one unit.
How TwinCore helps modernize legacy .NET applications
TwinCore's engineers modernize legacy .NET applications on the Microsoft stack, which the company has worked on since 2011, with 30+ specialists and 100+ delivered projects. The Legacy .NET Modernization to a Custom Warehouse Management System project followed the path described above: a legacy .NET warehouse application rebuilt on Angular and modern .NET, keeping the data and the core business logic, with cycle counting running without stopping warehouse operations. The domain expertise behind it sits in logistics and warehouse software.
Two ways to get engineers on it: .NET migration services when the migration runs as a project, or senior .NET developers when they join an existing team. For the criteria to evaluate either, see the guide to hiring a .NET developer; for the engineering standards that apply outside a migration, .NET development practices; and for how the vendors in this niche compare, the review of legacy-modernization companies.
Conclusion
Assess → prepare coexistence → migrate one module → compare behavior → cut over gradually → remove compatibility code
Legacy application modernization with .NET holds up on two things: the order of those stages, and a rollback that stays available until the next one is proven. Keep runtime, ORM, UI, and architecture changes in separate phases unless a dependency makes that impossible.
Have a legacy .NET Framework application that needs a modernization plan? Talk to TwinCore.

LinkedIn
Twitter
Facebook
Youtube
