Skip to content
GitHub
Logic Errors

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:

  1. Add productized repos and services (correct)
  2. Add “dual-write” to the OTEL router (wrong — accretion)
  3. Add if ctx.productized_scan_id branches to phases (wrong — accretion)
  4. 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