Modernising legacy .NET without downtime
Most organisations running an ageing .NET application know they need to move. What stops them is not the technical difficulty of the target platform, which is well documented, but the honest fear that a rewrite will take eighteen months, absorb the whole engineering budget, and produce a system that does slightly less than the one it replaced. That fear is well founded. The way out is to stop treating modernisation as a project with a launch date and treat it as a sequence of small, individually reversible migrations that never require the old system to be switched off.
Why big-bang rewrites go wrong
A full rewrite fails for a reason that has nothing to do with engineering skill. It requires you to reproduce every behaviour of a system nobody fully understands, including the behaviours that are technically bugs but that some department has quietly built a process around. The old system also keeps moving. It has to, because the business it serves keeps changing. So the rewrite is aiming at a target that shifts every month it runs, and the gap between the two versions widens rather than closes. Teams respond by freezing changes to the old system, which means the business stops getting improvements for the duration, which is precisely when someone senior starts asking what the money is buying. And the cutover is a single event with no gradual exposure. Everything you got wrong arrives on the same morning, in production, with users watching. Incremental migration inverts all three problems. You never need complete understanding, only understanding of the piece you are moving this month. The old system keeps evolving, because it is still in charge of everything you have not migrated. And each step is small enough to reverse before lunch.
Start by finding the seams
The first useful piece of work is not code. It is a map of where the system can be cut apart with the least damage. Look for boundaries that already exist. Anywhere the application talks to something over a network, or reads from a queue, or exposes an endpoint, you have a seam that can be intercepted without touching internal logic. Anywhere a module has its own tables and few foreign keys reaching outside them, you have a candidate for extraction. Then look at what changes and what does not. Pull the change history for the past two years and count commits per area. Code that has not been touched in three years is stable and can wait indefinitely, however unpleasant it looks. Code that changes every sprint is where your team is losing time, and migrating it first is what makes the effort pay for itself early. This is the single most useful ordering heuristic available, and it is almost always ignored in favour of migrating whatever is architecturally tidiest. Finally, be honest about what should not be migrated at all. Every legacy system contains features that were built for a client who left, or a process that has since been automated elsewhere. Deleting these is faster than porting them, and it is the cheapest work in the entire programme.
The strangler pattern, concretely
The approach is straightforward. Put a routing layer in front of the existing application, then move functionality behind it one piece at a time while the routing decides which version handles each request. In practice that means introducing a reverse proxy or gateway that initially forwards everything to the old application. Nothing changes for users, which is the point: you have added the mechanism for change without making any. Then, for the first slice, you build the replacement, route a small share of traffic to it, compare the results, and increase the share once you are satisfied. Two details decide whether this works. Route on something meaningful. Path-based routing is simplest and usually sufficient. Routing by user, tenant or region is more work but lets you expose a new implementation to a friendly internal group before anyone external sees it. Keep the ability to route back. The value of this pattern is not that the new code is correct, it is that when the new code is wrong you change one routing rule instead of running a rollback deployment. That property is worth protecting, which means resisting the temptation to remove the old path the moment the new one looks healthy.
Data is the part that actually hurts
Routing requests is a solved problem. Sharing state between an old and a new implementation is where modernisation programmes get stuck, and it deserves more planning than the application code. The simplest arrangement, and the right default, is for both versions to read and write the same database. It offends architectural purity and it works. You avoid synchronisation entirely, and the migration stays about code rather than about data consistency. When a module genuinely needs its own store, move reads before writes. Have the new service read from the existing database while the old system remains the only writer. That gets the new code into production, exercised by real traffic, with no risk of divergent state. Only once it is proven do you move the writes, and at that point the old system reads from the new service rather than the shared table. Avoid two-way synchronisation between databases if there is any alternative. It is the one pattern here that introduces a class of bug — silent divergence under concurrent writes — that is genuinely difficult to detect and worse to repair. If you cannot avoid it, make one side authoritative and treat the other as a cache you can rebuild.
Keeping both versions honest
Running two implementations of the same behaviour means you need a way to know whether they agree. The most effective technique is to run both and compare. Send the request to the old implementation, return its answer to the user, and in the background send the same request to the new one and log any difference. You get a stream of real production comparisons with no user-visible risk, and the differences are usually more interesting than any test suite would have found, because production inputs are stranger than the ones anyone writes tests for. Invest in the observability before the migration, not during it. You want per-route latency and error rates broken down by which implementation served the request, because the question you will be asked repeatedly is whether the new version is better, and you should be able to answer with a chart rather than an opinion. And set a stopping rule at the start. Some part of the old system will always be uneconomic to move, and a modernisation programme with no defined end quietly becomes permanent. Decide in advance what good enough looks like, write it down, and be willing to leave a stable, unloved module exactly where it is.
Key takeaway
Incremental modernisation is slower to describe and faster to finish than a rewrite, because it never requires a moment when everything must work at once. Put a routing layer in front of the system, migrate the code your team touches most often, share the database until you have a strong reason not to, compare old and new against real traffic, and decide up front which parts you will deliberately leave alone. The result is less satisfying to draw on a whiteboard than a clean new architecture, and considerably more likely to be running the business in two years.