Migrating from ASP.NET MVC to ASP.NET Core — Lessons Learned

Why Migrate?

After working on several migration projects — taking legacy ASP.NET MVC 5 applications and bringing them to .NET 6 and beyond — I've gathered some practical lessons worth sharing.

The benefits are clear: better performance, cross-platform support, modern tooling, and long-term support. But the migration path isn't always straightforward. Microsoft's official migration guides cover the happy path, but the real challenges show up in the details — the parts specific to your codebase that no guide can anticipate.

If your app is in active development, you need a strategy that lets you migrate incrementally without blocking the rest of the team. If it's in maintenance mode, you can afford a bigger rewrite. Either way, understanding what changes and what stays the same will save you a lot of headaches.

Planning the Migration

Before writing any code, I've learned to start with an audit of the existing application:

  1. Inventory your dependencies. List every NuGet package, third-party library, and external service. Check if .NET Core-compatible versions exist. Some older packages have been abandoned — you'll need alternatives.
  2. Map your authentication flow. This is almost always the most complex part. Document how users log in, what tokens or cookies are issued, and where session state lives.
  3. Identify custom HTTP modules and handlers. These don't exist in ASP.NET Core. Each one needs to become middleware.
  4. Review your Global.asax and web.config. Everything in these files needs a new home — Program.cs, appsettings.json, or middleware registrations.

For one project I worked on — a freight management application — this audit phase took about a week, but it saved us from multiple surprises later in the process.

Key Challenges

1. Authentication and Identity

One of the trickiest parts is migrating authentication. ASP.NET Core Identity works differently from the old membership system. Plan for:

  • Rehashing existing passwords or implementing a compatibility layer
  • Updating cookie authentication configuration
  • Migrating role-based access control (RBAC)

In one migration I worked on, we had to support both the old and new authentication systems during a transition period. The approach was to implement a custom IPasswordHasher<TUser> that could verify passwords hashed with the old algorithm and automatically rehash them with the new one on successful login:

public class MigrationPasswordHasher : IPasswordHasher<ApplicationUser>
{
    private readonly PasswordHasher<ApplicationUser> _newHasher = new();

    public string HashPassword(ApplicationUser user, string password)
    {
        return _newHasher.HashPassword(user, password);
    }

    public PasswordVerificationResult VerifyHashedPassword(
        ApplicationUser user, string hashedPassword, string providedPassword)
    {
        // Try new format first
        var result = _newHasher.VerifyHashedPassword(user, hashedPassword, providedPassword);
        if (result != PasswordVerificationResult.Failed)
            return result;

        // Fall back to legacy verification
        if (VerifyLegacyHash(hashedPassword, providedPassword))
            return PasswordVerificationResult.SuccessRehashNeeded;

        return PasswordVerificationResult.Failed;
    }

    private bool VerifyLegacyHash(string hashedPassword, string providedPassword)
    {
        // Implement your legacy hashing verification here
    }
}

When SuccessRehashNeeded is returned, Identity automatically rehashes the password on the next save, so the migration is transparent to users.

2. Configuration System

The old web.config / ConfigurationManager approach is replaced by the new IConfiguration system. This is actually a big improvement — appsettings.json, environment variables, and user secrets are much more flexible.

// Old way
var connectionString = ConfigurationManager.ConnectionStrings["Default"].ConnectionString;

// New way
var connectionString = builder.Configuration.GetConnectionString("Default");

What's not immediately obvious is how the configuration layering works. ASP.NET Core loads settings from multiple sources in order, with later sources overriding earlier ones:

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. User secrets (in Development)
  4. Environment variables
  5. Command-line arguments

This means you can keep your base config in appsettings.json and override specific values per environment without duplicating the entire file. For sensitive settings like connection strings and API keys, environment variables or Azure Key Vault are the way to go — never commit secrets to source control.

A pattern I find useful is binding configuration sections to strongly-typed classes:

// In appsettings.json
{
    "Sftp": {
        "Host": "sftp.example.com",
        "Port": 22,
        "Username": "user",
        "RemotePath": "/incoming"
    }
}

// Configuration class
public class SftpSettings
{
    public string Host { get; set; }
    public int Port { get; set; } = 22;
    public string Username { get; set; }
    public string RemotePath { get; set; }
}

// Registration
builder.Services.Configure<SftpSettings>(builder.Configuration.GetSection("Sftp"));

// Usage via DI
public class SftpService
{
    private readonly SftpSettings _settings;
    public SftpService(IOptions<SftpSettings> options) => _settings = options.Value;
}

3. Dependency Injection

ASP.NET Core has built-in DI, which is a massive improvement. If your legacy app used a third-party container like Unity, Autofac, or Ninject, you'll need to register all services in Program.cs or use extension methods to keep things organized.

