Back to overview
.NET

Modernizing a Legacy .NET Application Without Rewriting Everything

Modernizing a Legacy .NET Application Without Rewriting Everything

Millions of lines of .NET Framework code still run business-critical enterprise systems. Rewriting these systems from scratch introduces high costs and project risks. Incremental modernization allows teams to upgrade their codebases to .NET 10 step-by-step while keeping production systems stable.

Why Move to .NET 10

.NET Framework 4.8 is the final release of the original .NET Framework. While it receives security fixes through Windows OS updates, it receives no new runtime features. .NET 10, the latest Long-Term Support (LTS) version, offers major advantages:

  • Performance: Runtime optimizations, Span<T>, and hardware intrinsics make .NET 10 significantly faster on common workloads.
  • Cloud-Native Deployment: Built-in container support, minimal APIs, native OpenTelemetry integration, and first-class Linux hosting.
  • Long-Term Support: Supported until November 2028, providing a stable foundation for long-lived systems.
  • Modern Language and Tooling: Modern C# features, NativeAOT compilation, source generators, and cross-platform CLI workflows.

Staying on .NET Framework increases maintenance overhead over time. Build times are slower, Linux containers remain unavailable, and recruiting developers for legacy frameworks is increasingly difficult. An incremental migration addresses these challenges without the failure rate of a complete rewrite.

The Strangler Fig Pattern

The Strangler Fig pattern migrates systems incrementally by placing a routing layer in front of the application. Over time, new services replace legacy endpoints until the legacy application can be decommissioned.

The process follows five steps:

  1. Select a specific module or API route in the legacy application.
  2. Build the replacement service in .NET 10.
  3. Route incoming requests for that endpoint to the new service.
  4. Verify stability in production.
  5. Repeat for subsequent endpoints until the legacy application is phased out.

A reverse proxy or API gateway acts as the facade, directing traffic to either the legacy backend or the new .NET 10 service based on URL paths.

Routing with YARP

Microsoft's YARP (Yet Another Reverse Proxy) is designed for this pattern. It runs as a lightweight ASP.NET Core application and forwards requests based on route configuration:

// appsettings.json - YARP Strangler Fig configuration
{
  "ReverseProxy": {
    "Routes": {
      "new-orders-route": {
        "ClusterId": "new-orders-cluster",
        "Match": {
          "Path": "/api/orders/{**catch-all}"
        }
      },
      "legacy-fallback": {
        "ClusterId": "legacy-cluster",
        "Match": {
          "Path": "{**catch-all}"
        },
        "Order": 100
      }
    },
    "Clusters": {
      "new-orders-cluster": {
        "Destinations": {
          "destination1": { "Address": "https://new-orders-service:8080" }
        }
      },
      "legacy-cluster": {
        "Destinations": {
          "destination1": { "Address": "https://legacy-app:80" }
        }
      }
    }
  }
}

