Legacy Modernization and Architecture Evolution Questions
Evolving an existing system rather than designing greenfield. Covers the modernization patterns (strangler fig, anti-corruption layers, facades and protocol adapters), choosing between rehosting, replatforming, incremental refactoring and a full rewrite, data migration and coexistence (dual-running, change-data-capture versus bulk cutover, reconciliation and drift), cutover readiness and decommissioning, recovering undocumented behavior from legacy code and stored procedures, instrumenting a migration so you can tell in real time whether it is working, and the organizational and risk management of long migrations across many teams. The scope is the migration itself: not quantifying or prioritizing technical debt, not code-level refactoring craft, not how to decompose a system into microservices, and not cloud migration or deployment and rollback mechanics as topics in their own right.
You inherit a legacy system running on infrastructure (a mainframe, an unsupported platform, or similar) that the business cannot tolerate downtime or data loss from, with real compliance exposure if you get the migration wrong. How do you approach modernizing it?
Sample Answer
Direct answer
When the legacy system runs somewhere modern tooling barely reaches (a mainframe, an unsupported OS, proprietary hardware) and the business genuinely cannot absorb downtime or data loss, the approach is staged and conservative by design: rehost or wrap first to buy breathing room without touching the risky internals, then incrementally refactor or replace behind that wrapper, with retirement criteria and compliance sign-off treated as first-class deliverables, not an afterthought once the technical work is done.
Structured elaboration
A workable staged plan looks like:
- Rehost or API-wrap before you refactor. Get the system onto supportable infrastructure, or put a modern API in front of it, without changing its internal logic. This buys time and reduces operational risk (older hardware failing, staff who understand the system leaving) without touching the highest-risk code.
- Cost and risk analysis before committing to a target architecture. For a system with real compliance exposure, the honest options are usually rehost, wrap-and-strangle (wrap the legacy system behind a new API now, then incrementally re-implement and cut pieces over behind that wrapper later), or replace with a vendor product, and the choice should be driven by where the audit and revenue risk actually sits, not by which option is technically most interesting.
- Refactor incrementally behind the wrapper, treating each extracted piece with the same rigor as the strangler-fig capability extraction described above (the same wrap-and-strangle approach, now executed piece by piece): parallel-run it against the legacy path, verify identical output, then cut over.
- Define retirement criteria up front. For a compliance-sensitive system this usually means: a defined retention period for historical data in a queryable archive, a compliance sign-off checklist, and proof that every downstream consumer has migrated off the legacy interface, not just that the new system works.
- Build governance into the plan, not around it: who approves each cutover stage, what the audit trail requirements are for the migration itself (not just the resulting system), and how revenue-affecting changes get an extra review gate.
If the system is used globally, add explicit handling for data sovereignty (which regions' data can legally move where), multi-region active-active requirements if the system serves users with low-latency expectations across regions, and a per-region cutover sequence with its own compliance validation, since a single global cutover date is rarely realistic once regional regulatory approval is in the critical path.
On rehost-first ("forklift") specifically: virtualizing or rehosting the legacy application before refactoring reduces operational risk quickly, but it does not reduce the technical or compliance risk buried in the application logic itself, so it should be paired with parallel testing and a prioritized backlog of what still needs refactoring, not treated as the finish line.
Worked example
A team modernizing a mainframe billing system doing monthly batch settlements, with zero tolerance for revenue leakage:
- Phase 1 (rehost): they move the mainframe workload to an emulated environment on modern infrastructure, changing nothing about the COBOL logic, primarily to stop depending on aging hardware nobody can source parts for anymore.
- Phase 2 (API wrap): they build a service that exposes the settlement process through a modern API, so new reporting and reconciliation tools can integrate without touching the COBOL directly.
- Phase 3 (incremental refactor): they identify the least revenue-sensitive settlement rule (a fee calculation used by a small customer segment) and reimplement just that rule in a new service, running it in parallel against the legacy output for two full billing cycles before trusting it, because "the numbers matched in staging" is not sufficient evidence for a system with zero tolerance for revenue leakage.
- Retirement criteria: the legacy COBOL code for a given rule is only removed once its replacement has matched legacy output for a full compliance-relevant reporting period, and the audit function has signed off that the new system's controls satisfy the same requirements the old one did.
Trade-offs and pitfalls
The trade-off is speed versus provable correctness: the staged, parallel-run-everything approach is slow, but for a system where a mistake means real revenue leakage or a compliance failure, that slowness is the entire point, not an inefficiency to optimize away. The most damaging pitfall is treating the rehost step as the modernization itself and declaring victory once the hardware problem is solved, leaving the actual compliance and technical debt in the COBOL logic completely untouched.
Two services need to share a database during a migration, one on the old schema, one moving toward a new one. How would you evolve the schema underneath them without either side going down?
Sample Answer
Direct answer
The expand-contract pattern is the core technique: add the new schema shape alongside the old one (expand), migrate readers and writers to the new shape while both are supported, then remove the old shape once nothing depends on it (contract). It works whether you own the schema evolution end to end within one team, or need to coordinate it safely across many teams evolving their own consumers independently, though the coordination mechanism differs.
Structured elaboration
Expand-contract, concretely:
- Expand: add the new column, table, or message field without removing or repurposing the old one. Both old and new consumers can keep working, because nothing they currently depend on has changed.
- Dual-write or backfill: new writes populate both the old and new shape (or a migration backfills historical data into the new shape), so the new shape is fully populated and trustworthy.
- Migrate readers: consumers switch to reading the new shape, one at a time, verifying correctness as they go, the same incremental discipline as any other migration.
- Contract: once every consumer has moved off the old shape (verified, not assumed), remove it.
Views and materialized views can serve as a compatibility shim during this process: a view that presents the new underlying schema in the old shape lets legacy consumers keep working unmodified while new consumers read the underlying table directly, buying time without dual-writing application code.
Coordinating this across many teams, rather than within one team's own schema, needs additional machinery: automated compatibility checks (a CI check that rejects a schema change that would break a declared consumer's contract), a registry of who consumes what, and defined migration windows so teams know when it's safe to assume everyone has moved. Without this, expand-contract in a single team's codebase can rely on that team simply knowing its own consumers, but across an organization, the same pattern only works if consumer dependencies are visible and enforced, not just assumed.
Rollback at each stage: because expand-contract never removes the old shape until the contract step, rollback during expand or migrate is simple, revert to reading the old shape, since it's still there and still populated. The contract step is the one point of no return, which is exactly why it should only happen after verified, not assumed, migration of every consumer.
Worked example
A team evolving a shared orders table's status field from a legacy integer code to a new string enum, consumed by six other teams' services:
- Expand: they add a new
status_v2string column, populated by a database trigger that keeps it in sync with the legacy integer column for every write, so nothing existing breaks. - Registry and compatibility check: they register
orders.statusandorders.status_v2as tracked contracts, and CI is configured to fail any change that would removestatuswhile a consumer is still declared as depending on it. - Migrate readers: each of the six consuming teams migrates to
status_v2on their own schedule, updating the shared registry as they go. Automated checks confirm each team's contract test suite passes against the new field before they're marked migrated. - Contract: once all six teams show as migrated in the registry, and a final automated check confirms no queries against the old column in the last 30 days of logs, the team removes the legacy
statuscolumn and its sync trigger.
Because the trigger kept both columns in sync throughout, no team experienced downtime or a hard cutover moment; each one migrated when ready, within the compatibility window the expand-contract pattern provided.
Trade-offs and pitfalls
The main pitfall is skipping the contract step, once the new shape works and consumers have mostly moved, there's little pressure to finish removing the old one, and it lingers indefinitely as unnecessary complexity (a sync trigger nobody remembers the purpose of, a column two people are still secretly reading). The coordination mechanism (a registry plus automated checks) is what turns "mostly migrated" into "verifiably migrated," which is the only safe basis for the contract step across a system with many independent consumers.
You're migrating a revenue-critical system (billing or similar) where a mistake means real money lost, not just an outage. How would you run the two systems in parallel and prove the new one is right before you let it take over?
Sample Answer
Direct answer
Migrating something revenue-critical safely means never trusting the new system with real money until it has proven, through a real parallel run against production traffic, that it produces the same result as the system you already trust. The plan runs both systems on the same inputs, reconciles their outputs continuously, and only lets the new system's result become authoritative once reconciliation has been clean for long enough, with a clear process for handling the disputes that will inevitably surface along the way.
Structured elaboration
- Parallel-run architecture: both the legacy and new systems process the same real transactions, but only the legacy system's result is authoritative and drives real customer-facing outcomes (a bill sent, a payment charged). The new system runs in shadow, computing its own result for comparison without it affecting anything real.
- Reconciliation, continuous and automated: compare legacy and new outputs for every transaction (or a defined complete subset, if volume makes 100% comparison impractical), flagging any discrepancy immediately rather than in a batch review days later. For a system with legal and financial constraints, "no revenue loss" needs to be a measurable property of this reconciliation, not an assumption.
- Dispute handling: some discrepancies will be genuine bugs in the new system; others might reveal that the legacy system itself has a known quirk the new system correctly doesn't replicate, forcing an actual decision about which behavior is "correct" going forward, one that may need a business, not just an engineering, answer. This needs an explicit triage process, not an ad hoc one, given how consequential misclassifying a discrepancy could be.
- Cutover gating criteria: define in advance what "clean enough" means, zero discrepancies for a minimum period, or a discrepancy rate below an agreed threshold with each one explained, and don't let schedule pressure quietly lower that bar as a deadline approaches.
- Rollback triggers: even after cutover, keep the ability to fall back to the legacy system's authority if a problem surfaces that parallel-run testing didn't catch, since parallel-run reduces this risk but cannot eliminate it entirely.
Feature toggles, canary rollout by transaction type or customer segment, and full data-migration discipline (dual-writes with reconciliation) are the same underlying tools applied here with financial stakes raising the bar for how rigorously they're executed and how long the parallel-run period needs to run before anyone trusts the result.
Worked example
Migrating a billing monolith with near-zero downtime tolerance:
- Risk assessment identifies the highest-risk modules (anything touching invoice calculation and payment triggering) versus lower-risk ones (invoice formatting, notification text), and the migration sequences lower-risk modules first, following strangler-pattern discipline, to build confidence in the approach before touching the highest-stakes logic.
- Parallel run: for each module as it's extracted, the new implementation computes its result for every real transaction, and it's compared against the legacy result, with any mismatch logged with full transaction context for investigation, before that module's output is trusted for any real customer.
- Feature toggles and canarying: once a module's parallel-run reconciliation has been clean for an agreed period (say, two full billing cycles for a monthly-billing system, since that's the natural unit that would surface a periodic bug a shorter window would miss), the toggle shifts a small percentage of real transactions to the new module's output, ramping gradually while reconciliation continues in the background even for the traffic still on the legacy path.
- Metrics that determine readiness to cut over each module: zero unexplained discrepancies over the full billing cycle, explained discrepancies resolved with an explicit decision on which behavior is correct, and no open incidents tied to the module in the preceding cutover window.
- Rollback and DR: even post-cutover, the legacy calculation path remains callable for a defined grace period, so if a rare edge case surfaces after full cutover, the team can fall back rather than having destroyed the only system that could confirm the right answer.
Trade-offs and pitfalls
The trade-off is time against certainty, at the scale a revenue-critical migration demands, a full billing cycle or more per module for parallel-run confidence is a real cost, but it's small compared to the cost of a billing error affecting real customers and real revenue. The pitfall that shows up under schedule pressure is quietly shortening the reconciliation window or lowering the "clean enough" bar as a deadline approaches, exactly the discipline the plan is designed to protect, and exactly the discipline that erodes fastest when a deadline is looming.
Strangler fig, anti-corruption layer, facade, and a full rewrite all show up in conversations about modernizing a legacy system, and interviewers often use them loosely. How do you decide which one actually fits a given situation, and what makes you abandon the incremental approach partway through?
Sample Answer
Direct answer
These are four different tools for four different situations, and interviewers who use them interchangeably are usually testing whether you actually know the difference. A strangler fig is for gradually replacing a legacy system's functionality behind a routing seam while it stays live. An anti-corruption layer is for protecting a new system's domain model when it has to talk to a legacy system it is not replacing (or not replacing yet). A facade is for simplifying and unifying how callers interact with a messy legacy system, without migrating anything or translating between two different domain models at all. A full rewrite is for when the legacy system cannot be safely peeled apart at all. The decision comes down to whether the system can be decomposed into independently extractable pieces, and whether it needs to keep running the whole time. A fourth axis matters just as much: whether you are actually trying to replace anything at all, or you just want a safer, simpler interface onto a legacy system you have no plan to migrate away from, which is what a facade alone is for.
Structured elaboration
A useful way to separate them:
- Strangler fig answers "how do I replace this system's functionality over time without a big cutover." It assumes the system can be broken into pieces that can move independently, and it is a migration strategy, not a permanent architecture. Use it when the legacy system is large but decomposable, and downtime is not acceptable.
- Anti-corruption layer answers "how do I integrate with this system without its bad decisions becoming my bad decisions." It does not assume you are replacing anything; you might be integrating with a legacy system permanently (a partner's system you do not control) or temporarily (as one piece of a larger strangler effort, where the ACL sits between the parts still on legacy and the parts already moved). Use it any time a system you do not fully trust the shape of has to feed a system whose domain model you want to keep clean.
- Facade answers "how do I make a messy legacy system safer and simpler to call, without replacing or translating anything." Unlike an anti-corruption layer, it does not have to reconcile two different domain models, it is a single unified interface placed in front of a system you are not migrating away from, at least not yet, so callers stop depending directly on the legacy system's tangled internals. Use it when the goal is purely to make what already exists safer to call, or as the seam a later strangler-fig effort will route traffic through once you do decide to replace what is behind it.
- Full rewrite answers "the incremental approach is not viable here." Use it when the legacy system's capabilities are so tightly coupled that there is no seam to strangle along, when the code is small enough that a rewrite is genuinely cheaper than untangling it, or when the business can tolerate a real code freeze while the rewrite happens. It is also sometimes the right call for build-versus-buy reasons that have nothing to do with technical coupling: if a vendor product now does what the core subsystem does, replacing rather than incrementally modernizing can be the faster and cheaper path, provided the migration and switching costs are honestly priced in.
In practice they combine rather than compete: a strangler-fig migration typically starts by placing a facade in front of the legacy system to create a single seam, then uses an anti-corruption layer at that seam to protect the already-migrated parts from the still-legacy parts as pieces move across it.
Worked example
A team replacing a core subsystem (say, pricing) weighs the options (a facade alone is ruled out early, since the goal is to actually move pricing off the legacy system, not just make it safer to call):
- Strangler: pricing logic touches a dozen call sites across the codebase, but each call site is independently identifiable, so they can move pricing behind a facade and migrate call sites one at a time. This is the default choice given decomposability.
- ACL alone (no strangler): they decide pricing itself will stay on the legacy system for now, but a new checkout service needs pricing data. Rather than have checkout speak the legacy pricing format, they add an ACL so checkout's domain model stays clean, with no plan yet to replace pricing itself.
- Full rewrite / buy: they discover pricing logic is deeply entangled with tax and discount logic in ways that resist any clean extraction, and a commercial pricing engine now covers the requirements. They rewrite (replace) rather than strangle, accepting a scoped migration project with a defined cutover instead of an open-ended incremental one.
What would make the team abort a strangler approach midway and fall back to one of the other two: discovering that the "independent" call sites actually share hidden mutable state that makes partial migration unsafe, or finding the timeline slipping so far that the cost of running two systems is exceeding the cost a rewrite would have been from the start.
Trade-offs and pitfalls
The trap is picking strangler fig by default because it feels lower-risk, without checking that the system is actually decomposable; forcing a strangler approach onto tightly coupled logic produces years of a half-migrated system with all the maintenance cost of two systems and none of the safety benefit, because the seam itself becomes unreliable. The opposite trap is reaching for a full rewrite out of frustration with legacy code, when a narrower ACL would have solved the actual integration problem at a fraction of the cost and risk.
What is the anti-corruption layer pattern, and what job is it actually doing when you put one between a legacy system and a new one? Walk through a concrete example of translating legacy data into a new service's model.
Sample Answer
Direct answer
An anti-corruption layer (ACL) is a translation boundary you deliberately put between a legacy system and a new one so that the new system's domain model never has to bend to accommodate the legacy system's quirks. It is not just an adapter that converts data formats; its job is to protect the new model's integrity by absorbing all the legacy system's inconsistencies, missing fields, and outdated assumptions on the legacy side of the boundary, so nothing about the old system's design leaks into the new one.
Structured elaboration
Concretely, an ACL is responsible for:
- Translation: converting the legacy system's data shapes, field names, and units into the new system's domain model, not the other way around.
- Mapping semantic gaps: the legacy system might represent a concept the new system does not have an exact equivalent for (a status enum with legacy-only values, a field that means two different things depending on another field). The ACL is where you decide how those map, once, in one place, instead of every consumer inventing its own interpretation.
- Isolation: consumers on the new side never call the legacy system directly or see its raw shapes. If the legacy system changes (or if you eventually replace it), only the ACL has to change.
The benefit is that your new services get to have a clean domain model that reflects how the business actually works today, not how a fifteen-year-old system happened to represent it. The cost is that the ACL itself becomes a piece of infrastructure someone has to own, test, and keep in sync as both sides evolve, and a badly maintained ACL can become exactly the kind of tangled legacy code it was meant to prevent.
Worked example
Say a legacy order system represents order status as an integer code (0, 1, 2, 9) where 9 means "cancelled" but also gets reused for "refunded" depending on an unrelated flag elsewhere in the record, a real and common kind of legacy inconsistency. A new order service wants a clean OrderStatus enum: PENDING, CONFIRMED, SHIPPED, CANCELLED, REFUNDED.
The ACL sits at the boundary and does the translation:
def translate_legacy_status(legacy_code: int, refund_flag: bool) -> str:
if legacy_code == 9 and refund_flag:
return "REFUNDED"
mapping = {0: "PENDING", 1: "CONFIRMED", 2: "SHIPPED", 9: "CANCELLED"}
return mapping[legacy_code]
Every consumer on the new side calls the ACL and gets back a clean OrderStatus, never the raw integer code or the refund flag. If the legacy system later adds a sixth status code, only this one function needs to change.
To validate this kind of adapter, the concrete test strategy is a contract test against a fixture of known legacy inputs paired with their expected new-model outputs, covering every legacy value (including the ambiguous ones like the reused code 9) and every combination the ACL has to disambiguate, not just the happy path. That test suite is what tells you the ACL is behaving correctly before any real traffic depends on it, and it is what catches the translation silently breaking if someone touches the mapping later.
Trade-offs and pitfalls
The main pitfall is letting the ACL grow into a second copy of legacy logic instead of a thin translation boundary: if it starts encoding business rules of its own rather than just mapping shapes, you have created a new piece of legacy code, not protected against the old one. The other common mistake is skipping the ACL for "just this one caller" because it seems faster, which reliably ends with the legacy system's quirks leaking into the new domain model through that one exception, and everyone downstream having to account for it.
Unlock Full Question Bank
Get access to all 36 Legacy Modernization and Architecture Evolution interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.