Legacy Modernization: Rewrite, Replatform, or Refactor?

Most legacy systems are not replaced because they stopped working. They are replaced because the people who understood them left, the platform fell out of support, or the business needs something the design cannot accommodate. Those are three different problems, and they point to three different strategies.

Option 1: Refactor In Place

Keep the system running and improve it incrementally — add tests, break apart the worst-coupled modules, replace dead dependencies, expose an API. Nothing dramatic happens on any single day.

Choose this when the business logic is still correct and valuable, the platform is still supported, and the real problem is that the system is hard to change. Avoid it when the underlying runtime is out of support with no upgrade path, or the data model actively prevents what the business now needs.

The honest risk: refactoring can become permanent. Without a defined end state, it turns into indefinite maintenance that consumes budget without ever arriving anywhere.

Option 2: Replatform

Move the system largely as-is onto supported infrastructure — a current runtime, a managed database, containerized hosting — changing as little application logic as possible.

Choose this when the pressure is operational: unsupported operating systems, hardware nobody will service, a hosting arrangement that has become a risk, or a compliance requirement about where data lives. Avoid it when the application logic itself is the problem — replatforming a system nobody can change gives you an unchangeable system on nicer infrastructure.

This is often the correct first move even when a rewrite is the eventual plan, because it buys time and removes the urgency that makes rewrites rushed.

Option 3: Rewrite

Build a replacement, migrate the data, retire the original.

Choose this when the data model cannot support the business as it now works, when the platform is genuinely dead, or when the cost of change has risen so far that small requests take months. Be careful everywhere else. Rewrites fail in a recognizable pattern: the replacement must match years of accumulated behaviour before anyone can switch, the original keeps changing during the build, and the switchover date slips until confidence runs out.

The Strategy That Usually Works: Strangle It

Rather than a single cutover, route one capability at a time to new code while the legacy system continues to serve the rest. A facade in front of both directs traffic; each slice moves independently; each is reversible.

  • Value arrives during the project rather than only at the end.
  • Every step is individually reversible, so risk stays bounded.
  • The legacy system can keep running for as long as it needs to.
  • If priorities change, you stop with real value delivered rather than a half-finished replacement.

It requires discipline — running two systems is genuinely harder than running one, and it is easy to leave the last stubborn twenty percent forever. Name the end state and track progress toward it.

Questions Worth Answering First

  • What breaks the business if this system is unavailable for a day? That sets your risk tolerance.
  • Who understands the business rules encoded in it? If the answer is nobody, discovery is a bigger job than the build.
  • Is the data model the constraint, or just the code? This is the single most important distinction.
  • What is the actual deadline — a support end date, an audit, a contract? Real deadlines change the calculus; imagined ones cause rushed rewrites.

The Short Version

Refactor when the code is the problem. Replatform when the infrastructure is the problem. Rewrite when the data model is the problem — and even then, move capability by capability rather than all at once. A modernization plan that cannot deliver anything for twelve months is a plan with a high chance of being cancelled at month nine.

Related service: Product engineering and legacy modernization

Migrate the Data Early, Even If You Migrate Nothing Else

A trial migration of production data into the target schema, run in the first weeks, tells you more about real scope than reading the code does. It forces every implicit rule in the old schema to become explicit, because the target either accepts a row or refuses it. The useful output is not the count of tables loaded, it is the list of rows that would not load and the reason for each.

Run it repeatedly and script every correction. Manual cleanup does not survive the second run, and the run that counts happens against data that has changed since the first one. What the failure list usually contains:

  • Orphaned references: child rows pointing at parents that no longer exist. They are invisible in a legacy schema with no foreign keys and fail immediately when the target declares them.
  • Double-encoded text: a dump of UTF-8 data labeled latin1, restored into a utf8mb4 database, converts each stored byte to its own character, so one accented character becomes two, the first an A with a tilde.
  • Strictness: MySQL rows holding 0000-00-00 load fine under a permissive sql_mode and are rejected under the default sql_mode, which now includes NO_ZERO_DATE and NO_ZERO_IN_DATE together with strict mode.
  • Money in floats: amounts stored as FLOAT or DOUBLE do not convert to DECIMAL cleanly, and the differences show up at the cent level once rounded, so totals recomputed in the new system will not always match the old ones.
  • Timestamps without a zone: DATETIME stores what it was given, while TIMESTAMP is stored as UTC and converted using the session time_zone. A migration run on a server set to a different zone shifts one of them and not the other, silently.
  • Whitespace as identity: text values used as de facto keys can differ only by trailing spaces. The older utf8mb4_general_ci collation pads and treats them as equal; MySQL 8's default utf8mb4_0900_ai_ci is a NO PAD collation, so the same two values compare as different and both survive a unique index.

The Facade Is a Component You Build and Operate

The facade has to do three things, and only the first is routing. It matches an incoming request to whichever system owns that capability now. It carries identity across, so a user authenticated by one system is recognized by the other without signing in twice. And it keeps the two data stores in agreement for as long as both are live. The third is the one most often left undesigned.

Where the facade sits decides how fine the slices can be. A reverse proxy in front of HTTP endpoints routes on host and path, so the smallest movable unit is a URL. An API gateway can route on method and payload as well. Inside a server-rendered application whose screens share session state, the seam usually has to be a whole page or a whole session, which makes slices larger than anyone wants them.

Sync direction is a per-entity decision, not a global one. One system owns writes for a given entity and the other receives them; two systems both accepting writes for the same record requires conflict resolution, and there is no generic answer to that. Change data capture from the database log, an outbox table drained by a worker, and dual writes at the application layer all work, and they differ mainly in how they fail. CDC lags but does not lose writes. Dual writes fail partially and leave the two stores disagreeing with nothing to replay.

