Legacy migration: dual-path code is not migration, it's accretion
Legacy Migration: Dual-Path Code is Not Migration
Type: Principle / antipattern (no severity — applies whenever you reach for
if legacy: ... else: new: ...) · Applies to: any in-flight migration from old infrastructure to a productized replacement Related: ADR-032 — Storage topology and forward-only migrations
Problem
When migrating from legacy infrastructure to productized equivalents, the natural
instinct is to “add the new path alongside the old” — keeping backward compatibility
by adding if productized: ... else: legacy ... branches.
This is not migration. It’s accretion. It doubles the code surface, creates two execution paths that must both be tested and maintained, and guarantees the legacy path never gets removed because “something might still need it.”
Investigation
What Happened
During the productized-pipeline migration, the initial approach was:
- Add productized repos and services (correct)
- Add “dual-write” to the OTEL router (wrong — accretion)
- Add
if ctx.productized_scan_idbranches to phases (wrong — accretion) - Mark legacy files with deprecation headers (wrong — cosmetic, not migration)
This produced working contract tests but left the legacy pipeline entirely intact, with the productized pipeline running as an optional parallel path.
Why It Failed
- Legacy code was never removed — just annotated
- 68 files still had raw SQL against legacy tables
- schema.py couldn’t be deleted because everything still depended on it
- The “12 task” cleanup estimate was actually 100+ tasks because dual-path deferred the real work
Root Cause
Confusing “adding new capability” with “migrating existing capability.” Migration means the old code goes away. If the old code is still there after you’re done, you haven’t migrated — you’ve duplicated.
Solution
The correct pattern for legacy migration:
1. Pick the file
2. Identify what repositories it needs
3. Create any missing repos (protocol in application layer, implementation in infrastructure)
4. Inject repos into the component’s constructor
5. Replace every SQL query with a repo call
6. Delete the legacy code from that file
7. Update tests to use in-memory repo implementations
8. Verify tests pass
9. Move to next file
No dual paths. No deprecation markers. No “optional productized mode.” The file either uses repos or it doesn’t. When it does, the SQL is gone.
Prevention
Best Practices
- When requirements say “remove X,” the deliverable is the absence of X, not a comment saying X is deprecated
- Migration PRs should show net negative line counts — more deleted than added
- Each converted file should have zero direct SQL queries when done
- Test the ACTUAL behavior, not a parallel optional path
Warning Signs
- Adding
if new_path: ... else: old_path ...branches - Files with both repo calls AND raw SQL
- “Dual-write” as a migration strategy (it’s a crutch)
- Deprecation markers as a deliverable (they’re a TODO, not work)
References
- Clean Architecture: dependency injection replaces raw infrastructure access
- The correct pattern was demonstrated in the SafetyPhase conversion: repo injected,
all SQL removed, legacy
run()function deleted, tests rewritten