Refactoring Legacy Java Code: Safety Nets, Techniques, and Refactor-vs-Rewrite Decisions
Fast recall
Quick revision
Core answer
Refactoring without tests; big-bang rewrites; mixing refactors with feature commits; 'improve everything' scope creep
Discuss Feathers' legacy techniques, Strangler Fig, IDE automation, characterization tests, DORA metrics, business-level trade-offs
Interview-ready guide
Interview answer
Refactoring is changing code structure without changing behavior — the definition matters because it's what makes it safe.
The foundation
- Test safety net (even crude characterization tests capturing current behavior before you change anything)
- Small, reversible steps (Fowler's catalog: Extract Method, Rename, Introduce Parameter Object, Replace Conditional with Polymorphism, Strangler Fig for bigger scope)
- Each step green-green-green (tests pass before, during, after)
Legacy Java specifics
- When untestable (static deps, huge classes, no seams), use Feathers' Working Effectively with Legacy Code
- 'sprout method', 'wrap class', 'extract interface'
- Seams for injection; then real refactoring
Automate trivial refactors via IDE tools
- IntelliJ's Rename, Extract, Change Signature
- Safer than hand-edits
For large-scale change
- Prefer Strangler Fig (new code alongside old, traffic shifts gradually)
- Over big rewrites which usually fail
Refactor vs rewrite
- Refactor when design is recoverable with incremental changes and tests are feasible
- Rewrite when domain/technology fundamentally no longer fits, language/framework is dead, or cost of understanding exceeds cost of rewriting
- Rewrite almost always more expensive and risky than it seems (Joel Spolsky classic)
Common traps
- Refactoring without tests
- Mixing refactors with feature changes in same commit
- 'Boy scout' out of context (unrelated cleanup in feature PR)
- Large refactors stalling real work
Senior move
- Continuous and incremental refactoring
- Tied to active features
- Tracked via DORA-style metrics
- Treat codebase as gardening — always better than yesterday, never 'perfect'
Fowler's definition
- Change made to internal structure to make it easier to understand and cheaper to modify without changing observable behavior
- Behavior preserved: users and callers see no difference
- Internal structure improved: easier to understand, safer to change, less duplication
What refactoring is NOT
- Bug fixing (behavior changes)
- Feature development (behavior changes)
- Performance tuning (may change subtly; observable via metrics)
- Rewriting (different scale and risk)
- 'Improving the code' without specific goal
Why the distinction matters
- Pure refactoring is safe with tests + small steps; mixing with features multiplies risk
- Different commits, different review lens
- Reviewer can verify 'no behavior change' by test suite staying green + no new/changed tests
Two foundational books
- Fowler's Refactoring: catalog (Extract Method, Rename, Replace Conditional with Polymorphism) each with motivation, mechanics, example
- Feathers' Working Effectively with Legacy Code: how to refactor code with no tests and can't be tested as-is; introduce seams, break dependencies, add characterization tests
- Real Java codebases need both
Safety net first — characterization tests, seams, Feathers
The rule
- Never refactor without a way to verify you haven't broken something
Tests exist and green
- Proceed
Flaky or slow
- Fix first; flaky = won't know if broken; slow = skip
No tests exist
- Add characterization tests: capture what code currently does (not what it should)
- Locks in observed behavior
- Even if buggy, lock it first; fix bugs after refactor as explicit reviewed changes
Add at highest level
- E2E > integration > unit
- Coarse-grained fine; just detect regression
Untestable as-is (legacy)
- Feathers' techniques; goal: introduce seam — place you can alter behavior in tests without changing production code
- 1Extract Interface: static/direct deps → interface; inject real prod, stub test
- 2Sprout Method/Class: don't refactor whole; add new method/class for new code path; clean+tested; old left alone; gravity shifts over time
- 3Wrap Method/Class: wrap legacy in new class; add new behavior around; keep legacy core
- 4Parameterize Method: hard-coded deps → parameters; tests pass doubles; prod passes real
- 5Subclass and Override: private/final deps; subclass in test, override; fragile but works in tight corners
Combine
- E2E characterization test (1-2 scenarios)
- Introduce seam (Extract Interface)
- Unit tests through new seam
- Refactor freely
Applying refactorings
Core mechanics (every refactoring)
- 1Identify smell (duplication, long method, god class, shotgun surgery, feature envy)
- 2Pick from catalog
- 3Tests green
- 4One small step
- 5Tests still green
- 6Commit (one per commit)
- 7Next step
- If red, did too much; revert and smaller
Most valuable in Java
- Extract Method: split long into named smaller (validate, calculateDiscountedTotal, persist)
- Rename: names carry intent; IDE-automated safe
- Replace Magic Number/String with Constant: explicit meaning
- Introduce Parameter Object: 5+ params to record/dedicated class
- Replace Conditional with Polymorphism: massive if-else/switch on type to polymorphism or sealed hierarchies
- Move Method/Field: logic where data lives
- Decompose Conditional: extract condition + branches into named methods
- Introduce Guard Clauses: flatten nested if with early returns
- Replace Inheritance with Delegation: composition over inheritance for flexibility
- Replace Error Code with Exception/Result: Optional, Result, typed exceptions
IDE automation
- IntelliJ Rename, Extract Method, Change Signature, Introduce Variable, Inline safer than hand-edits
- Understands syntax + semantics across files
- Hand-editing misses call sites, String references, reflection
- Shortcut: Cmd/Ctrl+Alt+Shift+T opens menu
Separate refactor from feature
- Never mix in same commit or PR
- Pattern: 'refactor: extract X' then 'feat: use X for new behavior'
- Reviewer checks each independently
- Easier to revert
Boy scout rule
- 'Leave campground cleaner than found'
- When touching for bug/feature, improve slightly
- Compound over months
- BUT: scope discipline; don't cleanup-rampage unrelated areas
Large-scale (whole modules)
- Strangler Fig (Fowler): legacy + new coexist; route traffic gradually (flags, LB splits, routing); remove legacy when zero traffic
- Branch by Abstraction: abstraction layer over target; callers migrate; implement new behind; switch; remove old
- Parallel Change (expand-contract): APIs/schemas; add new alongside; migrate callers; remove old
Architecture-level
- Break monolith into modules before services (Spring Modulith)
- Extract module → microservice only when boundary stable
- ArchUnit to enforce through tests
- Extract by capability, not technical layer
Refactor vs rewrite
Joel Spolsky (2000)
- Rewriting 'single worst strategic mistake any software company can make'
- Old code contains years of bug fixes, edge cases, customer feedback
- Rewrite starts from zero; rediscovers all
- Rewrites underestimate hidden complexity 3-10x
- Take 2x estimates; often longer
- Feature delivery slows/stops
- New bugs old code had solved
When rewriting justified
- Technology truly dead (Flash, Silverlight, CoffeeScript)
- Domain fundamentally changed; old model can't represent new
- Security/compliance not meetable incrementally (rare)
- Team no longer exists; docs/tests missing; maybe — try archaeology first
- System small enough (2-week project, boundaries clear)
When NOT justified (common mistake)
- 'Code is ugly' → refactor
- 'Old framework' → migrate piece by piece
- 'Want new language' → non-critical services first
- 'New team doesn't understand' → understand it; contains business logic
- 'Faster in long run' → only if short term pays 2x cost
If truly necessary: Strangler Fig not big-bang
- New alongside old
- Gradual traffic
- Incremental data migration
- Ability to abandon if not working
- Kill switches at each stage
Hybrid
- Some code refactors cheaply
- Some components justify rewrite (well-defined, small, old-tech)
- Most legacy: refactor core, rewrite isolated pieces, connect via clear interfaces
Decision framework (business)
- 1Business value of change? (feature? hires? incidents?)
- 2Cost estimate in eng-weeks?
- 3Regression risk? (tests? blast radius?)
- 4Cost of NOT doing over 6-12 months? (interest)
- 5Spread cost across time (continuous refactor bundled)?
- If 1/4 > 2/3, refactor; rewrite = same questions × 3
Anti-patterns
- 1Refactoring without tests
- 2Big-bang refactoring (large PR, weeks, conflicts, unreviewable)
- 3Mixing with feature (can't tell what's what; can't revert)
- 4Boy-scout-outside-scope (random unrelated cleanup in feature PR)
- 5Refactor for its own sake (no business link)
- 6Gold-plating (perfect abstractions for code fine as-is)
- 7Premature abstraction (flexible APIs for use cases never come)
- 8Rewrite without safety net (new goes live; old gone; no rollback)
- 9Stopping features for a quarter to 'clean up' (loses trust, fails)
- 10Endless refactoring (never-done; perfection enemy of good)
Senior engineer moves
- Continuously in small bites tied to features; build muscle
- Protect test quality first priority; refactor without tests = gambling
- Separate PRs for refactor vs feature
- Strangler Fig for anything larger than a class
- Say no to rewrites; propose Strangler Fig as refactor-shaped alternative
- Quantify value in business terms (feature velocity, incident reduction, onboarding)
- Mentor juniors on catalog + safety
- Enforce through tooling: pre-commit hooks, ArchUnit, CI budget (no PR grows complexity beyond threshold)
Metrics — good hygiene
- DORA improve (lead time, deploy freq, change failure rate, MTTR)
- Cyclomatic complexity trending down (SonarQube)
- Test coverage stable/rising on critical paths
- Hotspots (change × complexity) declining risk
- Onboarding time shrinks
Metrics — poor/neglect
- Cycle time rising quarter over quarter
- 'Same bug keeps coming back' — masked design issue
- Incidents clustering around same modules
- New hires first merged PR in 6 weeks vs industry 1-2
Team culture
- Part of feature work; don't segregate
- Seniors model: small PRs, good tests, explicit refactor labels
- 15-20% capacity to debt/refactor explicitly
- Review rigor: refactor PRs labeled, reviewed separately
- Postmortems surface refactor candidates; track
- Quarterly hot-zone review: highest-risk modules; prioritize
- Boy scout with restraint
Tooling
- IntelliJ IDEA industry standard for Java; learn shortcuts
- Eclipse similar, slower
- SonarQube/Sonarlint IDE smells live
- SpotBugs/PMD/Checkstyle code-quality rules
- ArchUnit architectural as tests
- Spring Modulith module boundaries
- Testcontainers integration during refactor
- Mutation (PIT) verify test effectiveness post-refactor
Legacy Java specifics
- Raw types → parameterized
- Old enum-like int constants → enums
- Checked exceptions everywhere → domain-aware hierarchy
- Date/Calendar → java.time
- Servlet-era → Spring MVC/Boot
- Hand-rolled DB → Spring Data JPA/JdbcClient
- Java 6/7 collections → Streams + immutable
- Pre-records POJO → Records where appropriate
- Lombok abuse → records; Lombok only for genuine boilerplate
Common scenarios
Legacy monolith to evolve
- Identify bounded contexts (DDD)
- Tests on seams between contexts
- Spring Modulith; enforce boundaries
- Split module → microservice over time
- Strangler Fig for external endpoints
Class 3000 lines, no tests
- Characterization at API level
- Extract small methods (IDE-automated)
- Coherent responsibilities → extract classes
- Iterate weeks; measure size/complexity declining
Framework upgrade (Spring Boot 2→3)
- Not pure refactoring; framework-level behavior changes
- Upgrade in stages; OpenRewrite for automated migrations
- Extensive integration tests before/after
- Staging → canary → prod
DB schema refactor
- Expand-contract; never big-bang
- Add new column alongside old; dual-write; migrate reads; remove old
- Migrations (Flyway/Liquibase); test on prod-size data
Strategic take
- Discipline, not periodic event
- Gets better every day or not at all
- Tests are permission slip; invest first
- Strangler Fig only safe large-scale strategy
- Rewrites almost always wrong; say no
- Seniors build cultures where refactoring is continuous, small, normal — not heroic rescues
Bottom line
- Safety (tests) + small steps + good tools + separation from features + large-scale via Strangler Fig
- Refactor almost always; rewrite rarely; Strangler Fig when genuinely needed
- Measure business-level impact; make trade-offs explicit with product
Refactoring Legacy Java Code: Safety Nets, Techniques, and Refactor-vs-Rewrite Decisions
Direct Answer
Refactoring is changing code structure without changing behavior — the definition matters because it's what makes it safe. The foundation is a test safety net (even crude 'characterization tests' that capture current behavior before you change anything), then apply small, reversible steps (Fowler's catalog: Extract Method, Rename, Introduce Parameter Object, Replace Conditional with Polymorphism, Strangler Fig for bigger scope), each one green-green-green (tests pass before, during, after). Legacy Java specifics: when code is untestable (static deps, huge classes, no seams), use techniques from Michael Feathers' Working Effectively with Legacy Code — 'sprout method', 'wrap class', 'extract interface', seams for injection, and only then real refactoring. Automate trivial refactors via IDE tools (IntelliJ's Rename, Extract, Change Signature) because they're safer than hand-edits. For large-scale change, prefer Strangler Fig (new code alongside old, traffic shifts gradually) over big rewrites which usually fail. Refactor vs rewrite decision: refactor when the design is recoverable with incremental changes and tests are feasible; rewrite when the domain/technology fundamentally no longer fits, language/framework is dead, or the cost of understanding exceeds the cost of rewriting — but rewrite is almost always more expensive and risky than it seems (Joel Spolsky classic). Common traps: refactoring without tests, mixing refactors with feature changes in the same commit, 'boy scout' rule out of context (unrelated cleanup in a feature PR), and large refactors stalling real work. Senior move: make refactoring continuous and incremental, tied to active features, tracked via DORA-style metrics, and treat the codebase as gardening — always better than yesterday, never 'perfect'.
Common Mistakes & Pitfalls
Refactoring without tests; big-bang rewrites; mixing refactors with feature commits; 'improve everything' scope creep
Check your understanding
Answer these without looking back at the article.
- Walk through introducing seams for a legacy class that uses static dependencies.
- How do you decide when a large refactor justifies a dedicated PR vs bundled with a feature?
- When have you said no to a proposed rewrite, and how did you propose refactoring instead?
- What tools do you use to detect refactor candidates at scale in a Java codebase?
Related interview prep categories
2Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.