How do you migrate a large legacy codebase safely: the strangler pattern, adapters and characterization tests?
Predict first
A callback-based legacy store is wrapped in an adapter implementing an async interface. What do callers see?
final class LegacyStore: @unchecked Sendable {
func load(id: Int, completion: @escaping @Sendable (String?) -> Void) { completion(id == 1 ? "legacy-1" : nil) }
}
protocol ItemRepository { func item(id: Int) async -> String? }
struct LegacyAdapter: ItemRepository {
let legacy: LegacyStore
func item(id: Int) async -> String? {
await withCheckedContinuation { c in legacy.load(id: id) { c.resume(returning: $0) } }
}
}
let repo: ItemRepository = LegacyAdapter(legacy: LegacyStore())
print(await repo.item(id: 1) ?? "nil", await repo.item(id: 2) ?? "nil")
- legacy-1 nil
- nil nil
- It does not compile
Mental model. A strangler fig grows around an old tree until the tree can be removed and the fig stands on its own. You never have a moment with no tree.
Rewriting a working app from scratch usually fails: it takes longer than planned, must keep up with a moving target, ships nothing until the end and loses subtle behaviour the old code handled. The strangler fig pattern replaces the old system gradually: you grow the new code around the old, route one slice of behaviour at a time to the new implementation, and delete the old part when nothing uses it, so the app ships continuously and the risk per step is small. The mechanics: 1) Find a seam, a place where you can substitute behaviour without editing the callers: a protocol, a factory, a function boundary. 2) Put an interface in front of the old code (for example ItemRepository with item(id:) async -> String?) and make callers depend on that. 3) Write an adapter that implements the interface over the legacy code: for a completion-handler API, withCheckedContinuation wraps it as async, so new code is clean while the old implementation still runs. 4) Build the new implementation behind the same interface and switch to it by a flag, a percentage rollout or one screen at a time. 5) Remove the legacy code and the adapter.
Make each step safe. Write characterization tests first: tests that record what the old code actually does (including its quirks and bugs) for a range of inputs, so the new code can be compared against them; run old and new side by side (shadow mode) and log differences before switching. Prefer small, reversible steps: feature flags and kill switches, one module or screen per release, and a rule that no legacy file gets bigger. Migrate by value: start with the code that changes most or hurts most (the hot spots in git history and the crash reports), not the oldest. Keep interop clean: Objective-C and Swift can coexist (see the interoperability topic), UIKit screens can be hosted in SwiftUI and the reverse, and old and new navigation need one owner during the transition. Track progress (percentage of screens migrated, lines of legacy code) and agree an end date; strangler migrations stall when the last 20% has no owner. Resist rewriting and redesigning at once: first move behaviour behind the interface unchanged, then improve it, since mixed changes make regressions impossible to attribute.
The adapter turns the old callback into an async call behind a clean interface, so callers cannot tell and the implementation can be replaced later.
Common traps
- Starting a big-bang rewrite and freezing the old app. It ships nothing for months and loses behaviour; migrate behind an interface.
- Migrating without tests of the current behaviour. You cannot tell regressions from intended changes; write characterization tests first.
They might follow up with
- What is a seam? A point where behaviour can be substituted without editing the callers, such as a protocol or factory.
- How do you check the new code matches the old? Characterization tests and running both in shadow mode, comparing outputs.
Related questions
- 15.3.1 What is a feature module, how do you draw module boundaries, and how does access control enforce them?
- 13.5.1 What is the repository pattern, and why map storage models to domain models?
- 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
← What is a feature module, how do you draw module boundaries, and how…