The legacy system modernization decision record: why the migration model you chose determines your dual-system maintenance cost and your feature parity scope creep gap
The migration model, the strangler boundary definition, and the feature parity specification are architectural decisions that are almost never made explicitly — they emerge from a big bang rewrite scoped to "match current functionality" without a written feature enumeration, a strangler fig pattern adopted without a completion criterion, and a cutover gate defined as "feature parity" with no feature list. Three failure patterns: the B2B SaaS that estimated 4 months for a billing rewrite and took 16 because undocumented edge cases, an undiscovered API integration, and a mid-rewrite team lead departure each extended the scope; the developer tooling company that ran its Ruby monolith for two additional years because the strangler fig had no boundary definition and no endpoint was ever classified as a migration priority with a date; and the enterprise SaaS whose "feature parity" cutover gate produced an 18-month dual-system overrun while a parity backlog grew faster than it was cleared.
A 36-person B2B SaaS company built expense management and corporate card tools for mid-market companies across financial services and professional services. The engineering team of nineteen had grown from four to nineteen over three years and had shifted its primary language from PHP to Python midway through year two. The billing and invoicing module — responsible for subscription charging, usage-based billing, retroactive adjustments, and the generation of PDF invoices with customer-specific formatting — had been built in PHP during year one and had never been migrated. The module worked correctly, but every change to it required context that the current Python-focused team lacked, and the two engineers with PHP experience were among the team's most senior members and were increasingly allocated to product development work rather than billing maintenance.
In month one of year four, the engineering manager and three senior engineers conducted a four-hour planning session to scope a Python rewrite of the billing module. The scope was recorded as: "Build a Python equivalent of the billing module that matches current functionality and integrates with the existing subscription service." No feature enumeration was produced. The team's estimate was four months for a two-engineer team. The estimate was based on the team's reading of the PHP code — approximately 12,000 lines across fourteen files — and on their general confidence with Python. The session did not include a behavioral audit of production traffic, a review of enterprise customer contracts for billing-specific clauses, or an interview with the customer success team about escalation history. The planning session identified the scope as the PHP codebase; it did not identify the scope as the production behaviors that the PHP codebase had accumulated over three years.
In month two of the rewrite, the engineer working on the retroactive billing adjustment module encountered the first undiscovered scope item: the legacy PHP module contained twenty-three conditional branches handling retroactive adjustments across four currencies, most of which were flagged in the code with comments of the form "// per contract with [CUSTOMER_NAME]." The contracts had been signed in years one and two by the founding CEO before a formal contract management system was adopted; they existed as PDF files in a shared drive that had not been included in the planning session's scope analysis. The engineer who had authored the original billing module had left the company eighteen months earlier. The customer contracts specified billing behaviors — proration models, rounding rules for fractional cents in currency conversion, credit memo formats — that were encoded in the PHP code but not in any product specification. Implementing these behaviors in Python required reading each contract, mapping the contract language to the PHP conditional logic, and verifying that the Python implementation matched the production output for each currency and adjustment type combination. The work added three weeks to the original estimate.
At month four, the new Python billing module handled the main subscription billing flow, the usage-based billing accumulation, and the basic invoice generation correctly. It was not production-ready because the retroactive adjustment module was still being completed and because a discovery in month three had added a new scope item: a curl-based integration that the enterprise integration with the third-largest customer's ERP system was making against a legacy PHP endpoint that was not in the billing module's public API documentation. The endpoint had been added as a one-off three years earlier, documented in a comment in the PHP code but not in the API documentation, and had never been identified as a dependency in the planning session. Implementing the equivalent endpoint in Python required understanding the ERP's data model — a task that took two weeks and required a support call with the customer's technical team. Two additional undocumented ERP integrations from other enterprise customers were identified during this investigation.
At month seven, the engineering team lead who had designed the Python billing module's architecture left the company for a new position. The engineer who took over the work spent three weeks recovering context: reading the implementation, running through the existing test suite, and identifying which PHP behaviors were fully implemented and which were partially implemented or not yet started. The context recovery produced a clarified scope and a new estimate of nine more weeks to completion. At month ten, the module entered internal QA against the PHP production system, with both systems running the same billing operations and outputs being compared. The QA pass identified fourteen behavioral discrepancies — twelve were test-environment configuration differences, two were genuine implementation gaps in the Python module, both in edge cases of the retroactive adjustment logic in the fourth currency type. At month fourteen, the module entered a parallel-run production phase where the Python module processed billing operations but the PHP module's outputs were used for actual invoicing. At month sixteen, the cutover occurred. The PHP billing module was decommissioned four weeks later after confirming no customer complaints.
The rewrite had taken sixteen months instead of four. The dual-system maintenance period — during which the PHP billing module continued receiving bug fixes for the four enterprise customers who remained on it while the Python rewrite was in development — had produced three bug fixes that required parallel implementation in the Python module, one of which was missed in the initial cutover and surfaced as a billing error for one customer in the first operating month. The engineering team's assessment after the cutover was accurate: the big bang rewrite had been worth doing, because the PHP module genuinely had been a velocity constraint. But the scope of the rewrite had been defined as the PHP codebase, not as the production behaviors, and the difference between those two scopes — the customer contracts, the undiscovered API dependencies, the edge cases encoded in conditional branches — had cost twelve additional months.
A 47-person developer tooling company built a platform for CI/CD pipeline analytics — build performance tracking, test flakiness dashboards, deployment frequency metrics, and an anomaly detection service that alerted on degradations in pipeline performance. The platform had been built initially in Ruby using Sinatra as the API framework; by year three, the engineering team had migrated primarily to Go and the Ruby codebase had become a maintenance liability. The platform handled approximately 40,000 API requests per hour across seven major service areas.
In year four, the engineering team adopted the strangler fig pattern to replace the Ruby monolith with Go services. The decision was made and communicated in a single engineering all-hands: "We're going to gradually replace the Ruby monolith with Go services. New features will be built in Go. Existing Ruby endpoints will be migrated to Go when they're next touched for feature work or bug fixes." An NGINX routing layer was added in front of both systems to direct traffic based on the service area. No boundary definition document was produced. No endpoint was classified as a migration priority versus a permanent deferral. No migration completion criterion was specified.
The first year of the strangler fig went well. The three most actively developed service areas — webhook event processing, build status aggregation, and user authentication — were migrated to Go services in the first eight months. All three had been touched multiple times for feature work and bug fixes, which had generated the natural migration trigger the policy specified. By month twelve, the Go services were handling approximately 65% of API traffic. The Ruby monolith handled the remaining 35%: the legacy report generation service, the webhook delivery retry system, the notification preferences module, the billing integration callbacks, and the v1 API compatibility layer that kept five years of legacy API endpoints functional for twelve enterprise customers who had not migrated to the v2 API.
The second year was where the pattern stalled. The remaining Ruby endpoints shared a common characteristic: they worked correctly and required no feature work or bug fixes. The report generation service generated scheduled PDF and CSV reports on a schedule; it had not needed a change in six months. The notification preferences module was stable. The webhook delivery retry system had been tuned to production and had not required modification since a configuration change the prior year. The v1 API compatibility layer existed specifically to avoid requiring enterprise customers to migrate — its stability was the feature. None of these endpoints had a natural migration trigger because their stability was the reason they were last on the migration list.
By month eighteen, the Ruby monolith was serving approximately 28% of API traffic and had effectively been frozen: no Go migration work had happened in the prior four months, and no Ruby maintenance work had happened in the prior three months. The Ruby dependencies had not been updated since month twelve. In month twenty, a CVE was disclosed in a Ruby gem that the monolith depended on — a TLS session handling library used in the webhook delivery retry system. The fix required updating the gem and running the full Ruby test suite, which had not been run in five months. The dependency update broke the v1 API compatibility layer, which was the most complex and least understood part of the monolith; its test coverage was 34% and its behavior in edge cases had not been verified in the prior year. Diagnosing and fixing the breakage took two weeks. The incident produced the first detailed conversation the team had about the v1 API compatibility layer and its migration status.
At month twenty-four, the Ruby monolith was serving 22% of production API traffic. The two years of strangler fig migration had moved traffic from 100% to 78%. The remaining 22% was concentrated in the v1 API compatibility layer (11%), the report generation service (7%), and the notification preferences module and billing callbacks (4%). The engineering team's assessment was that the v1 API compatibility layer would require a coordinated migration effort with twelve enterprise customers — a six-to-nine-month project with customer outreach, migration documentation, and a deprecation window — and that this project had never been planned because the strangler fig policy had not asked whether the v1 API compatibility layer was a migration target or a permanent maintenance commitment. The answer, had the question been asked two years earlier, was that it was a migration target: but a migration target with a specific dependency (enterprise customer migrations) that needed its own project, not a natural migration trigger from feature work. The strangler fig had been the correct choice over a big bang rewrite of the full Ruby monolith, but the pattern without a boundary definition had produced an indefinitely persistent legacy system because the components most resistant to natural migration triggers were the ones that needed explicit migration planning.
A 53-person enterprise SaaS company built a procurement and vendor management platform for companies in regulated industries — pharmaceutical, financial services, and healthcare — handling purchase order approval, vendor risk scoring, contract management, and compliance reporting. The platform had been built as a monolith beginning in year one; by year six, the monolith was 320,000 lines of Python and had a documented tendency to produce cascading failures when a single service area degraded. In year six month three, a memory leak in the purchase order approval service had taken down the entire platform for four hours, affecting eleven enterprise customers during their peak procurement window. The four-hour outage produced a CTO-level decision to modernize the architecture.
The modernization strategy was strangler fig: new Go services would be built to replace the monolith's service areas, with traffic migrated progressively. The cutover condition — the point at which the new architecture would replace the monolith entirely and the monolith could be decommissioned — was specified as "when the new architecture reaches feature parity with the legacy system." The engineering team understood this to mean "when all the features of the legacy system are available in the new architecture." Feature parity was not further defined. No feature enumeration was produced. The phrase was used repeatedly in planning conversations as a shorthand for "done," with the implicit assumption that the team would know feature parity when they saw it.
The migration progressed well in the first six months. The purchase order creation and approval flow — the highest-traffic and highest-criticality service area — was migrated to a Go service first, deliberately, because the memory leak incident had been in this area. The vendor risk scoring service and the invoice reconciliation service followed in months four and five. By month six, the new Go services were handling approximately 44% of monolith API traffic. The engineering team estimated that feature parity was six months away.
In month seven, the product team surfaced the first parity gap: the new architecture did not support the audit trail export format that three enterprise customers used in their compliance reporting. The export format was a CSV with specific column ordering, timestamp formatting, and a digital signature field that had been built as a one-off feature request for a pharmaceutical company in year three. It was not in the new architecture's scope because it was not in any product specification document that the migration planning had referenced. It existed in the Python monolith as a 400-line module called by a single API endpoint that was accessed exclusively by automated processes at the three affected customers. The module was added to the parity backlog.
In month nine, the customer success team flagged a procurement workflow used exclusively by three medical device manufacturer customers that had a regulatory-specific multi-approval sequence — four approval levels with different authorization requirements for FDA-regulated purchases over specific dollar thresholds — that was not in the general purchase order approval service. The feature had been built in year two for the first medical device customer and replicated twice. It was not documented in the product specification. It was in the monolith. It was added to the parity backlog.
The pattern repeated. At month twelve, the parity backlog had forty-three items. The items were not all large — twenty-seven were small (under two days of work), eleven were medium (one to two weeks), and five were large (three or more weeks, including the v1 API compatibility work for two enterprise customers who had never migrated to the v2 API). But each item had been discovered by a different person in a different context: a product manager reviewing a customer renewal, a CS team member investigating a support ticket, an engineer reading the monolith code while fixing a bug. None of the items had been in scope for the original migration planning because they had not appeared in any planning document.
At month fifteen, the team estimated three months to clear the parity backlog. The same estimate had been given at month nine, when the backlog had thirty-seven items. Between months nine and fifteen, twenty-one items had been completed and twenty-seven new items had been added. At month eighteen, the parity backlog had twenty-nine items and the team gave the same three-month estimate. At month twenty-four, the parity backlog had twelve items. The engineering lead held a planning session and produced what the team should have produced in month zero: a complete feature enumeration by behavioral audit — reading every monolith endpoint, reviewing the three years of CS escalation history, and reviewing the ten largest customers' contracts — that classified every monolith feature into migrate, deprecate (remove without replacement), or defer (post-cutover addition). The enumeration took six days and produced a list of 847 features: 798 already migrated, 23 in the parity backlog, 14 to be deprecated (features with zero traffic in the prior 12 months), and 12 to be deferred. The team cut over twelve weeks later, 18 months past the original cutover estimate.
Structural properties set by the legacy modernization decision
Three structural properties are determined when a team decides — or fails to explicitly decide — how to approach legacy system modernization: what the migration model determines about the dual-system maintenance cost as undiscovered legacy behavior, undocumented API contracts, and missing edge cases are found after the new system is believed complete, what the strangler boundary definition determines about migration completion as stable components with no natural migration trigger accumulate into an indefinitely persistent legacy system, and what the feature parity definition determines about the scope and duration of the migration period as undiscovered features are added to a backlog that recedes as fast as the team advances it. None of these properties are labeled as decisions in the conversations that produce them. The migration model emerges from a planning session that scopes the rewrite as the current codebase without asking what behaviors the codebase has accumulated that are not in any specification. The strangler boundary emerges from a policy that says "migrate when touched" without asking which components will never be touched. The feature parity gate emerges from a phrase used in planning conversations that is never converted into a written feature list.
Property 1: The migration model and the dual-system maintenance cost. The migration model is the decision about whether the legacy system will be replaced by a big bang rewrite (all functionality rebuilt before cutover), a strangler fig (functionality migrated component by component, with traffic routing shifted progressively), or a parallel run (both systems run simultaneously, outputs compared before the legacy is decommissioned). The dual-system maintenance cost is the engineering work required to keep both systems operational during the migration period: bug fixes applied to both systems when the legacy still serves production traffic, operational knowledge split between legacy and new system owners, and the cognitive overhead of engineers making changes with awareness of both systems' behavior. The migration model determines the shape of this cost. Big bang rewrites defer the dual-system period — both systems are not simultaneously serving production traffic until the parallel-run phase — but extend the pre-cutover development period whenever undiscovered legacy behavior is found after the new system is believed complete; scope expansion during development has a compounding cost because it extends the dual-system period in both directions (more time before cutover, more time the legacy accumulates divergent bugs). Strangler fig distributes the dual-system cost across the migration period but limits the scope of each migration unit to a bounded component, reducing the blast radius of each unit's cutover. The migration model selection should be based on a characterization of the legacy system's undocumented behavior risk, not on the desire to end the dual-system period on a specific date. For systems under 30,000 lines with clear documentation and under three years of production use, big bang is tractable. For systems with years of production use, customer-specific business logic in code comments, and a behavioral audit surface that exceeds what the planning team can enumerate in a planning session, strangler fig reduces the risk that undiscovered scope will collapse the pre-cutover estimate. Connect this property to the incident command system decision record: the dual-system period introduces a specific incident risk — failures can occur in either system, or in the routing layer between them, and the on-call team must be prepared to diagnose and triage incidents in both; the incident command model for the migration period should specify which system is authoritative for each service area, how the IC determines which system to rollback to, and the escalation path for failures at the routing boundary.
Property 2: The strangler boundary definition and the migration completion criterion. The strangler boundary is the explicit specification of which legacy components will be migrated (in-scope), which will remain in the legacy system permanently (out-of-scope), and what the trigger condition is for initiating migration of each in-scope component. A strangler fig pattern without a defined boundary produces an indefinitely persistent legacy system because components that are functionally stable generate no natural migration trigger — they work correctly, require no feature work or bug fixes, and accumulate no urgency under a "migrate when touched" policy with no date bound. The pattern stalls at the residual layer: the components that are still on the legacy system are exactly the ones that are least likely to be touched, because they are stable. The boundary definition document must answer three questions that "migrate when touched" does not: which components have a defined migration schedule (with a date bound, not just a trigger condition), which components are permanent maintenance commitments (because the migration cost exceeds the value — typically API compatibility layers for long-term customers, legacy report formats for compliance contracts, and features whose full behavioral audit would require extensive customer engagement), and when the legacy system will be decommissioned given those commitments. If the boundary definition produces a set of components that will never be migrated, the legacy system is not a migration candidate — it is a permanently dual architecture — and operational decisions (monitoring, dependency updates, on-call ownership) should treat it as such rather than as a system in the process of being replaced. Connect this property to the API versioning decision record: the components most resistant to migration in a strangler fig are typically API compatibility layers maintaining legacy API versions for enterprise customers who have not migrated; the API versioning decision specifies the deprecation window, the customer migration support process, and the maximum end-of-life date for legacy API versions; the strangler fig boundary definition and the API version sunset policy must be aligned — the v1 API compatibility layer cannot be decommissioned before the last customer using v1 has migrated, and the customer migration timeline determines the migration horizon for that component of the legacy system.
Property 3: The feature parity definition and the scope creep prevention. Feature parity as a cutover gate is operationally undefined unless the parity specification enumerates every feature by name, classifies each into migrate, deprecate, or defer, and freezes the migrate scope against additions after the specification date. Without a written parity specification, the parity gap is discovered incrementally as customers, customer success teams, and product managers encounter missing features that were never in scope for the migration planning — undocumented business logic in customer contracts, one-off features built for regulatory requirements, compatibility behaviors for customers who have not migrated to newer API versions. Each discovery adds to a parity backlog that grows as fast as it is cleared, because the rate of discovery is determined by how frequently customers exercise the unmigrated behavior, not by how quickly the team migrates it. The parity specification procedure that prevents scope creep has three steps: (1) generate the feature enumeration from the legacy system's actual behavior, not from product documentation — read every endpoint, review the CS escalation history for the prior two years, and review the contracts for the top customers by ARR; product documentation reflects what the team intended to build, not what accumulated in production over years of customer requests and one-off fixes; the behavioral audit finds the gap between them; (2) classify each feature into migrate (in scope for the new system, included in the cutover criteria), deprecate (remove without replacement — notify affected customers, set a removal date, and remove from both systems before cutover to reduce the cutover scope), or defer (add after cutover — a named feature with a post-cutover delivery commitment, treated as a backlog item for the new system, not a parity requirement); (3) freeze the migrate scope at the specification date — discoveries after the specification date go through a change control process that evaluates whether the discovery is a cutover-blocking regression (add to migrate scope with an assessed timeline impact) or a post-cutover feature (add to defer scope, not to migrate scope). Connect this property to the health check design decision record: during the parallel run phase of a migration, both the legacy and new systems must have accurate health checks that reflect their actual capability; a health check that returns healthy for the new system when the system's feature coverage excludes a migrate-scope feature that is being exercised in production creates a false availability signal — the system is available but not complete; the parallel run health check specification should include functional coverage assertions alongside the standard operational checks. The WhyChose extractor finds the legacy modernization decisions buried in your AI chat history — the architecture review conversation where the team decided between big bang and strangler fig and the reasoning about risk tolerance that drove the choice, the planning session where the scope was defined as the codebase rather than the production behaviors, and the all-hands where the strangler boundary was communicated as "migrate when touched" without the follow-up question about which endpoints had no natural migration trigger.
The legacy system modernization ADR: five sections
Section 1: Migration model selection and risk characterization. Specify the migration model — big bang rewrite, strangler fig, parallel run, or a combination — with a written rationale that addresses the three risk factors that determine model appropriateness: system size (lines of code and number of distinct service areas), production age (years in production and estimated accumulated edge cases from customer contracts and CS escalation history), and documentation quality (whether a complete behavioral specification exists that the rewrite can be validated against). For each risk factor, document the team's assessment and the rationale for how the assessment influenced the model choice. If big bang rewrite is selected: document the behavioral audit procedure that will be conducted before rewrite scope is finalized, and specify that scope changes discovered during development (not after the behavioral audit) will require a formal scope revision with a revised timeline estimate. If strangler fig is selected: specify the initial traffic routing plan, the routing technology (NGINX, API gateway, load balancer rule), and the mechanism for monitoring traffic distribution between legacy and new systems over the migration period. The risk characterization should address the dual-system maintenance cost explicitly: what maintenance obligations the team accepts for the legacy system during the migration period (dependency updates, security patches, bug fixes), and how those obligations will be resourced alongside new system development. Connect to the CI/CD pipeline security decision record: the migration period requires CI/CD pipelines for both the legacy and new systems; the pipeline configuration should specify whether the legacy system's pipeline runs the full test suite (appropriate if the legacy is still receiving bug fixes) or only a smoke test suite (appropriate if the legacy is frozen and only security patches are applied); the pipeline security configuration should apply to both systems during the dual-system period, not only to the new system.
Section 2: Behavioral audit procedure and scope specification. For any migration (big bang or strangler fig), specify the behavioral audit procedure that will be conducted before scope is finalized, and document the audit results as the migration scope specification. The behavioral audit has four sources: production traffic analysis (every endpoint and parameter combination with non-zero traffic in the prior six months, with traffic volume and unique customer count per endpoint), customer contract review (billing behaviors, data format commitments, API compatibility guarantees, and SLA clauses in the contracts of the five largest customers by ARR and the three longest-tenured customers), CS escalation history (every escalation in the prior 18 months that resulted in a code change to the legacy system — these are the undocumented edge cases), and code audit of customer-specific conditional branches (every conditional in the legacy codebase whose condition includes a customer identifier, a feature flag, or a date threshold). Document the audit findings as a feature enumeration: every behavior, endpoint, and integration identified, with source (traffic audit, contract review, CS history, or code audit) and frequency (daily traffic volume, customer count, or escalation frequency in the prior 12 months). The feature enumeration is the migration scope specification. Any behavior not in the enumeration that is discovered after the scope is frozen must go through the change control process in Section 4 rather than being added to the migration scope informally. Connect to the data residency decision record: the behavioral audit should include a data flow enumeration for the legacy system — which data moves between which components, which data is stored in which database, and whether any data has residency commitments in enterprise customer contracts; the migration's data layer design must preserve residency commitments, and the data flow enumeration is the input to the migration's data architecture specification.
Section 3: Strangler boundary definition and migration schedule. For strangler fig migrations, specify the boundary definition document with three components: the migration inventory (every legacy component classified as in-scope for migration, with the service area, current traffic percentage, and migration trigger), the deferral registry (every legacy component classified as out-of-scope, with a written rationale for each deferral — maintenance cost justification, customer commitment, or permanent architectural decision), and the migration schedule (the trigger condition for each in-scope component, the date bound by which migration will begin regardless of whether the trigger condition has been met, and the estimated completion date). The migration schedule must include a date bound for every in-scope component because "migrate when touched" without a date bound produces the indefinite deferral pattern — components that are stable receive no natural trigger and are perpetually deprioritized. The boundary definition document should specify the total traffic percentage currently on the legacy system, the traffic percentage that will remain on the legacy system after the migration inventory is complete (handled by the deferral registry), and the decommissioning plan for the legacy system given those traffic commitments. If the deferral registry contains components that will never be migrated, the decommissioning plan must address those components separately — either through a customer migration project (for API compatibility layers), a deprecation project (for features with zero or low value traffic), or an explicit architectural decision that the component will be maintained indefinitely in the legacy system as a permanent hosted service. Connect to the API versioning decision record: the deferral registry's API compatibility layer entries require an API versioning sunset policy that is coordinated with the strangler fig migration schedule; the legacy system cannot be decommissioned until the last customer using legacy API versions has migrated, and the customer migration timeline must be factored into the migration schedule's date bounds for the compatibility layer components.
Section 4: Feature parity specification and scope change control. Specify the feature parity definition as a complete feature enumeration from the behavioral audit, with each feature classified as migrate, deprecate, or defer, and a freeze date after which scope changes require formal change control. The parity specification should include three acceptance criteria categories for the cutover: (1) every migrate-scope feature has been implemented in the new system and verified through behavioral equivalence testing (not unit test coverage — the legacy and new systems produce identical outputs for the same inputs across the production traffic replay); (2) every deprecate-scope feature has been removed from both systems with customer notifications sent for affected customers; (3) every defer-scope feature has a post-cutover delivery commitment with a timeline and an owner. The scope change control process applies to any behavior discovered after the specification date that is not in the enumeration: the change is classified by the feature parity specification owner as a cutover-blocking regression (the new system fails to handle behavior that the legacy system handles correctly, and the behavior is in the migrate scope — add to migrate scope with an assessed timeline impact) or a post-cutover discovery (behavior exists in the legacy system but was not in the behavioral audit — add to defer scope with a post-cutover delivery commitment, do not add to migrate scope). The distinction between a cutover-blocking regression and a post-cutover discovery is the key scope change control decision: a regression must be fixed before cutover, a discovery can be addressed after. Without this distinction, every discovery is a blocker, and the parity backlog grows indefinitely. Connect to the incident command system decision record: the parallel run phase is the highest-risk period of the migration — both systems are handling production traffic and divergences in their outputs must be detected and triaged; the incident command model for the parallel run should specify the detection mechanism (output comparison with alerting on divergence), the classification of divergences (regression vs acceptable behavioral difference vs new feature gap), and the rollback authority for each classification.
Section 5: Cutover procedure and legacy decommissioning protocol. Specify the cutover acceptance criteria, the cutover execution procedure, and the legacy decommissioning protocol. The acceptance criteria must cover four categories: functional equivalence (behavioral audit replay confirms identical outputs), API compatibility (all external integrations verified against the new system), operational readiness (minimum observation period in production with no P1 incidents, SLA breaches, or data consistency failures — specify the minimum period as a number, not as "adequate" or "sufficient"), and rollback capability (the rollback procedure from the new system to the legacy system is tested and confirmed to execute within the documented recovery time objective). The cutover execution procedure should specify the traffic migration sequence (if strangler fig — which remaining traffic is moved at cutover and in what order), the monitoring alerting thresholds active during the cutover window, and the decision authority for proceeding versus rolling back at each step of the cutover sequence. The rollback window — the period after cutover during which rollback to the legacy system is possible — should be specified as a number of days, not as "if needed." The legacy decommissioning protocol specifies the actions taken when the rollback window closes: removal of the routing configuration, decommissioning of the legacy infrastructure, archival of the legacy codebase, and notification to any teams or customers who were aware of the legacy system's existence as a fallback. The decommissioning date should be calendared at the cutover decision, not decided after the cutover — delaying the decommissioning decision produces legacy systems that remain provisioned, billed, and monitored indefinitely after their rollback window closes. Connect to the health check design decision record: the legacy system's health checks should be updated to reflect its decommissioning status during the rollback window — the legacy system is healthy if it can serve traffic, but its health check should return a Deprecation header indicating the planned decommissioning date; after the rollback window closes, the health check is updated to return a permanent redirect to the new system before the legacy infrastructure is decommissioned.
FAQ
When is big bang rewrite the right choice over strangler fig for a legacy system?
Big bang rewrite is appropriate when the legacy system is small enough (under 30,000 lines), young enough (under three years of production use), and well-documented enough that the complete feature set can be enumerated before the rewrite begins. All three conditions must hold simultaneously. A system under three years of production use has accumulated fewer undocumented edge cases from customer contracts, support escalations, and one-off fixes. A system under 30,000 lines is small enough that a full behavioral audit — reading every endpoint, reviewing CS escalation history, reading customer contracts for billing and API commitments — is tractable in a week. A well-documented system has a specification that the rewrite can be validated against. When any of these conditions does not hold — a six-year-old billing module with twenty-three customer-specific conditional branches, a Ruby monolith with 140,000 lines accumulated over five years of production use, a platform with enterprise customer contracts containing behavioral commitments that exist nowhere except the contracts — the risk of the big bang approach exceeds the benefit of a clean cutover date. The scope expansion from undiscovered legacy behavior compounds: each discovery extends the pre-cutover period, each extension increases the probability of a mid-rewrite team member departure, and the combination produces the 4-month-to-16-month progression. For systems over 50,000 lines or over three years in production, the behavioral audit alone will take longer than the initial estimate for a small system and will produce a feature enumeration that reveals the migration is larger than the planning session assessed.
How do you discover undocumented legacy behavior before starting a migration?
The behavioral audit for a legacy system migration has four sources that together surface undocumented production behavior that a specification document will not contain: (1) Production traffic analysis — run the API access logs for the prior six months through frequency analysis to identify every endpoint and parameter combination that real customers use; endpoints with zero traffic in six months are migration candidates for deprecation, not migration; endpoints with low traffic but concentrated among high-value customers require individual review. (2) Customer contract review — the contracts with the five largest customers by ARR and the three oldest customers by tenure are the most likely sources of custom behavior built to fulfill contractual commitments; search for SLA clauses, data format specifications, API compatibility commitments, and billing model definitions; these will name features that may not appear in any product documentation because the features were built to satisfy the contract, not to serve the general product. (3) CS escalation history — the customer success team's escalation queue for the prior 18 months contains every edge case that real customers encountered and that required a code change to resolve; many of these edge cases produced a conditional branch in the production code that is now a behavioral dependency for that customer; the CS history is the most complete source of undocumented production behavior because it was generated by real customers encountering real gaps. (4) Code audit of customer-specific conditionals — a static analysis pass identifying every conditional whose condition includes a customer identifier, a feature flag, or a date threshold surfaces the 'if customer_id == 12345' behaviors that are effectively private API contracts; each such conditional requires an explicit migration decision: migrate the custom behavior, negotiate a migration to the standard behavior, or deprecate and notify. Conducting the behavioral audit before the migration scope is written prevents scope expansion during development; scope expansion during development costs more than scope expansion during planning because development work must be paused, re-estimated, and re-scheduled.
How should the strangler fig boundary be defined to ensure migration completes?
The strangler fig boundary definition has three required components for ensuring migration completion: the migration inventory (every legacy component classified as in-scope, with a migration trigger and a date bound), the deferral registry (every legacy component classified as out-of-scope, with a written rationale), and the completion criterion (the conditions under which the legacy system can be decommissioned). The migration inventory is not complete unless it includes a date bound for every in-scope component. "Migrate the report generation service when next touched for feature work" is an intent with no completion guarantee; "migrate the report generation service by 2026-Q3, triggered by the next relevant feature request or by a sprint allocation at the start of Q3 if no feature request arrives" is a plan with a defined completion date. The deferral registry is as important as the migration inventory: naming which components will not be migrated converts the strangler fig from an indefinitely growing list of remaining items into a bounded migration with a defined completion scope. Deferral categories: permanent maintenance commitments (API compatibility layers for long-term customers — address through an API versioning sunset project, not a strangler migration), low-traffic features with high behavioral complexity (estimate migration cost, compare to maintenance cost over a three-year horizon, and make an explicit keep-or-deprecate decision), and dependencies on external systems being deprecated (the component's migration can wait until the dependency is resolved). The completion criterion specifies when the legacy system can be decommissioned given the migration inventory and the deferral registry: when every in-scope component has been migrated and the deferral registry's permanent commitments have their own maintenance model (not "the legacy system handles it indefinitely with no ownership").
What should the cutover acceptance criteria include for a legacy migration?
Cutover acceptance criteria should specify four verification categories: (1) Functional equivalence — the new system produces the same output as the legacy system for every request in the last 90 days of production traffic, verified by running a traffic replay against both systems in a controlled environment. This is behavioral verification, not unit test coverage — the test suite covers what the team intended to implement; the traffic replay covers what customers actually send. Discrepancies are classified as regressions (must fix before cutover) or intentional behavioral differences (documented in the migration ADR). (2) API compatibility — every external integration that calls the legacy system's API has been verified against the new system; for integrations that will continue using legacy API versions, the compatibility layer's maintenance period and sunset policy are confirmed with the customer. (3) Operational readiness — the new system has been running in production (in the parallel run or strangler fig phase) for a minimum specified observation period without a P1 incident, SLA breach, or data consistency failure; the minimum period should be a number (30 days, 60 days) not a qualitative assessment. (4) Rollback readiness — the rollback procedure from the new system to the legacy system has been executed in a test environment within the recovery time objective; rollback is available for a specified window after cutover, after which the legacy system is decommissioned. Cutover criteria that include "product team sign-off" or "customer confirmation" without the four behavioral categories produce negotiations at the cutover moment because the criteria for sign-off are not specified; the team agrees to cut over when the mood is right, not when the criteria are met, and scope creep accumulates in the gap between intuition and verification.
Further reading
- API versioning decision record — the versioning scheme, deprecation window, and version sunset policy that determine when API compatibility layers in the legacy system can be decommissioned; the strangler fig boundary definition and the API version sunset policy must be aligned, because the legacy API compatibility layer cannot be removed until the last customer using the legacy API version has migrated, and the customer migration timeline determines the migration horizon for that component.
- Incident command system decision record — the IC authority model, coordination protocol, and detection model for the dual-system maintenance period and the parallel run phase; both the legacy and new systems can produce incidents during the migration, and the routing layer between them is an additional failure surface; the IC assignment and rollback authority for migration-period incidents must be specified before the parallel run begins.
- Health check design decision record — the liveness, readiness, and startup probe semantics for both the legacy and new systems during the migration period; a health check that returns healthy for the new system when the system's feature coverage excludes in-scope features being exercised in production creates a false availability signal; the parallel run phase requires functional coverage assertions in the readiness probe alongside standard operational checks.
- Data residency decision record — the data jurisdiction and cross-region data flow decisions that must be preserved in the new system's data architecture; enterprise customer contracts may contain residency commitments that are satisfied by the legacy system's data architecture; the behavioral audit must include a data flow enumeration to ensure the new system's architecture preserves those commitments before the legacy system is decommissioned.
- CI/CD pipeline security decision record — the pipeline configuration for both the legacy and new systems during the dual-system maintenance period; the legacy system's pipeline must cover security patches during the migration period even if feature development is frozen; the pipeline security configuration should apply to both systems, and the legacy system's pipeline scope (full test suite vs smoke tests) should be specified in the migration ADR based on the legacy system's maintenance obligations during the migration period.
- Open-source extractor — find the legacy modernization decisions buried in your AI chat history: the architecture review conversation where the team debated big bang vs strangler fig and the reasoning about risk tolerance that drove the choice, the planning session where the scope was defined as the codebase rather than the production behaviors and the question about customer contracts was not asked, and the all-hands where the strangler boundary was communicated as "migrate when touched" without the follow-up about which endpoints had no natural migration trigger and would need explicit scheduling.