A three‑part series · Part 1
The Constraint That Shaped Everything
Modernizing a legacy API without breaking a single client.
Most "we rewrote our legacy system" stories start with a clean slate. This one didn't. We were asked to modernize a first‑party API that had been running on ASP.NET WebForms (.NET Framework 4.7.2) for well over a decade. It backed a consumer mobile app with a large installed base — and that single fact rewrote the rules for the entire project.
Because here's the thing about mobile clients: you don't control when they update. A meaningful share of real traffic comes from app versions released years ago, sitting on phones that will never see another update. Every one of those versions has the old API's URLs, request shapes, and exact JSON responses compiled into it. If the modernized API changed anything those clients could observe, we wouldn't be shipping a modernization — we'd be shipping an outage.
So the goal wasn't "rewrite the API." It was something much more disciplined:
Replace the entire implementation. Change nothing a client can see.
That constraint — modernize everything on the inside, keep the outside byte‑for‑byte identical — shaped every architectural decision that followed.
What "identical" actually means
It's easy to say "keep it backwards‑compatible." It's much harder in practice, because the surface a client depends on is larger and stranger than you'd expect. For us, parity meant preserving all of the following, exactly:
- The URLs. The legacy API exposed classic
.aspxendpoints. Those paths were baked into shipped clients, so the new API had to keep answering the same literal paths — even though nothing about the new stack uses WebForms anymore. In ASP.NET Core that's just an attribute:
[Route("Login.aspx")] public sealed class LoginController : ControllerBase { /* ... */ }
- The HTTP contract. Same methods, same query‑string parameters, same status codes.
- The response bytes. Same JSON field names, same casing, same property order, same content types — down to details no reasonable person would design on purpose.
That last point is where a faithful migration gets humbling. Legacy systems accumulate quirks, and clients quietly depend on them. A few real examples from our surface (generalized):
- Parameter casing that varied by endpoint. The same logical value arrived as
UserGuidon one endpoint andUserGUIDon another, because different pages had been written by different people over the years. - Request/response field asymmetry. One endpoint accepted a write parameter spelled
PlayBackPosition(capital B) but returned the same value in a field spelledPlaybackPosition(no capital B). Both spellings had to survive. - A client‑side typo we had to preserve behavior around. The app guarded on the presence of a misspelled field before reading the correctly‑spelled one. The legacy server had never sent the misspelled key, so that guard always fell through to a fallback path — and that fallback was the behavior in the field. The bug was load‑bearing.
The mindset shift is the whole game: in a parity migration, the legacy behavior is the specification — warts included. Your job isn't to build the API you wish existed. It's to reproduce the one that does, exactly, and only then improve what's safe to improve.
Why Clean Architecture — and why it wasn't overkill
Given that the observable contract had to be frozen, why not just port the old code as‑is and move on? Because the inside was exactly what we were there to fix: business logic tangled into page code‑behind, data access threaded throughout, little that could be tested in isolation. And we had to do the migration incrementally — one endpoint at a time.
Concretely, a single endpoint became a small vertical slice across the three layers:
// Core — the contract, no infrastructure knowledge public interface IUserAccountRepository { Guid? CheckLogin(string emailAddress, string password); } // Web — a thin controller: request in, Core call, serialize out [Route("Login.aspx")] public sealed class LoginController(IUserAccountService users) : ControllerBase { [HttpGet, HttpPost] public IActionResult Index([FromQuery] LoginRequest request) => LegacyJson(new { UserGuid = users.CheckLogin(request) }); }
This gave us isolation per endpoint, real testability behind interfaces, and a forcing function for discipline — odd legacy behavior lived in one obvious place, with a comment explaining why, instead of smeared across a page's lifecycle. Clean Architecture can absolutely be overkill for a small service. It wasn't here.
The most pragmatic decision: keep the database
The single choice that de‑risked the project more than any other was refusing to rewrite the data layer. The legacy system did its data access through a large set of stored procedures — years of battle‑tested SQL encoding real business rules. Rewriting those into a new ORM model would have been a second, enormous migration riding on top of the first.
So we didn't. We adopted a modern data‑access stack (EF Core) but pointed it at the existing stored procedures, calling them as‑is:
// EF Core, but executing the legacy stored procedure verbatim // — no schema changes, no rewritten SQL var row = _db.LoginResults .FromSqlRaw("EXEC CheckLogin @EmailAddress, @Password", new SqlParameter("@EmailAddress", email), new SqlParameter("@Password", password)) .AsNoTracking().AsEnumerable().FirstOrDefault();
The database stayed the source of truth for business logic; the new API just called into it through clean repository interfaces. The theme worth internalizing for any parity‑driven migration: modernize the layers that were actually the problem, and leave the ones that already work alone.
Where this leaves us
By the end of the groundwork, the shape of the project was clear: a frozen external contract treated as the specification, a clean layered internal architecture that made incremental migration feasible, a deliberate decision to preserve the existing database, and a per‑endpoint workflow — document the legacy behavior, reproduce it exactly, verify it against reality, move on.
That foundation is what made everything else possible — including the parts nobody plans for. Because when you rebuild a fifteen‑year‑old system faithfully enough, you don't just reproduce its behavior. You start to find out things about it: latent bugs it had been hiding, assumptions that no longer held, and at least one failure that only appeared once the code left the environment it had always run in.
Let's Talk About Your Project
A quick 30‑minute call is all it takes to find out if we're a good fit for each other. Book a time and we'll take it from there.