Requests matching /api/orders/* route to the .NET 10 microservice, while all other traffic continues to the legacy application. Callers use the same hostname and endpoints without knowing migration is underway.

Extracting Legacy Endpoints

A common starting point is extracting data endpoints from a WCF service or ASP.NET WebForms application into an ASP.NET Core minimal API.

Legacy Implementation

Consider a legacy WCF service querying customer records directly via ADO.NET:

// Legacy WCF Service - CustomerService.svc.cs (.NET Framework)
[ServiceContract]
public class CustomerService
{
    public CustomerDto GetCustomer(int id)
    {
        using var conn = new SqlConnection(ConfigurationManager.ConnectionStrings["Default"].ConnectionString);
        conn.Open();
        var cmd = new SqlCommand("EXEC sp_GetCustomer @Id", conn);
        cmd.Parameters.AddWithValue("@Id", id);
        using var reader = cmd.ExecuteReader();
        
        if (!reader.Read())
            return null;

        return new CustomerDto
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1),
            Email = reader.GetString(2)
        };
    }
}

Modern Minimal API Replacement

The extracted endpoint in ASP.NET Core 10 uses minimal APIs and dependency-injected data sources while keeping the existing database stored procedure unchanged:

// Modern ASP.NET Core 10 - Program.cs
using Microsoft.Data.SqlClient;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddKeyedScoped<SqlConnection>("db", (sp, key) =>
    new SqlConnection(builder.Configuration.GetConnectionString("Default")));

var app = builder.Build();

app.MapGet("/customers/{id:int}", async (int id, IConfiguration config) =>
{
    await using var conn = new SqlConnection(config.GetConnectionString("Default"));
    await conn.OpenAsync();

    await using var cmd = new SqlCommand("EXEC sp_GetCustomer @Id", conn);
    cmd.Parameters.AddWithValue("@Id", id);

    await using var reader = await cmd.ExecuteReaderAsync();

    if (!await reader.ReadAsync())
        return Results.NotFound();

    return Results.Ok(new CustomerDto(
        Id: reader.GetInt32(0),
        Name: reader.GetString(1),
        Email: reader.GetString(2)
    ));
});

app.Run();

Extracting the transport layer first isolates network handling from business logic without forcing immediate database restructuring.

Upgrading Dependencies

Legacy .NET Framework projects often depend on libraries that lack direct .NET 10 support. Upgrades follow a systematic approach.

Step 1: Run .NET Upgrade Assistant

The .NET Upgrade Assistant scans project files and packages to identify API changes, deprecated libraries, and configuration differences:

# Install the Upgrade Assistant
dotnet tool install -g upgrade-assistant

# Analyze a project
upgrade-assistant analyze ./MyLegacyApp/MyLegacyApp.csproj

# Run interactive upgrade
upgrade-assistant upgrade ./MyLegacyApp/MyLegacyApp.csproj

Step 2: Map Common Replacements

The table below summarizes standard migration paths for legacy .NET components:

Legacy Component Modern Alternative Notes
WCF Server ASP.NET Core Minimal APIs / gRPC CoreWCF supports interim migrations
System.Web (HttpContext) Microsoft.AspNetCore.Http Requires updating middleware and filters
Newtonsoft.Json System.Text.Json Faster memory usage; Newtonsoft still runs if needed
Entity Framework 6 EF Core 10 / Dapper Review query evaluation behavior differences
MSMQ Azure Service Bus / RabbitMQ Enables cross-platform message queues
ASP.NET WebForms Blazor / Razor Pages / SPA UI layer requires rebuild; backend logic can be extracted
Global.asax / HttpModules ASP.NET Core Middleware Maps directly to RequestDelegate pipeline

Database Access and Connection Pooling

Database modernization does not require immediate table restructuring. Focusing on connection management and cancellation support provides immediate stability benefits.

Initial Focus Areas

  • Stored Procedures: Keep existing stored procedures during the initial migration to preserve business rules.
  • Connection Configuration: Replace ConfigurationManager.ConnectionStrings in web.config with IConfiguration, environment variables, or Azure Key Vault references.
  • Cancellation Tokens: Pass CancellationToken parameters through asynchronous queries so containers can terminate cleanly during rolling deployments.
// Data access with Dapper and cancellation support
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;

public class CustomerRepository(IConfiguration configuration)
{
    private readonly string _connectionString = configuration.GetConnectionString("Default") 
        ?? throw new InvalidOperationException("Default connection string not found.");

    public async Task<CustomerDto?> GetByIdAsync(int id, CancellationToken ct = default)
    {
        await using var connection = new SqlConnection(_connectionString);
        await connection.OpenAsync(ct);

        return await connection.QuerySingleOrDefaultAsync<CustomerDto>(
            new CommandDefinition(
                "EXEC sp_GetCustomer @Id",
                parameters: new { Id = id },
                cancellationToken: ct
            )
        );
    }
}

Modernizing CI/CD Workflows

Moving from Windows-only TFS or MSBuild scripts to the cross-platform dotnet CLI enables lightweight Linux build agents and faster pipeline execution.

# .github/workflows/build.yml - .NET 10 CI Pipeline
name: Build and Test

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET 10
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Restore dependencies
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release

      - name: Publish
        run: dotnet publish --no-build --configuration Release -o ./publish

      - name: Build Container Image
        run: docker build -t myapp:${{ github.sha }} .

Observability with OpenTelemetry

Legacy file-based logging (such as static log4net log files) does not fit containerized deployments. .NET 10 includes native OpenTelemetry integration for tracing, metrics, and structured log export:

// Program.cs - OpenTelemetry setup in .NET 10
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSqlClientInstrumentation()
        .AddOtlpExporter(otlp => otlp.Endpoint = new Uri("http://otel-collector:4317")))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

builder.Logging.AddOpenTelemetry(logging => logging
    .AddOtlpExporter());

var app = builder.Build();

Exporting traces via OpenTelemetry allows teams to trace requests as they move between the YARP proxy, the new .NET 10 services, and legacy backends.

Deciding What Not to Migrate

A successful modernization plan defines boundaries for what should remain untouched:

  • Infrequently Changed Modules: Stable components with low churn and no active feature requests can remain isolated on legacy runtimes.
  • Tightly Coupled Internal Logic: Code lacking clear boundaries should be refactored or wrapped before extraction.
  • Untested Legacy Code: Write integration and characterization tests before attempting to migrate complex business logic.
  • Low-Impact Utilities: Prioritize high-value API endpoints and active development areas first.

Phased Migration Plan

Phase Typical Scope Key Deliverables Risk Level
1. Assessment 2–4 weeks Upgrade Assistant audit, dependency inventory, test baseline Low
2. Foundation 3–5 weeks YARP reverse proxy, CI/CD pipeline, first .NET 10 service Low
3. Incremental Extraction Sprint-based Extract bounded contexts behind YARP routes Medium (mitigated by feature flags)
4. Data Access & Telemetry Parallel Modernize connection handling and OpenTelemetry tracing Medium
5. Legacy Retirement Final stage Decommission legacy IIS hosts and unused endpoints Low

Working with Neneos

Neneos assists teams with .NET modernization and cloud-native architectures. Our work includes architecture reviews, hands-on migration of legacy systems, and CI/CD platform engineering.

To discuss modernizing your application estate, contact our team or learn more about our working model.

#.NET #.NET Framework #Migration #Architecture #DevOps #Cloud Native