Guide to modernizing enterprise applications in .NET

📅 04 May 2026 ⏱️ 5 min 🇪🇸 Spanish Version 💬 0

Moving from a legacy .NET Framework (or even 4.8) to modern .NET sounds, in theory, like an obvious decision. However, in practice, when you have a large enterprise product, with applications running on IIS, Windows App Services, a robust API, a React front end, an internal CRM-style app, and a Windows Service handling background work, it stops looking like a simple "upgrade". The work becomes far more complicated than expected.

This is a topic I mention a lot in my videos, since many of you are stuck and unable to make the leap. What should worry most of you is not "being unable to use containers or cloud functions"; the real problem you are facing is knowing that every change to the system keeps getting harder to make.

Since I have experienced this firsthand, and more than once, let’s talk about it and figure out how to solve it.

 

 

1 - You do not win here with one big heroic migration

 

The worst idea for a system of this scale is usually the most tempting one: opening a new branch that tries to solve everything at once, attempting to migrate 100% to .NET 8 or .NET 10, fixing thousands of compilation errors, and praying that everything still works in the end. Spoiler: that almost never goes well.

The problem is not only technical, it is also about visibility. This change will take months, and during that time, neither the client nor the business team sees usable progress; they only see cost, risk, and accumulating work.

This means that, instead of doing a Big Bang, what we need is to make small migrations. Short steps that improve the system little by little. And even if the final product still relies on .NET Framework for a while, every step we take will slightly improve the ecosystem. That gives us those quick wins that are vital for keeping morale up and, why not say it, justifying the work.

 

2 - Duplication is not always bad: Strangler Fig to the rescue

 

When we have a huge system, with thousands or millions of lines of code, the model that works best is not to "rewrite", but to duplicate the old system little by little. In other words: create a new application in modern .NET that coexists with the existing one. This new app will start receiving traffic and gradually absorbing routes, modules, or capabilities, while whatever has not yet been migrated continues to be served from .NET Framework.

The idea, known as the Strangler Fig pattern, is simple: you do not replace the old system all at once. You put a new layer in front of it, usually in modern .NET, and move pieces over one by one.

For a while, your architecture will look something like this:

Usuario / Frontend
        |
        v
Fachada moderna (.NET 8/10)
        |
        +--> Nueva funcionalidad en .NET moderno
        |
        +--> Sistema legacy en .NET Framework 4.8

 

From the outside, the product is still a single product. On the inside, you are changing the engine mid-flight. This way, you do not need to decide in a single afternoon how to modernize the entire domain, the public API, or asynchronous processes. You only need to decide which part can live outside the legacy application first without causing a mess.

What is interesting here is that this model forces you to identify the real boundaries of your system, and that alone is already a major win. Very often, the problem is not that the code is "old", but that business logic, data access, and presentation are too tightly mixed together.

Careful, there is a tradeoff here: you will have to maintain both the legacy code and the new code for as long as the transition lasts. On the other hand, it greatly reduces risk, since you can control with a simple feature flag whether traffic goes through the new path or the old one.

 

3 - Start with what moves easily, not with what you most want to migrate

 

I know the immediate frustration is usually "I cannot use containers", "this takes forever to compile", or "I cannot put this in a Lambda". Those complaints are real, we have all had them, but to reach that point of total modernity, you need to get your house in order first. We can start by migrating the most obvious parts: the typical background task or some process that runs asynchronously every so often.

These processes have a simple "architecture": they receive the work, process it, complete or fail, maybe with some retries, but they usually do not go much beyond that.

For example, this could be a process in a classic Windows Service:

public partial class InvoiceWorkerService : ServiceBase
{
    private Timer _timer;

    protected override void OnStart(string[] args)
    {
        _timer = new Timer(ProcessPendingInvoices, null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
    }

    protected override void OnStop()
    {
        _timer?.Dispose();
    }

    private void ProcessPendingInvoices(object state)
    {
        var processor = new InvoiceProcessor();
        processor.Process();
    }
}

 

And from there, we can move it easily to a BackgroundService in modern .NET:

public sealed class InvoiceBackgroundWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<InvoiceBackgroundWorker> _logger;

    public InvoiceBackgroundWorker(
        IServiceScopeFactory scopeFactory,
        ILogger<InvoiceBackgroundWorker> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                await using var scope = _scopeFactory.CreateAsyncScope();
                var processor = scope.ServiceProvider.GetRequiredService<InvoiceProcessor>();

                await processor.ProcessAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, "Error processing pending invoices.");
            }
        }
    }
}

 

The business behavior is the same, but now it is running in an updated and decoupled environment. Achieving these kinds of small wins is crucial in a large-scale migration. Saying in a meeting that "everything will be fine when we finish" is not enough; we need to demonstrate empirically, step by step, that what we are saying is true.

 

4 - Plan to meet objectives

 

I have worked on migrations that dragged on forever, and on others that took the right amount of time. The ones that worked well had one thing in common: good planning. As I have already said, if you try to modernize everything at once, you are done for. You have to go little by little, identifying clear objectives. Some logical steps are usually:

  • Check which parts can be packaged in the new SDK-style project format (.csproj).
  • Review the build steps to make sure there are no strange hacks, and if there are, understand why and fix them.
  • Update third-party libraries that do not work outside Windows. This is the most obvious part if your goal is to end up running in Linux containers, which cuts costs dramatically.
  • Remove or replace dependencies that are no longer supported. It is critical to always have stable and secure versions; if a library is deprecated, this is the perfect moment to retire it.

If you look closely, completing just one of these points can take weeks or months, depending on how tightly coupled the system is. So in reality you do not have one migration in mind, but several sub-migrations. There is no need to implement a textbook hexagonal architecture across the entire solution from day 1; it is enough to stop spreading code tied to obsolete dependencies all over the app, or at least hide them behind an interface.

 

5 - Migrating is not the problem. Dragging it out is.

 

At this point, the question of whether it is worth leaving .NET 4.8 behind is almost unnecessary. When your technology stack starts limiting how you deploy, where you can host, or what operational options you have, the answer is clear.

The real question is: How do you do this without turning it into an endless project that systematically competes with, and loses to, the product’s new features?

The key to selling this to your managers is this: reduce the impact, achieve small wins, do not mix too many unknowns at once, and make sure that in every iteration we come out a little less tied down than before. In my experience, as soon as a couple of modules are migrated and the benefits, performance, ease of deployment, developer experience, are tangible, there is usually a green light to continue. That said, assume that the full migration will take years and will require the effort of multiple teams.

By the way, the .NET ecosystem offers tools like the .NET Upgrade Assistant that can help automate part of the initial heavy lifting: detecting incompatibilities, converting projects, and giving you a foundation to iterate on. They will not magically solve the architecture for you, but they do save a lot of headaches.

And remember: every year that goes by, every extra feature you add to the legacy system, makes it harder to get out of there. Starting today will always be better than starting tomorrow. Treat it as a long-term product initiative, and you will get there safely.

This post was translated from Spanish. You can see the original one here.
If there is any problem you can add a comment below or contact me in the website's contact form.


💬 Comments