Skip to content

theekavi/dotnet-modernization

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Legacy .NET → Modern .NET 8 Modernization

A side-by-side case study showing how to take a typical "enterprise" .NET Framework 4.x API and modernize it to .NET 8 — with measurable improvements in performance, testability, and maintainability.

.NET C# Tests License


The Problem

Every .NET shop has at least one of these in production:

  • ASP.NET Web API on .NET Framework 4.x (sometimes 4.5, sometimes 4.8)
  • Synchronous database access blocking ASP.NET threads
  • Static service classes ("just call OrderService.Process()")
  • No DI container, or one wired up by hand
  • Zero unit tests because nothing is testable
  • Slow, brittle, scary to change

Rewriting from scratch is expensive and risky. Modernizing incrementally is the realistic path — but requires knowing exactly what to change and why.

This repo demonstrates that path.

What's Inside

.
├── legacy/                    # Realistic .NET Framework 4.x style
│   ├── Controllers/           # Web API 2 controllers, fat
│   ├── Services/              # Static, sync, no interfaces
│   ├── Repository/            # ADO.NET, hand-rolled mapping
│   └── Models/                # Anemic data classes
│
├── modern/src/OrderApi/       # .NET 8 reference implementation
│   ├── Endpoints/             # Minimal API endpoints
│   ├── Services/              # Async, DI, interface-based
│   ├── Repositories/          # EF Core or Dapper
│   ├── Validators/            # FluentValidation
│   └── Middleware/            # Cross-cutting concerns
│
├── modern/src/OrderApi.Tests/ # xUnit + Moq + Testcontainers
│
├── benchmarks/                # BenchmarkDotNet results
└── docs/
    └── REFACTOR-LOG.md        # Step-by-step migration playbook

The Side-by-Side Comparison

1. Controller / Endpoint

❌ Legacy

[RoutePrefix("api/orders")]
public class OrdersController : ApiController
{
    [HttpPost, Route("")]
    public IHttpActionResult Create(OrderDto dto)
    {
        // Inline validation, business logic, and DB access — all in the controller.
        if (string.IsNullOrEmpty(dto.CustomerEmail))
            return BadRequest("Email required");

        var conn = new SqlConnection(ConfigurationManager.ConnectionStrings["Db"].ConnectionString);
        conn.Open();
        // ...50 lines of SQL and mapping...

        EmailService.SendConfirmation(dto.CustomerEmail);  // synchronous, blocks thread
        return Ok(new { id = orderId });
    }
}

Problems:

  • Validation, business logic, persistence, and side effects all mixed
  • Synchronous I/O blocks ASP.NET worker threads (scaling cliff)
  • Static EmailService impossible to mock → impossible to test
  • ConfigurationManager tied to web.config → can't reuse outside ASP.NET

✅ Modern

// Endpoints/OrderEndpoints.cs
public static class OrderEndpoints
{
    public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");

        group.MapPost("/", CreateOrder)
             .WithName("CreateOrder")
             .Produces<CreateOrderResponse>(201)
             .ProducesValidationProblem();

        return app;
    }

    private static async Task<IResult> CreateOrder(
        CreateOrderRequest request,
        IValidator<CreateOrderRequest> validator,
        IOrderService orderService,
        CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
            return Results.ValidationProblem(validation.ToDictionary());

        var order = await orderService.CreateAsync(request, ct);
        return Results.Created($"/api/orders/{order.Id}", order);
    }
}

Wins:

  • Each concern has its own object (validator, service, repository)
  • Async all the way down — no thread blocking
  • Everything injected → everything testable
  • Auto-generated OpenAPI from response types
  • Cancellation tokens propagate properly

2. Service Layer

❌ Legacy: Static God-Class

public static class OrderService
{
    public static int CreateOrder(OrderDto dto)
    {
        // Connects directly to DB, sends emails, writes to log files,
        // calls 3rd-party APIs synchronously. Untestable.
        var orderId = OrderRepository.Insert(dto);
        EmailService.SendConfirmation(dto.CustomerEmail);
        Logger.Log("Order created: " + orderId);
        return orderId;
    }
}

