Legacy Modernization
Purpose
Replace a legacy system incrementally, keeping it in production the entire time. The failure mode this skill exists to prevent is the two-year rewrite that ships nothing and is cancelled.
When to Use
- A system that works but resists change.
- Migrating off an unsupported framework, runtime, or vendor.
- Extracting a service from a monolith.
- Any proposal that starts with "we should just rewrite it".
Capabilities
- Characterization testing to capture undocumented behavior.
- Seam identification and dependency breaking.
- Strangler-fig migration behind a routing facade.
- Data migration with dual writes and backfills.
- Sequencing work so every step is independently shippable.
Inputs
- The legacy system, its deployment, and its actual traffic patterns.
- The concrete pain: what change is expensive, and how expensive.
- Constraints: uptime requirements, data volume, compliance, team capacity.
Outputs
- A migration plan of independently shippable increments.
- Characterization tests covering the behavior being preserved.
- A facade or router that lets old and new coexist.
- A decommissioning checklist for the old path.
Workflow
- Resist the rewrite — Establish what the current system does that a rewrite would have to rediscover. This list is always longer than expected, and it is the argument.
- Pin the behavior — Characterization tests at the outermost boundary you can reach: HTTP, CLI, batch output. Record what it does, bugs included.
- Introduce a facade — Put a routing layer in front of the capability. Every request now flows through a point you control.
- Carve off one capability — Choose the smallest one with clear boundaries. Implement it in the new system. Route a percentage of traffic to it.
- Compare, then cut over — Run both and diff the outputs (shadow mode) until you trust the new path. Then flip the route.
- Delete the old path — Immediately. A migration that leaves both paths alive has doubled the maintenance burden, not reduced it.
- Repeat — Next capability. Each cycle ships.
Best Practices
- Never begin by changing the database. Data is the hardest thing to migrate and the least reversible — do it last, or behind a repository interface.
- Shadow traffic is the cheapest confidence you will ever buy. Send real requests to the new path, discard its response, and compare.
- Feature-flag every cutover so the rollback is a config change, not a deploy.
- Migrate the highest-churn code first. The legacy code nobody has touched in three years is not what is slowing you down.
- Keep a written kill list of old code paths, and cross them off. Otherwise the "temporary" fallback lives forever.
- Do not modernize style, structure, and behavior at once. Preserve behavior; the rest can follow.
Examples
Strangler routing with shadow comparison:
async def get_pricing(request: PricingRequest) -> Pricing:
legacy_result = await legacy_pricing.calculate(request)
if flags.enabled("pricing.shadow", request.tenant_id):
try:
new_result = await pricing_service.calculate(request)
if new_result != legacy_result:
logger.warning(
"pricing_mismatch",
extra={"tenant": request.tenant_id,
"legacy": legacy_result.total_cents,
"new": new_result.total_cents},
)
except Exception:
logger.exception("shadow_pricing_failed") # never affects the response
if flags.enabled("pricing.cutover", request.tenant_id):
return await pricing_service.calculate(request)
return legacy_result
Shadow mode surfaces every discrepancy on real traffic before a single user is affected by the new path.
Notes
- A mismatch rate that will not converge to zero usually means the legacy behavior is not what the spec says. The legacy behavior is the spec.
- Dual-writing to two data stores requires an idempotency key and a reconciliation job. Without both, they will drift.
- The decommission is part of the project. A migration is not done when the new path serves traffic; it is done when the old code is deleted.
1---2name: legacy-modernization3description: Use when incrementally modernizing a legacy system without a rewrite. Covers characterization tests, seams, the strangler pattern, and sequencing migrations so the system stays shippable throughout.4---56# Legacy Modernization78## Purpose910Replace a legacy system incrementally, keeping it in production the entire time. The failure mode this skill exists to prevent is the two-year rewrite that ships nothing and is cancelled.1112## When to Use1314- A system that works but resists change.15- Migrating off an unsupported framework, runtime, or vendor.16- Extracting a service from a monolith.17- Any proposal that starts with "we should just rewrite it".1819## Capabilities2021- Characterization testing to capture undocumented behavior.22- Seam identification and dependency breaking.23- Strangler-fig migration behind a routing facade.24- Data migration with dual writes and backfills.25- Sequencing work so every step is independently shippable.2627## Inputs2829- The legacy system, its deployment, and its actual traffic patterns.30- The concrete pain: what change is expensive, and how expensive.31- Constraints: uptime requirements, data volume, compliance, team capacity.3233## Outputs3435- A migration plan of independently shippable increments.36- Characterization tests covering the behavior being preserved.37- A facade or router that lets old and new coexist.38- A decommissioning checklist for the old path.3940## Workflow41421. **Resist the rewrite** — Establish what the current system does that a rewrite would have to rediscover. This list is always longer than expected, and it is the argument.432. **Pin the behavior** — Characterization tests at the outermost boundary you can reach: HTTP, CLI, batch output. Record what it does, bugs included.443. **Introduce a facade** — Put a routing layer in front of the capability. Every request now flows through a point you control.454. **Carve off one capability** — Choose the smallest one with clear boundaries. Implement it in the new system. Route a percentage of traffic to it.465. **Compare, then cut over** — Run both and diff the outputs (shadow mode) until you trust the new path. Then flip the route.476. **Delete the old path** — Immediately. A migration that leaves both paths alive has doubled the maintenance burden, not reduced it.487. **Repeat** — Next capability. Each cycle ships.4950## Best Practices5152- Never begin by changing the database. Data is the hardest thing to migrate and the least reversible — do it last, or behind a repository interface.53- Shadow traffic is the cheapest confidence you will ever buy. Send real requests to the new path, discard its response, and compare.54- Feature-flag every cutover so the rollback is a config change, not a deploy.55- Migrate the highest-churn code first. The legacy code nobody has touched in three years is not what is slowing you down.56- Keep a written kill list of old code paths, and cross them off. Otherwise the "temporary" fallback lives forever.57- Do not modernize style, structure, and behavior at once. Preserve behavior; the rest can follow.5859## Examples6061**Strangler routing with shadow comparison:**6263```python64async def get_pricing(request: PricingRequest) -> Pricing:65 legacy_result = await legacy_pricing.calculate(request)6667 if flags.enabled("pricing.shadow", request.tenant_id):68 try:69 new_result = await pricing_service.calculate(request)70 if new_result != legacy_result:71 logger.warning(72 "pricing_mismatch",73 extra={"tenant": request.tenant_id,74 "legacy": legacy_result.total_cents,75 "new": new_result.total_cents},76 )77 except Exception:78 logger.exception("shadow_pricing_failed") # never affects the response7980 if flags.enabled("pricing.cutover", request.tenant_id):81 return await pricing_service.calculate(request)8283 return legacy_result84```8586Shadow mode surfaces every discrepancy on real traffic before a single user is affected by the new path.8788## Notes8990- A mismatch rate that will not converge to zero usually means the legacy behavior is not what the spec says. The legacy behavior is the spec.91- Dual-writing to two data stores requires an idempotency key and a reconciliation job. Without both, they will drift.92- The decommission is part of the project. A migration is not done when the new path serves traffic; it is done when the old code is deleted.