Proving the New Path Behaves the Same

Before a slice takes real traffic, run it in shadow: send the production request to both systems, serve the legacy response to the user, and record the difference. The differences sort into three groups. Formatting that does not matter, ordering and rounding that might, and genuine disagreement about what the answer is. Sorting them is the work, and it is more of the work than building the slice.

Comparison needs tolerances defined before the first run or the diff log becomes noise nobody reads. Timestamps generated at request time will always differ. Collections returned without an explicit ORDER BY differ in order between engines and sometimes between runs on the same engine. Decide what counts as equal, encode that in the comparison, and treat every remaining difference as a defect that has to be explained rather than filtered.

Some of those differences are the legacy system being wrong. Some behavior is wrong and users have built their process around it, so preserving it and fixing it are both defensible. That needs a named person with authority to rule, and the ruling belongs in writing beside the test that asserts it, because the next engineer will otherwise read the assertion as a bug and correct it.

Cross-Cutting Concerns Are Paid Once, Early

Authentication, authorization, audit logging, and session handling cannot be moved one capability at a time, because every slice depends on all four. They are built before the first slice ships and they set the ceiling on what the slices can do.

Authorization is usually the hardest of the four, because legacy permission models tend to be implicit rather than declared: a role check embedded in one screen, a query silently filtered by a column, a menu entry that simply does not render. Reproducing the effective permission set means enumerating who can currently do what, which is a data exercise against production, not a reading exercise against source.

Audit logging carries a second-order consequence that is easy to miss. If the legacy application writes audit rows into a table that compliance or an internal auditor queries directly, the new path has to write into the same place, or the audit trail splits at the migration boundary and every later question requires two queries and a manual merge. Discovering that after several slices have moved means backfilling.

Everything That Reads the Legacy Database Directly

The application is rarely the only thing connected to the legacy database. Reporting tools, spreadsheets with saved ODBC connections, an ETL job feeding a warehouse, and partner integrations often query the schema directly, and they were usually written by people who are not on the project. They break quietly rather than loudly: once a table stops being the system of record for a capability, those queries still succeed and simply return stale rows.

Finding them is a discovery task with concrete methods. Enable the general or slow query log across a full business cycle including month end, list the database accounts and their grants, and sample the server process list at different hours. A report that runs on the first Monday of the month will not appear in a Tuesday sample, which is why the observation window has to cover a cycle rather than a week.

Each consumer found then needs a decision: repoint it at the new system, keep a compatibility view in the legacy schema fed by the sync, or agree a date on which it stops working. A read-only view that preserves the original column names is often the cheapest way to keep a report running while the capability underneath it moves.

Frequently Asked Questions

Is replatforming the same thing as lift and shift?

Not quite. Lift and shift usually means moving the same operating system image onto a different hypervisor or cloud VM, with nothing above the hardware layer changing. Replatforming as described here normally also swaps infrastructure components for managed equivalents, a self-hosted database for a managed one or a hand-built host for a container image, which changes operational behavior even when the application code does not: connection limits, backup and restore mechanics, failover behavior, and how you get a shell to debug something at 2am. The relearning is in operations, not in code.

Can this work when the legacy system has no API and no clear service boundaries?

Usually yes, because the seam does not have to be inside the application. Anything served over HTTP can be fronted by a reverse proxy that routes on path or host, and the legacy code never needs to know it is there. The harder cases are thick desktop clients that connect straight to the database, and server-rendered systems whose state lives in session objects shared across screens; there the seam has to be found at the data layer instead, or the first slice has to be a larger unit than a single screen.

What has to exist before the work can start?

A restorable copy of production data in a non-production environment, an environment where the legacy system can be stood up and broken without affecting users, and access to the build and deployment path for both systems. Named subject matter experts with time actually allocated matter as much as the technical items, because rule discovery moves at the speed of the people who can answer questions about why something works the way it does. If the data copy cannot be produced, that is itself a finding about the state of the backups.

What happens to scheduled jobs and batch processes during the overlap?

Each job needs a single owning system, the same way each capability does, because a nightly job running in both places will double-post. The usual arrangement is to leave a job running in exactly one system and let the other read its results through the sync, rather than reimplementing it early. The jobs that fail first are the ones that assume they are the only writer, or that depend on running after another job has finished, so the ordering and the assumptions should be written down before any of them move.

Is a single cutover ever used?

It is not the route recommended above; moving capability by capability stays the default. A single cutover becomes the only available option when there is genuinely no seam to route on: a closed vendor package with no interception point, or a system whose state sits in one transactional store that cannot be split without rewriting the transactions that span it. It also happens when a fixed external date, a license or hosting contract ending on a known day, removes the possibility of an overlap period. Where it is unavoidable, the effort goes into rehearsal, repeated full migration runs against fresh production copies with a rollback that has been measured and practiced rather than described.

What happens to the old system's data after it is retired?

Decide it before decommissioning, because restoring a retired system later to answer one question is expensive and sometimes not possible once the runtime is out of support. The common arrangement is a read-only archive: a final database copy with the schema documented, or an extract into a queryable format with the field meanings recorded next to it. Retention obligations from contracts or regulation set the minimum period and sometimes the format, so they should be confirmed while the original system is still running and someone can still query it.

What does the customer decide rather than the engineering team?

The order in which capabilities move, because that is a business priority question rather than a technical one. Also which reports and integrations are in scope versus retired, the acceptable downtime for each capability at the moment it switches, and the retention rules for archived data. The engineering side can say what each option costs in effort and risk and which sequences create dependencies, but the ranking itself is not theirs to set.