5.1 Verifiable Refactoring in Small Steps
Refactoring improves internal code structure while keeping externally observable behavior unchanged. Each small transformation needs evidence that behavior still matches. “It is only cleanup” is a useful intention, but a poor test result.
First, define which behaviors cannot change
External behavior doesn't just include return values; it may also include:
- The type and timing of the thrown exception;
- Persisted results and transaction boundaries;
- Emitted events, their order, and their count;
- Logs, metrics, or audit records;
- API status codes, fields, and compatibility behavior;
- Performance and resource usage covered by the contract.
If the requirements themselves are changing, first separate structural changes from behavioral changes, so that each step's intent and failure reasons are clearly identifiable.
Establish Observation Points Before Refactoring Untested Code
Legacy code doesn't necessarily need immediate, fine-grained unit tests. Instead, you can start with characterization tests based on the system's current observable boundaries:
The following JUnit 5 method is a project fragment: legacyCalculator, MatchScore, Outcome, and ScoreResult belong to the legacy fixture. The expected +16/-16 describes an assumed observed win between equally rated players, not a universal rating rule or a draw. Supply the real fixture before running it.
@Test
void preservesEqualRatingWinDelta() {
ScoreResult result = legacyCalculator.calculate(
new MatchScore(1200, 1200, Outcome.WIN));
assertEquals(new ScoreResult(+16, -16), result);
}A characterization test records current behavior; it does not establish that the behavior is correct. If obvious defects are found, separately verify expectations, add a test that initially fails, then fix the behavior as a change. Don't silently fix defects alongside a pure refactoring.
When code is hard to test, start with coarse tests within existing boundaries, then use minimal seams to isolate time, filesystem, database, or network dependencies. Adding a clear interface for testing is often also a design improvement.
Use Code Smells to Locate Sources of Change
A code smell is a signal for investigation, not an automatic verdict:
| Signal | Questions to Ask | Common Directions |
|---|---|---|
| Long functions | Do they mix multiple abstraction levels or reasons for change? | Extract functions, replace temporary variables with queries |
| Long parameter list | Do parameters always appear in groups and is the order prone to error? | Parameter objects, preserve complete objects |
| Duplicate code | Do two code segments actually change for the same reason? | Extract shared concepts, not just eliminate textual repetition |
| Shotgun Surgery | Why does one business change end up scattered everywhere? | Move functions/fields, centralize strategies |
| Divergent Change | Does one module bear multiple unrelated responsibilities? | Decompose module, separate stages |
| Primitive Obsession | Do strings or numbers hide units and rules? | Value Objects, Enums, Constrained Types |
| Too many comments explaining the process | Can intent be expressed with names and structure? | Rename, extract functions; keep the "why" |
A 10-line function can still be confusing, and a 50-line clear sequence of transformations might not need to be broken apart. The goal is to reduce understanding and modification costs, not to meet a line-count threshold.
Example: Separate Calculation from Orchestration
The original method reads data, calculates rating changes, writes to the database, and sends notifications:
Both complete methods below are alternative Java 21 service fragments. matches, scores, ratingFormula, and notifications are existing collaborators; this Match has a different interface from the small state-machine object in 4.1. Assume ratingFormula is deterministic and side-effect-free, collaborators are thread-confined, and ordinary call failures are in scope. JVM termination and allocation failure are outside this exercise.
public void complete(String matchId, String winnerId) {
Match match = matches.getRequired(matchId);
if (!match.isInProgress()) throw new IllegalStateException();
if (!match.hasPlayer(winnerId)) throw new IllegalArgumentException();
String loserId = match.otherPlayer(winnerId);
int winnerScore = scores.get(winnerId);
int loserScore = scores.get(loserId);
int winnerDelta = ratingFormula.winnerDelta(winnerScore, loserScore);
int loserDelta = -winnerDelta;
scores.update(winnerId, winnerScore + winnerDelta);
scores.update(loserId, loserScore + loserDelta);
match.complete(winnerId);
matches.save(match);
notifications.send(winnerId, "score +" + winnerDelta);
notifications.send(loserId, "score " + loserDelta);
}Step one can be to extract pure computation only, without altering transaction, call order, or failure behavior:
record RatingChange(int winnerScore, int loserScore,
int winnerDelta, int loserDelta) {}
private RatingChange calculateChange(int winnerScore, int loserScore) {
int winnerDelta = ratingFormula.winnerDelta(winnerScore, loserScore);
return new RatingChange(
winnerScore + winnerDelta,
loserScore - winnerDelta,
winnerDelta,
-winnerDelta);
}Place RatingChange and calculateChange inside the same service class, then replace complete with the following method. Reads, writes, state mutation, and notifications retain their order:
public void complete(String matchId, String winnerId) {
Match match = matches.getRequired(matchId);
if (!match.isInProgress()) throw new IllegalStateException();
if (!match.hasPlayer(winnerId)) throw new IllegalArgumentException();
String loserId = match.otherPlayer(winnerId);
int winnerScore = scores.get(winnerId);
int loserScore = scores.get(loserId);
RatingChange change = calculateChange(winnerScore, loserScore);
scores.update(winnerId, change.winnerScore());
scores.update(loserId, change.loserScore());
match.complete(winnerId);
matches.save(match);
notifications.send(winnerId, "score +" + change.winnerDelta());
notifications.send(loserId, "score " + change.loserDelta());
}Run the characterization tests again. Only then consider moving the notification to post-commit events, adding transactions, or changing failure recovery; these would alter observable semantics and belong to future design changes and should not be disguised as function extraction.
Compare more than the final score. With a fixture returning delta 16 for scores 1200/1200, both versions must write 1216 and 1184, complete and save the match, then send the same two messages. Inject failures at these boundaries:
| Failure point | Behavior both versions preserve |
|---|---|
| Match not in progress / winner not a participant | Reject before reading or writing scores |
| Second score write fails | First write has occurred; no match completion or notifications |
| First notification fails | Both score writes and match save have occurred; second notification is not called |
These partial outcomes may expose a design defect. Preserve them during extraction, then change the transaction/recovery contract in a separate step. Java int arithmetic also keeps its existing wraparound behavior; silently adding Math.addExact would introduce a new exception path.
Precondition Checks for Common Refactoring Techniques
Extract function
Use when a code segment has a distinct intent. The name should explain "why" it's done, and parameters and return values should reveal data dependencies. If extracting a function requires twelve or more parameters, first review its responsibilities and data ownership.
Introduce parameter object
When parameters travel together and form a domain concept, group them in a type. This Java 21 declaration requires existing TournamentId and PlayerId value types, plus the Instant import:
import java.time.Instant;
public record RegistrationCommand(
TournamentId tournamentId,
PlayerId playerId,
Instant requestedAt) {}Don't create a semantically meaningless Params bag that just stuffs all values in to shorten the signature.
Move Function
If a function frequently reads data from another object, it might belong to that object or its domain service. After refactoring, still check dependency direction: putting database access, HTTP calls, and domain rules directly into an entity for "data proximity" will just worsen the design.
Use polymorphism instead of conditionals
A strategy object is worth introducing only when the branch represents a stable family of behaviors that needs extension. A short, closed, and clear switch is often easier to maintain than five classes each containing just one line of code.
Safe Loop
Keep it buildable and runnable at every step:
- Identify behaviors that must remain unchanged and current verification methods;
- Get the tests passing first;
- Make a named structural transformation;
- Run relevant tests and static analysis;
- Check the diff to ensure no behavioral changes have been introduced;
- Save a change that can be independently rolled back;
- Repeat the next step.
IDE refactoring tools that automatically rename, move, and inline code understand syntax and references and are generally safer than global search-and-replace; they still can't determine whether reflection, configuration, serialization names, or external APIs are affected.
Large-Scale Replacement: Progressive Migration via Abstraction
When swapping out database clients, message brokers, or third-party vendors, you don’t need to maintain a large, long-term branch:
Caller -> Application-owned Port -> Old Implementation
-> New ImplementationRoute callers through the application-owned abstraction, add the new implementation, and validate it through configuration or traffic shifts. Remove the old implementation and temporary compatibility layer after migration. This "Branch by Abstraction" approach keeps the system integrable and deployable throughout the migration process.
Dual writing or shadow reading introduces additional consistency and cost risks; you must define strategies for comparison, privacy, failures, and rollback, rather than toggle a switch alone.
When to Stop
When the code is already safe enough to handle the current changes, further abstraction is just guessing at the future. Signals to stop include:
- The target change has been localized;
- Names and boundaries can convey intent;
- Tests can quickly identify regressions;
- The new abstraction lacks a clearly defined second use case;
- The next step will expand the scope without reducing known risks.
The next section uses metrics, static analysis, and code reviews to help assess code quality, but won't treat any single number as code quality itself.