Patterns in Legacy Codebases
Patterns in legacy codebases is where seniors earn their keep: no tests, tangled dependencies, original authors gone, business can't stop shipping.
Introduction
Patterns in legacy codebases is where seniors earn their keep: no tests, tangled dependencies, original authors gone, business can't stop shipping. Michael Feathers' playbook: characterize first, introduce seams, apply patterns incrementally — Adapter, Facade, Strangler — never big-bang.
The story
Team inherited 30,000-line PHP monolith, zero tests, 12 years of patches. Three months of characterization tests, then seams via Adapter and Facade. Over 18 months 60% replaced with TypeScript microservices via Strangler Fig — zero production incidents.
The business problem
Legacy traps teams between rewrite fantasy and change paralysis:
- No tests: every edit is roulette.
- Tangled deps: touch one class, break ten.
- Lost knowledge: authors gone; behavior undocumented.
- Business pressure: can't freeze for "great refactor."
The problem teams faced
Legacy pattern application requires:
- Characterization tests before any structural change.
- Seams — places to change behavior without editing call sites.
- Incremental patterns: Adapter, Facade, Strangler — not rewrite.
- ACL at legacy boundary — don't leak foreign vocabulary inward.
Understanding the topic
Intent: Change legacy safely via seams and incremental patterns.
- Characterization — record outputs; tests describe what is, not what should be.
- Seam — inject dependency, extract interface, wrap with Adapter.
- Adapter/Facade — simplify legacy surface for new code.
- Strangler — route new features to new stack at edge.
Internal architecture
Adapter seam at legacy ERP:
// New domain code — clean vocabularyinterface InventoryPort {reserve(sku: string, qty: number): Promise<ReservationId>}// Adapter wraps legacy ERP XML/RPC messclass ErpInventoryAdapter implements InventoryPort {constructor(private legacy: LegacyErpClient) {}async reserve(sku: string, qty: number) {const raw = await this.legacy.call("INV_HOLD", { sku, qty }) // foreign APIreturn ReservationId.parse(raw.hold_id) // translate inward}}
Visual explanation
Three diagrams cover Feathers workflow, seams, and strangler combo:
Informative example
Before / after — direct legacy call to Adapter seam:
// ❌ New feature calls legacy ERP directly — vocabulary leaksconst hold = await legacyErp.call("INV_HOLD", { sku, qty })// ✅ Adapter seam — new code speaks domainclass OrderService {constructor(private inventory: InventoryPort) {}async place(order: Order) {await this.inventory.reserve(order.sku, order.qty)}}// Legacy isolated in ErpInventoryAdapter — characterize adapter thoroughly
Execution workflow
Characterize
Record current outputs as expected.
Real-world use
Working Effectively with Legacy Code (Feathers); Strangler Fig (Fowler); ACL (Evans DDD); characterization tests in Rails/Java legacy migrations.
Production case study
30k-line PHP monolith migration:
- Start: zero tests; 12 years patches.
- 3 months: characterization tests across critical paths.
- 18 months: 60% TypeScript via Adapter + Strangler; 0 incidents.
Trade-offs
- Pro: continuous delivery; no big-bang; business keeps shipping.
- Con: characterization slow upfront — pays back.
- Con: dual-run complexity during strangler period.
Decision framework
- Never refactor legacy without characterization on touched paths.
- Adapter at every legacy integration — ACL inward.
- Strangler for route-level migration; Adapter for vocabulary.
Best practices
- Pin legacy behavior before Adapter rewrite.
- New features on new stack when strangler route exists.
- Document legacy quirks in adapter tests, not domain.
Anti-patterns to avoid
- Big-bang rewrite "we'll catch up on tests later."
- Edit legacy globals without seam — shotgun surgery.
- Skip ACL — ERP field names in domain entities.
Common mistakes
- Characterization tests that encode bugs as correct.
- Adapter grows business logic — becomes new monolith.
Debugging tips
- Characterization diff after change — intentional or regression?
Optimization strategies
- Characterize highest-churn legacy modules first — ROI.
Common misconceptions
- Legacy patterns ≠ rewrite — incremental only.
- Characterization ≠ approval — documents reality.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionCharacterization test vs unit test?+
Answer
Follow-up
2AdvancedQuestionWhat is a seam in legacy code?+
Answer
Follow-up
3IntermediateQuestionAdapter vs Strangler in legacy?+
Answer
Follow-up
Summary
You can apply Feathers' workflow, place Adapters at legacy seams, and pair with Strangler. Tell the 30k PHP zero-incident story — if you can do that, you own Patterns in Legacy Codebases and complete Refactoring & Anti-Patterns.