The core of Medius ran as a tightly-coupled .NET Framework monolith on an Azure hosting model heading for end-of-life, and it needed to move to a modern .NET runtime. My part was building the foundation the migration team ran on — the mechanism that made per-controller migration reversible, the proof of concept that showed the approach actually worked, and as many controllers and shared dependencies as I could migrate myself once that foundation existed. Batch-migrating the rest of the 355 controllers was the team executing against guidelines I'd helped establish.

Why not just rewrite it

The system processes live invoices and procurement data for real companies, which took a big-bang rewrite off the table immediately — too much downtime risk, too much surface area to get wrong all at once. The alternative was slower but safer: migrate one controller at a time, in production, with a real way back if something broke.

Making migration reversible

The first thing that had to exist was a way to redirect traffic for a single endpoint from the legacy application to the new one, without touching the frontend at all. That lived behind a config-driven switch — flip a controller's entry on, and its traffic started flowing to the new runtime; flip it off, and traffic reverted to the old one. In production, that rollback took about five minutes. That five-minute number mattered more than almost anything else in the project: it's the difference between a migration a team can safely batch-execute over months and one that requires holding your breath on every merge.

Proving it on the smallest slice first

Before touching anything complex, the first controllers migrated were the simplest ones — low-stakes endpoints picked specifically to expose whatever the tooling got wrong while the blast radius was still small.

The first real obstacle was a piece of legacy behavior the frontend quietly depended on: a type-identification quirk baked into how the old serializer shaped its responses, which broke on nested data when reimplemented naively in the new stack. The fix was to stop resolving that behavior per-request and instead precompute it once at startup — a small architectural change that turned an intermittent bug into a solved problem.

One controller was deliberately rewritten from scratch rather than lifted, specifically to force the underlying ORM migration into the open before the rest of the team hit it at scale — the old and new data-access layers modeled queries, transactions, and entity relationships differently enough that this couldn't be a mechanical port. That controller became the reference the rest of the migration worked from.

A separate, heavily-used localization service turned out to be more entangled with the legacy core than expected, and the obvious shortcut — sharing the logic between old and new — was a dead end because it reached too deep into legacy internals. It got rebuilt independently in the new stack instead, while keeping its underlying data in sync with the legacy system rather than duplicating it outright.

What the rest of the team migrated against

The guideline that shaped the batch migration: preserve structure when porting code over, for readability; every migrated endpoint gets a non-regression test that diffs its old and new behavior; nothing starts redirecting until its release has actually reached production; and no new end-to-end tests are needed, since several thousand existing ones already cover most of the surface area — a clean pipeline run is signal enough.

The one gap automated tests didn't close well was confirming that endpoints which write data were doing so correctly, since some legacy persistence behavior didn't carry over cleanly. That got handled the blunt way: logging the actual database operations from both the old and new code paths and diffing them directly for anything that mutates data.

Tooling that had to exist before this could scale

A few supporting pieces sit underneath the migration rather than inside it: an automated pipeline that kept dependencies from drifting silently out of date; a mid-project upgrade of the new stack to a newer long-term-support .NET runtime for performance and language improvements, which came with exactly one meaningful breaking change to absorb; automated architecture checks wired into the build so the new codebase's intended structure couldn't quietly erode while dozens of controllers were mid-flight; and a small dashboard tracking migrated-versus-remaining controllers, so progress was visible instead of tribal knowledge.

What shipped

By the end, the team had a proven, reversible path off the monolith: per-endpoint traffic control with a five-minute rollback, non-regression testing backed by thousands of existing end-to-end tests, direct verification for data-mutating endpoints, and architecture rules enforced automatically at build time. Those were the stepping stones the rest of the team used to carry all 355 controllers across — not something I finished alone, but something I made finishable.