The key difference is that ASP.NET Core DI is baked into the framework. Controllers, Razor Pages, middleware, hosted services — everything resolves through the same container. This makes the code more testable and the dependencies more explicit.

For large applications, I organize service registrations into extension methods:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddDataServices(this IServiceCollection services)
    {
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        services.AddScoped<IOrderService, OrderService>();
        return services;
    }

    public static IServiceCollection AddExternalServices(this IServiceCollection services)
    {
        services.AddHttpClient<ISamsaraClient, SamsaraClient>();
        services.AddScoped<ISftpService, SftpService>();
        return services;
    }
}

// In Program.cs — clean and readable
builder.Services.AddDataServices();
builder.Services.AddExternalServices();

One gotcha: be careful with service lifetimes. Injecting a scoped service (like DbContext) into a singleton will cause issues. The compiler won't catch it, and you'll get stale data or concurrency exceptions at runtime. ASP.NET Core can detect this — enable scope validation in development:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

4. Static Files and Bundling

The wwwroot folder and the new static asset system take some getting used to. BundleConfig is gone — consider using a simple build tool or just serving individual files during development.

In the legacy world, BundleConfig.cs handled CSS/JS bundling and minification. In ASP.NET Core, you have several options:

  • Webpack or Gulp for a full build pipeline with bundling, minification, and preprocessor support
  • LibMan for simple client-side library management
  • ASP.NET Core's built-in static asset handling with fingerprinting via asp-append-version

For the projects I've migrated, I typically set up a Gulp pipeline that processes CSS (with PostCSS or a preprocessor) and bundles JavaScript with webpack. The source files live in an Assets/ folder, and the build output goes to wwwroot/. This keeps the source and output cleanly separated.

5. Replacing Java/Tomcat Services

On one project, we had a separate Java application running on Tomcat that handled background file processing — monitoring an SFTP server and loading freight data into the database. As part of the migration, we replaced this entirely with ASP.NET Core Hosted Services.

This was a significant win: one less technology stack to maintain, one less deployment pipeline, and the background processing could share the same configuration, DI container, and data access layer as the web application. The Hosted Service approach is covered in detail in my background jobs post.

6. Reporting: Crystal Reports to DevExpress

If your legacy app uses Crystal Reports, you'll need to find an alternative — Crystal Reports doesn't support .NET Core. We migrated to DevExpress for PDF report generation. The API is different, but the transition was manageable:

// DevExpress XtraReports
var report = new FreightDocumentReport();
report.Parameters["OrderId"].Value = orderId;
report.CreateDocument();

using var stream = new MemoryStream();
report.ExportToPdf(stream);
return File(stream.ToArray(), "application/pdf", "freight-document.pdf");

The key is to not try to convert Crystal Reports templates directly — redesign the reports in the new tool, using the opportunity to improve the layout and fix long-standing formatting issues.

The Migration Strategy That Worked

After doing this several times, here's the approach I've settled on:

Phase 1: Infrastructure

Set up the new ASP.NET Core project alongside the legacy app. Get the build pipeline, configuration, and DI container working. Don't migrate any features yet — just get a "Hello World" page running with the correct configuration.

Phase 2: Data Layer

Migrate your data access — Entity Framework models, DbContext, repositories. If you're using stored procedures, those don't change. This is also a good time to switch from the old SqlConnection approach to Dapper for performance-critical queries.

Phase 3: Authentication

Get login/logout working. Test with real user accounts. This is the riskiest part, so give it extra attention.

Phase 4: Feature Migration

Migrate features one at a time, starting with the simplest pages. For each page:

  1. Create the Razor Page (or Controller/View if using MVC)
  2. Wire up the data access
  3. Test against the same database as the legacy app
  4. Get sign-off before moving to the next feature

Phase 5: Background Services

Migrate any scheduled tasks, Windows Services, or background processors.

Phase 6: Cutover

Once all features are migrated and tested, switch DNS/load balancer to the new app. Keep the legacy app available for rollback.

Testing Considerations

Functional testing after migration is critical. The application should behave identically to the legacy version — same business logic, same data, same workflows. I maintain a test checklist for each migrated feature:

  • Does the page render correctly?
  • Do all form submissions work?
  • Are validation rules the same?
  • Do file uploads/downloads work?
  • Are permissions enforced correctly?
  • Does the page behave correctly with edge-case data?

Automated tests are ideal, but for a migration, manual testing against the existing app as a reference is often the most practical approach — especially for UI-heavy pages where the rendering might differ slightly.

Conclusion

Migration is worth it, but plan carefully. The applications I've migrated have seen measurable improvements in page load times, developer productivity, and deployment flexibility. The key is to be systematic: audit first, migrate incrementally, and test thoroughly — especially around authentication, data access, and any custom middleware you need to port over.

If you're facing a similar migration, don't be intimidated by the size of the change. Break it into phases, keep the legacy app running in parallel, and validate each step before moving on.

← Back to all posts