Legacy code is dangerous to change when nobody can confidently answer:
What behavior depends on this code?
What breaks if I move it?
Which edge cases are intentional?
Which dependencies are hidden?
How will I know if the refactor changed behavior?
The wrong response is usually:
rewrite everything cleanly
because the old implementation often contains years of undocumented production knowledge.
A safer approach is:
observe behavior
↓
protect behavior
↓
create a seam
↓
make one small change
↓
run tests
↓
review difference
↓
repeat
Refactoring is behavior-preserving change
If you deliberately change user-visible behavior at the same time, you are doing both refactoring and feature work. That can be valid, but it increases uncertainty. When possible, separate structural cleanup from behavioral changes so regressions are easier to locate.
1. Define what makes legacy code risky
Code is not risky merely because it is old.
A ten-year-old function with strong tests and clear boundaries can be easier to modify than a six-month-old function that depends on:
global state
database
filesystem
current time
randomness
network
environment variables
static singleton
hidden cache
Typical warning signs
- No reliable tests.
- Large functions with many branches.
- Shared mutable state.
- Side effects mixed with calculations.
- Static or global dependencies.
- Unclear ownership.
- Many unrelated responsibilities.
- Production-only edge cases.
Risk is uncertainty multiplied by impact
uncertain behavior
+
high production impact
=
high refactoring risk
Before touching the code, identify the blast radius
Ask:
Who calls this?
What data does it change?
Which external systems does it touch?
Which jobs depend on it?
Can changes be rolled back?
Is traffic observable?
Do not begin with architecture diagrams alone
Legacy systems often behave differently from their intended design.
Use:
runtime logs
tests
database behavior
real request examples
production metrics
to understand what actually happens.
2. Protect behavior before improving structure
Safe legacy refactoring loop (diagram)
Imagine a method:
calculateInvoice()
that contains:
discount rules
tax calculation
database lookup
currency conversion
logging
email notification
You may immediately see five architectural problems.
That does not mean the safest first step is:
split into five services
First preserve observable behavior
Start with cases that matter:
normal invoice
zero discount
maximum discount
tax-exempt customer
currency conversion
failed payment
missing customer
Record outputs and side effects
Depending on the function:
return value
exception
database row
sent event
HTTP call
created file
log outcome
Then change structure
For example:
extract tax calculation
into calculateTax()
and run the behavior tests again.
Keep each step reversible
Good refactoring increments are small enough that when a test fails you can explain:
exactly what changed
since the last green state
3. Build characterization tests
A characterization test answers:
What does this code
actually do today?
rather than:
What should the ideal
implementation do?
Example
Suppose old code unexpectedly rounds:
10.005
to:
10.00
You may believe:
10.01
would be better.
During pure refactoring, the first test may intentionally assert:
current result = 10.00
because changing the rounding rule is a separate behavior change.
Characterization tests can exist at many levels
unit
integration
API
command line
database
snapshot
approval
end-to-end
Start at the level you can access
If the class cannot be instantiated without:
database
filesystem
network
15 globals
do not spend three days forcing a perfect unit test first.
A broader test around:
HTTP endpoint
may provide enough protection to begin extracting seams.
Golden-master style tests
For deterministic complex output:
input fixture
↓
legacy system
↓
large output
↓
approved expected result
can quickly protect behavior.
Normalize unstable fields
Remove or control:
timestamps
random IDs
temporary paths
machine-specific data
so the test measures meaningful behavior rather than noise.
4. Add test scaffolding around hard dependencies
Legacy code test scaffolding (diagram)
Test scaffolding is temporary or supporting structure that makes risky code observable and controllable.
Typical scaffolding
- Fake clock.
- Deterministic random generator.
- Temporary database.
- HTTP stub server.
- Filesystem sandbox.
- Fixture loader.
- Recorded response.
- Wrapper around static dependency.
Time is a common hidden dependency
Legacy:
if currentTime() > subscription.endDate:
expire()
Hard to test reliably.
Introduce:
Clock.now()
so tests can provide:
2026-08-30 10:00
Randomness should become controllable
generateRandomCoupon()
can be wrapped behind:
RandomSource
or a function parameter.
Network dependencies can be stubbed at a boundary
legacy code
↓
PaymentGateway wrapper
↓
real API
tests can substitute:
FakePaymentGateway
Do not mock every internal method
A brittle test that asserts:
method A called method B
then method C exactly once
may block harmless refactoring.
Prefer testing:
observable contract
unless the interaction itself is the required behavior.
5. Create seams before large extractions
A seam is a place where you can substitute behavior.
Before seam
function checkout(order):
db = GlobalDatabase.instance()
rate = StaticTaxService.getRate()
now = SystemClock.now()
payment = RealGateway.charge(order)
Testing requires the entire world.
After introducing seams
checkout(
order,
database,
taxService,
clock,
paymentGateway
)
now tests can control dependencies.
Do not inject everything blindly
Dependency injection is useful where substitution improves:
testability
ownership
configuration
architecture
not as a goal by itself.
Wrapper seam
If a difficult static library call exists:
LegacyVendorApi.send(...)
first wrap it:
VendorClient.send(...)
Then legacy code depends on your narrow adapter.
Function seam
processOrder(order, calculateTax)
can be enough in languages where functions are easy to pass.
Configuration seam
Replace:
read environment variable
everywhere
with:
AppConfig
created once at the boundary.
6. Refactor in mechanically small steps
Large conceptual goals should be decomposed into tiny transformations.
Goal
separate pricing logic
from checkout controller
Unsafe version
rewrite checkout
create new architecture
rename all variables
change data model
change API response
update database
add caching
Safer sequence
1. rename confusing local variable
2. extract calculateSubtotal()
3. run tests
4. extract calculateDiscount()
5. run tests
6. extract calculateTax()
7. run tests
8. create PricingResult
9. run tests
10. move pure pricing functions
into PricingService
11. run tests
Prefer transformations your tools understand
IDE-assisted:
rename
extract method
move method
change signature
can reduce mechanical mistakes.
Run tests frequently
Do not refactor for:
45 minutes
before discovering:
something broke 37 minutes ago
Keep the code runnable
Aim for:
green
change
green
change
green
rather than:
red for two days
then hope everything works
7. Separate pure logic from side effects
One of the most valuable legacy-code moves is separating:
calculation
from:
I/O
Mixed legacy function
load order from database
calculate discount
call tax API
update database
send email
Extract pure calculations
calculateDiscount(order, rules)
calculateTotals(order, taxRate)
These functions can often be tested with:
input
→
output
and no mocks.
Keep side effects at the edges
load data
↓
pure calculation
↓
save result
↓
send notification
Benefits
- Fast tests.
- Deterministic behavior.
- Less mocking.
- Easier reasoning.
- Clearer failure boundaries.
Do not force everything to be pure
Applications must eventually:
write
send
read
persist
communicate
The goal is to make those effects explicit and narrow.
8. Break large functions without changing behavior
Legacy seam extraction workflow (diagram)
A 600-line function can feel impossible to test.
Do not begin by deciding:
what the final class hierarchy should be
Find stable chunks first
Look for:
calculation block
validation block
database block
mapping block
notification block
Extract one contiguous block
Convert:
20 lines inside function
into:
calculateShippingCost(...)
without changing logic.
Do not improve logic during the first extraction
Avoid simultaneously:
extracting
renaming
simplifying branches
changing rounding
changing null behavior
if the risk is high.
After extraction, add focused tests
Once:
calculateShippingCost()
exists as an isolated unit, write direct tests.
Then simplify
Only after behavior is protected should you consider:
removing duplication
simplifying conditionals
introducing better types
renaming domain concepts
Use temporary awkwardness strategically
A temporary signature such as:
calculateShipping(
order,
customer,
config,
country,
currentDate,
featureFlag
)
may reveal hidden coupling.
That information helps decide the next extraction.
9. Turn every bug into regression protection
A production bug gives you a valuable test case.
Weak workflow
find bug
change condition
deploy
Safer workflow
capture failing input
write test that reproduces bug
confirm test fails
apply smallest fix
confirm test passes
run related regression tests
Example
Bug:
customer with expired coupon
receives negative total
Write:
given expired coupon
and order total 20
expected total:
not below 0
before changing the implementation.
Keep the regression test permanently
That bug represents:
production knowledge
that was previously missing
from the test suite
Test the smallest useful level
If the bug can be reproduced in:
pure pricing function
prefer that over a five-minute end-to-end test.
If the bug depends on:
database transaction
+
HTTP request
+
serialization
an integration test may be necessary.
10. Replace large subsystems incrementally
Sometimes the legacy implementation genuinely should disappear.
You still do not need:
old system off
new system on
all at once
Branch by abstraction
Introduce:
PaymentProcessor
in front of:
LegacyPaymentProcessor
Then add:
NewPaymentProcessor
behind the same boundary.
Routing can move gradually
0% new
5% new
25% new
50% new
100% new
where product risk and infrastructure support gradual rollout.
Strangler-style replacement
Move one capability at a time:
legacy application
├── old checkout
├── old search
├── old reports
└── old notifications
then:
legacy application
├── new checkout
├── old search
├── old reports
└── old notifications
Run old and new implementations side by side where useful
For deterministic calculations:
old result
vs
new result
can reveal differences before the new implementation controls production behavior.
Do not duplicate side effects during shadow execution
Comparing:
price calculation
twice may be safe.
Executing:
charge card
twice is not.
11. Treat databases and APIs as migration boundaries
Database changes deserve special sequencing
Suppose you want to rename:
customer_name
to:
display_name
A one-step deployment may break old application instances.
Expand and contract
1. add new column
2. deploy code that can read old
and write both
3. backfill
4. switch reads to new
5. stop writing old
6. remove old column later
API refactoring also needs compatibility
Avoid changing:
{
"user_name": "A"
}
directly into:
{
"displayName": "A"
}
while deployed consumers still expect the old field.
Adapters isolate old contracts
external legacy DTO
↓
adapter
↓
new internal model
lets internal refactoring proceed without immediately breaking external clients.
Keep migration logic temporary and visible
Mark:
dual writes
compatibility fields
temporary adapters
fallback reads
with a planned removal condition.
12. Use source control as a safety mechanism
Git or another version-control system is part of your refactoring safety net.
Small commits
Prefer:
Extract tax calculation
Add test for zero-rate customer
Introduce Clock seam
over:
Refactor billing
containing 2,000 changed lines.
Separate mechanical changes from behavioral ones
Commit:
rename variable
separately from:
change discount logic
Why this helps
- Easier review.
- Easier rollback.
- Easier bisecting.
- Clearer history.
- Smaller merge conflicts.
Review diff size
After each refactoring step ask:
Did I change only what
I intended to change?
Avoid formatting the entire file during risky changes
Mixing:
real code changes
+
thousands of whitespace changes
hides meaningful differences from reviewers.
13. Verify behavior beyond automated tests
Tests are essential, but production behavior may include cases the test suite does not know.
Monitor after refactoring
Compare:
error rate
latency
database load
request volume
retry rate
business conversion
queue depth
before and after deployment.
Use structured comparison during migrations
For a new calculation:
oldPrice
newPrice
difference
can be logged safely in a controlled environment when values are not sensitive.
Feature flags can reduce rollout risk
legacy behavior
or
new behavior
can be selected without immediately deploying new code.
Flags need removal plans
Otherwise the codebase accumulates:
temporary branch
after temporary branch
and becomes harder to reason about.
Define rollback before deployment
Ask:
Can we disable the new path?
Can we deploy previous version?
Did database migration remain compatible?
Are writes reversible?
14. Follow a repeatable refactoring workflow
Step 1: choose one concrete change
Weak goal:
clean up order system
Better:
extract discount calculation
so it can be tested directly
Step 2: identify existing behavior
inputs
outputs
side effects
edge cases
failure behavior
Step 3: establish protection
Add:
characterization test
integration test
snapshot
approval output
at the narrowest practical boundary.
Step 4: create a seam if necessary
Wrap:
database
network
clock
filesystem
randomness
Step 5: make one structural change
Examples:
rename variable
extract local variable
extract function
move function
introduce parameter
wrap dependency
Step 6: run the tests immediately
If a test fails, the change set should be small enough to inspect quickly.
Step 7: inspect the diff
Look for accidental:
condition changes
default changes
ordering changes
exception changes
side-effect changes
Step 8: commit the green state
Keep a known-good checkpoint.
Step 9: repeat
protect
change
verify
commit
protect
change
verify
commit
Step 10: stop when the next change becomes easy
Refactoring success is not:
perfect architecture
It is:
the next safe change
is easier than before
15. Copy/paste legacy refactoring checklist
Safe legacy refactoring checklist
Before changing code
- Define the concrete goal.
- Avoid vague "clean up everything" scope.
- Identify production impact.
- Identify callers.
- Identify side effects.
- Identify data written.
- Identify external dependencies.
- Identify rollback path.
- Check monitoring coverage.
Understand behavior
- Read current tests.
- Read production logs.
- Inspect real input examples.
- Inspect edge cases.
- Inspect error handling.
- Inspect configuration.
- Inspect feature flags.
- Inspect database constraints.
- Inspect external API behavior.
Characterization tests
- Capture current behavior.
- Test normal case.
- Test boundary case.
- Test known weird behavior.
- Test current error behavior.
- Test important side effects.
- Do not silently "correct" behavior during characterization.
- Name tests after observable behavior.
Test level
- Use unit tests when available.
- Use integration tests when unit isolation is impossible.
- Use API tests for request/response contracts.
- Use snapshot tests for stable structured output.
- Use approval tests for large deterministic output.
- Use end-to-end tests only where necessary.
- Prefer the cheapest test that protects the behavior.
Golden master
- Capture representative inputs.
- Capture deterministic outputs.
- Normalize timestamps.
- Normalize random IDs.
- Normalize machine-specific paths.
- Review approved baseline manually.
- Avoid approving unknown incorrect output blindly.
Test scaffolding
- Introduce fake clock.
- Introduce deterministic random source.
- Introduce temporary filesystem.
- Introduce local database fixture.
- Introduce HTTP stub.
- Introduce queue fake.
- Introduce dependency wrapper.
- Keep scaffolding focused on observable behavior.
Seams
- Identify static dependencies.
- Identify global state.
- Identify hidden singletons.
- Identify system clock.
- Identify randomness.
- Identify filesystem access.
- Identify network access.
- Identify environment access.
- Wrap difficult dependencies.
- Inject where substitution adds value.
Time
- Replace direct current-time access behind a clock.
- Make tests choose current time.
- Test before boundary.
- Test exactly at boundary.
- Test after boundary.
- Test timezone assumptions separately.
Randomness
- Inject random source where deterministic tests matter.
- Seed random generator when appropriate.
- Avoid tests that occasionally fail.
- Test generated-value constraints separately.
Filesystem
- Use temporary directories.
- Avoid real user directories.
- Test missing file.
- Test permission failure where meaningful.
- Test existing file.
- Test cleanup.
- Isolate path construction.
Network
- Wrap remote clients.
- Stub expected response.
- Stub timeout.
- Stub malformed response.
- Stub server failure.
- Avoid real network in fast unit tests.
- Keep a smaller set of integration tests against real protocols where useful.
Database
- Use test database or transaction fixture.
- Reset state.
- Use deterministic seed data.
- Test transaction behavior.
- Test constraints.
- Avoid shared test state.
- Keep migrations represented in tests.
Pure logic
- Find calculations mixed with I/O.
- Extract calculations.
- Pass required values explicitly.
- Return values instead of mutating globals.
- Add focused tests.
- Keep side effects at boundaries.
Large functions
- Identify coherent blocks.
- Extract one block at a time.
- Keep behavior unchanged during extraction.
- Run tests after each extraction.
- Avoid rewriting all conditionals at once.
- Use temporary parameters if needed.
- Let awkward signatures reveal coupling.
Rename
- Use automated rename tool.
- Rename one concept at a time.
- Avoid mixing renames with behavior changes.
- Run tests.
- Inspect public API names separately.
Extract method
- Select contiguous behavior.
- Preserve order.
- Preserve exception behavior.
- Preserve side effects.
- Preserve return semantics.
- Add direct tests after extraction when useful.
Extract class
- Do not extract class before responsibility is visible.
- First extract cohesive functions.
- Identify shared data.
- Move behavior and data together.
- Keep old facade if callers depend on it.
- Migrate callers gradually.
Dependency injection
- Use where it creates a useful seam.
- Avoid injecting trivial values unnecessarily.
- Prefer constructor injection for stable dependencies where idiomatic.
- Prefer function parameters for narrow functional seams where idiomatic.
- Keep composition at application boundary.
Globals
- Locate reads.
- Locate writes.
- Wrap access.
- Pass value explicitly where practical.
- Eliminate hidden mutation gradually.
- Add tests around concurrency if shared state is involved.
Static methods
- Leave pure stateless utilities alone if they are harmless.
- Wrap static I/O or environment calls.
- Avoid global mocking frameworks as the first solution when a simple seam works.
Constructors
- Avoid heavy I/O in constructors where possible.
- Extract factory if construction has side effects.
- Keep object creation deterministic.
- Separate loading from initialization.
- Test failure during construction.
Side effects
- Identify database writes.
- Identify messages.
- Identify emails.
- Identify files.
- Identify metrics.
- Identify cache mutations.
- Preserve side-effect ordering where behavior depends on it.
Ordering
- Test important operation order.
- Be careful moving side effects.
- Preserve transaction boundaries.
- Preserve event-before-commit or event-after-commit semantics intentionally.
- Check cleanup ordering.
Exceptions
- Preserve exception type during pure refactoring.
- Preserve cause.
- Preserve cleanup.
- Avoid swallowing errors.
- Test expected failure path.
- Separate deliberate error-contract changes.
Null behavior
- Preserve null handling during structural change.
- Add explicit regression cases.
- Do not replace null with empty value silently.
- Improve null contracts separately.
Defaults
- Preserve default values.
- Test missing configuration.
- Test empty configuration.
- Avoid changing default behavior during extraction.
Conditionals
- Add tests before simplifying complicated branches.
- Cover each meaningful branch.
- Extract named predicates.
- Simplify one branch at a time.
- Avoid combining logical rewrites with moves.
Duplication
- Do not remove duplication before understanding differences.
- Compare duplicated blocks.
- Add tests for both callers.
- Extract only genuinely common behavior.
- Keep differing policy explicit.
Bug fixes
- Capture failing input.
- Write failing regression test.
- Confirm failure.
- Apply smallest fix.
- Confirm test passes.
- Run broader suite.
- Keep regression test permanently.
Feature changes
- Separate from refactoring when possible.
- Commit structural changes first.
- Keep tests green.
- Implement new behavior afterward.
- Add behavior-specific tests.
Source control
- Commit frequently.
- Keep commits narrow.
- Use descriptive messages.
- Avoid unrelated formatting.
- Avoid unrelated cleanup.
- Preserve easy revert.
- Use bisectable history.
Code review
- Keep diff small.
- Explain intended behavior preservation.
- Highlight temporary seams.
- Highlight migration code.
- Point reviewers to characterization tests.
- Avoid mixing generated formatting noise.
Branch by abstraction
- Introduce abstraction around old implementation.
- Keep old behavior active.
- Add new implementation behind same abstraction.
- Migrate callers or traffic gradually.
- Compare behavior.
- Remove old implementation after confidence is established.
Strangler migration
- Move one capability at a time.
- Route requests deliberately.
- Preserve old paths until migrated.
- Monitor new path.
- Remove old path only after verified cutover.
Shadow execution
- Use for side-effect-free comparisons.
- Compare old and new outputs.
- Record differences.
- Do not duplicate irreversible side effects.
- Limit production overhead.
- Remove shadow path after migration.
Feature flags
- Use for controlled rollout.
- Define owner.
- Define default.
- Define rollback.
- Define removal date or condition.
- Test both branches while flag exists.
- Delete stale flags.
Database migrations
- Prefer backward-compatible expand/contract.
- Add before removing.
- Backfill separately.
- Support mixed application versions during rollout.
- Avoid destructive migration before all readers are updated.
- Verify rollback implications.
API changes
- Preserve external contract during internal refactoring.
- Use adapters.
- Version breaking changes.
- Support old consumers during migration.
- Test compatibility.
- Keep translation at boundary.
Events and queues
- Preserve event schema.
- Version breaking changes.
- Avoid changing semantic meaning silently.
- Support replay of historical events.
- Make migration consumers tolerant where required.
Performance
- Establish baseline where performance matters.
- Refactoring should preserve behavior, including important performance constraints.
- Compare latency.
- Compare allocations.
- Compare database query count.
- Watch N+1 regressions.
- Avoid optimization without measurement.
Concurrency
- Identify shared state.
- Identify locks.
- Preserve synchronization semantics.
- Do not move operations across lock boundaries casually.
- Test races where practical.
- Review asynchronous ordering.
Transactions
- Preserve transaction boundaries.
- Do not accidentally commit earlier.
- Do not move network call inside transaction without reason.
- Test rollback.
- Test partial failure.
- Keep data integrity invariant.
Observability
- Track error rate.
- Track latency.
- Track throughput.
- Track retries.
- Track queue depth.
- Track business outcomes.
- Compare before and after deployment.
Production rollout
- Deploy small change.
- Monitor.
- Compare metrics.
- Keep rollback available.
- Avoid stacking many risky refactors into one release.
- Stop rollout when behavior diverges.
Rollback
- Know previous deploy version.
- Keep schema backward compatible where possible.
- Avoid irreversible writes during early rollout.
- Keep feature flag fallback.
- Document manual recovery where necessary.
Test brittleness
- Avoid asserting private method calls unnecessarily.
- Avoid asserting exact implementation sequence unless required.
- Prefer observable behavior.
- Avoid over-mocking.
- Keep fixtures readable.
- Remove obsolete scaffolding as design improves.
Mocking
- Mock boundaries, not every internal function.
- Use fakes for stateful dependencies where useful.
- Use stubs for predefined responses.
- Use spies only when interaction itself matters.
- Avoid mocks that reproduce implementation logic.
Test speed
- Keep fast unit tests close to extracted pure logic.
- Keep smaller integration suite for boundaries.
- Avoid making every refactor depend on slow end-to-end tests.
- Parallelize where safe.
- Keep feedback loop short.
Coverage
- Do not chase percentage alone.
- Cover risky branches.
- Cover important contracts.
- Cover bugs.
- Cover side effects.
- Cover migration boundaries.
- Use coverage as navigation, not proof of correctness.
Dead code
- Prove code is unused.
- Search references.
- Check reflection or dynamic loading.
- Check configuration.
- Check scheduled jobs.
- Check feature flags.
- Remove in a separate commit.
Comments
- Preserve comments describing hidden business rules.
- Delete comments only after understanding them.
- Convert important behavior comments into tests where possible.
- Add explanation for non-obvious compatibility code.
Naming
- Improve names after behavior is protected.
- Use domain language.
- Avoid renaming everything in one giant commit.
- Keep public contract names stable unless deliberately migrated.
Documentation
- Document discovered behavior.
- Document seam ownership.
- Document migration state.
- Document temporary compatibility logic.
- Document rollback.
- Update architecture notes after structural change stabilizes.
Stopping criteria
- Do not refactor indefinitely.
- Stop when the current feature or risk is easier to change.
- Leave code better than you found it.
- Record remaining debt.
- Avoid perfectionism during production work.
Final review
- Do we know which behavior must remain unchanged?
- Is that behavior protected by tests?
- Are risky dependencies isolated behind seams?
- Are time and randomness controllable?
- Are side effects explicit?
- Is the current change mechanically small?
- Are tests green after every step?
- Is the diff easy to review?
- Are refactoring and behavior changes separated where possible?
- Is source control history reversible?
- Are external contracts preserved?
- Are database changes backward compatible?
- Are important regressions covered permanently?
- Are new extracted units easier to test?
- Is production observability sufficient?
- Is rollback possible?
- Is the next change safer and easier than before?
16. FAQ
What should I do first when refactoring legacy code?
Identify the behavior you cannot afford to break and add tests around it. Then make one small structural change and immediately verify that the behavior remains the same.
What is a characterization test?
It is a test that captures the behavior the existing system currently exhibits. Its purpose is to detect accidental changes during refactoring, even when the existing behavior may later need improvement.
What is test scaffolding?
Test scaffolding is supporting infrastructure that makes difficult code controllable or observable. Examples include fake clocks, temporary databases, HTTP stubs, deterministic random sources, filesystem sandboxes, and wrappers around static dependencies.
Should I rewrite legacy code from scratch?
Usually not as the default strategy. A rewrite can lose undocumented production behavior. Incremental replacement behind stable boundaries is often safer because old and new implementations can be compared and migrated gradually.
Can I refactor without unit tests?
Yes, but you still need some form of behavioral protection. Integration, API, snapshot, approval, or end-to-end tests can provide the first safety net. After creating seams and extracting pure logic, more focused unit tests usually become easier to add.
How small should refactoring steps be?
Small enough that when a test fails, you can easily identify the most recent structural change that could have caused it. Renaming, extracting one method, wrapping one dependency, or moving one cohesive block are good examples.
When is a legacy refactor finished?
It does not need to end in perfect architecture. A useful stopping point is when the code is safer to change, better protected by tests, has clearer boundaries, and the next required feature can be implemented with lower risk.
Key terms (quick glossary)
- Legacy code
- Existing production code that is difficult or risky to change because its behavior, dependencies, tests, or design are insufficiently clear.
- Refactoring
- Changing the internal structure of code while preserving its intended externally observable behavior.
- Characterization test
- A test that captures the behavior an existing system currently exhibits so accidental changes can be detected.
- Test scaffolding
- Supporting infrastructure added to make difficult code testable, controllable, or observable.
- Seam
- A point where a dependency or behavior can be substituted without rewriting the surrounding system.
- Fake
- A simplified working implementation of a dependency used during tests, such as an in-memory repository.
- Stub
- A test replacement configured to return predefined responses.
- Golden master
- An approved reference output used to detect behavior changes in a complex legacy system.
- Regression test
- A test that ensures a previously discovered bug or behavior change does not reappear.
- Branch by abstraction
- A migration technique that places an abstraction in front of an old implementation so a new implementation can be introduced gradually.
- Strangler pattern
- An incremental replacement strategy in which pieces of an old system are replaced one capability at a time.
- Pure function
- A function whose result depends only on its inputs and that does not produce externally observable side effects.
- Side effect
- An externally observable operation such as writing to a database, sending a network request, changing global state, or creating a file.
- Expand and contract
- A migration technique that introduces a new compatible representation before removing the old one.
- Feature flag
- A runtime switch used to enable, disable, or gradually roll out a code path without requiring another code change.
- Rollback
- Restoring a previous known-good implementation or behavior after a problematic change.
Worth reading
Recommended guides from the category.