✅ Modern: DI + Async + Single Responsibility

public sealed class OrderService(
    IOrderRepository orders,
    IEmailNotifier emails,
    ILogger<OrderService> log) : IOrderService
{
    public async Task<OrderDto> CreateAsync(CreateOrderRequest req, CancellationToken ct)
    {
        var order = Order.Create(req.CustomerEmail, req.Items);
        await orders.AddAsync(order, ct);
        await emails.SendOrderConfirmationAsync(order, ct);

        log.LogInformation("Order {OrderId} created for {Email}", order.Id, order.CustomerEmail);
        return OrderDto.From(order);
    }
}

3. Database Access

❌ Legacy: ADO.NET, Synchronous, Manual Mapping

public static int Insert(OrderDto dto)
{
    using (var conn = new SqlConnection(ConnString))
    {
        conn.Open();
        var cmd = new SqlCommand("INSERT INTO Orders ... SELECT SCOPE_IDENTITY()", conn);
        cmd.Parameters.AddWithValue("@email", dto.CustomerEmail);
        // ...10 more parameters, easy to typo, no compile-time safety...
        return Convert.ToInt32(cmd.ExecuteScalar());
    }
}

✅ Modern: EF Core, Async, Type-Safe

public sealed class OrderRepository(AppDbContext db) : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct) =>
        await db.Orders
                .Include(o => o.Items)
                .FirstOrDefaultAsync(o => o.Id == id, ct);

    public Task AddAsync(Order order, CancellationToken ct)
    {
        db.Orders.Add(order);
        return db.SaveChangesAsync(ct);
    }
}

Measurable Improvements

Run the included benchmarks:

cd benchmarks && dotnet run -c Release

Typical results (your hardware will vary):

Metric Legacy Modern Improvement
Throughput (req/sec) ~420 ~3,150 7.5x
P99 latency 380 ms 45 ms 8.4x faster
Memory per request 2.1 KB 380 B 5.5x less
Lines of code 1,847 1,120 39% less
Test coverage 0% 87%

(Read benchmarks/RESULTS.md for methodology.)

Migration Playbook

The docs/REFACTOR-LOG.md walks through the actual order I'd recommend modernizing in production — without breaking anything along the way:

  1. Bridge to .NET Standard 2.0 so libraries can be shared
  2. Extract interfaces from static services (still synchronous)
  3. Introduce a DI container without ASP.NET integration yet
  4. Add unit tests for the now-injectable services
  5. Async-ify I/O paths bottom-up
  6. Slice off endpoints to a new ASP.NET Core project (strangler fig)
  7. Migrate data access to EF Core / Dapper
  8. Cut over route by route until legacy app is empty
  9. Decommission legacy

Running

Legacy (read-only — for reference)

The legacy project intentionally cannot be run here (it targets .NET Framework which isn't a Linux/cross-platform target). It's included as source code reference showing realistic 4.x patterns.

Modern

cd modern/src/OrderApi
dotnet restore
dotnet run
# → http://localhost:5000/swagger

Tests

cd modern/src/OrderApi.Tests
dotnet test

What This Demonstrates

  • Migration strategy — not just "what's better" but how to get there safely
  • Modern .NET idioms — minimal APIs, primary constructors, records, DI
  • Testing discipline — unit + integration with Testcontainers
  • Performance awareness — benchmarks, async correctness, allocations
  • Pragmatism — knowing when to use EF Core vs Dapper, when to fight the framework

The patterns here mirror real .NET modernization projects — done at a pace that doesn't break production.

License

MIT — fork it, learn from it, ship with it.

Author

Thee Kavi — Backend engineer, Bangkok 🇹🇭 theekavi.dev · github.com/theekavi

About

Side-by-side case study: legacy .NET Framework 4.x → modern .NET 8. Shows incremental migration patterns — DI, async, EF Core, minimal APIs, FluentValidation, xUnit. Includes 6-phase refactor playbook for production migrations.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors