- Chapter
- Chapter 1 — Refactoring Fundamentals
- Prerequisites
- Topics 1.1–1.2
- You will be able to
- Build a test safety net that verifies behaviour rather than implementation, refactor in small steps that keep the build green, rely on automated IDE refactorings where possible, verify continuously from IDE to CI, and recover quickly when a step goes wrong
- Covers
- Tests; Small changes; Continuous verification
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — A Safe Refactoring Workflow
- 4A. Deep Dive — Where Automated Refactorings Are Not Enough
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
Refactoring safety is the set of practices that ensure refactorings really are behaviour-preserving: a test safety net that detects behaviour changes, small changes that are easy to verify and undo, and continuous verification that runs checks after every step, at every stage from the IDE to CI.
| Practice | Protects against |
|---|---|
| Tests (behaviour-focused) | Accidental behaviour changes |
| Small changes | Hard-to-locate errors, long broken periods, merge conflicts |
| Automated refactorings | Mechanical mistakes (missed references, wrong scopes) |
| Continuous verification | Late discovery of problems |
| Version control discipline | Inability to undo quickly |
2Why It Exists
Refactoring changes code that works. Without safety, every change risks introducing bugs that users find first — and teams quickly learn to "not touch working code", which is how systems rot. The safety practices make refactoring routine: developers can change structure confidently because any behaviour change is detected within seconds or minutes, close to its cause.
3Core Concepts
3.1 Tests

Tests suitable for refactoring verify behaviour at stable boundaries, not implementation details.
// Brittle: tied to implementation → breaks on every refactoring
@Test
void callsRepositoryAndCalculatorInOrder() {
service.placeOrder(cmd);
InOrder o = inOrder(repo, calculator);
o.verify(calculator).calculate(any());
o.verify(repo).save(any());
}
// Robust: verifies observable behaviour → survives restructuring
@Test
void placedOrderHasDiscountedTotalAndIsStored() {
OrderId id = service.placeOrder(cmdFor(vipCustomer(), line("A", 2, "100.00")));
assertThat(orders.findById(id)).get()
.extracting(Order::total)
.isEqualTo(Money.inr("180.00"));
}| Test characteristic | Why it matters for refactoring |
|---|---|
| Tests public behaviour, not private methods | Private structure can change freely |
| Uses real collaborators where cheap (value objects, policies) | Fewer mocks to rewrite when responsibilities move |
| Fast (unit tests in ms) | Can run after every small step |
| Deterministic (fixed clocks, no network) | Failures mean real changes |
| Clear failure messages | Locate the cause quickly |
Coverage is necessary but not sufficient. Line coverage shows which code ran, not whether assertions would catch a change. Mutation testing (PIT) introduces small changes into the code and checks that tests fail; surviving mutants reveal weak spots in the safety net.
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<configuration>
<targetClasses><param>com.shop.pricing.*</param></targetClasses>
<mutationThreshold>80</mutationThreshold>
</configuration>
</plugin>When no tests exist, write characterisation tests first — tests that capture current behaviour, right or wrong (Chapter 4, Topic 4.2).
3.2 Small changes

Each step should be small enough to verify immediately and cheap to undo.
| Step size principle | Practice |
|---|---|
| One refactoring at a time | Extract Method, run tests; Move Method, run tests |
| Keep it compiling | Never leave the code in a non-compiling state for long |
| Parallel change (expand–contract) | Add new structure, migrate callers, remove old structure |
| Commit often | Every green state is a potential commit |
| Undo, don't debug | If a step breaks tests unexpectedly, revert and take a smaller step |
Parallel change (expand–migrate–contract) keeps even signature changes safe:
// Expand: add new method alongside old one
public Money price(Product p, PricingContext ctx) { … new implementation … }
@Deprecated(forRemoval = true)
public Money price(Product p, Customer c, LocalDate d) { // old signature
return price(p, new PricingContext(c, d)); // delegates
}
// Migrate: move callers to the new method one by one (tests green after each)
// Contract: delete the deprecated method when no callers remain3.3 Continuous verification

Verification happens at several speeds:
| Stage | Check | Feedback time |
|---|---|---|
| IDE | Compiler, inspections, automated refactoring previews | Instant |
| Local test run | Unit tests of the module (continuous testing plugins) | Seconds |
| Pre-commit / pre-push | Module tests, formatting, static analysis | < 1–2 minutes |
| CI pipeline | All tests, integration tests, ArchUnit, Sonar quality gate, mutation tests (nightly) | Minutes |
| Deployment | Canary/feature flags, monitoring | Minutes–hours |
The faster the feedback, the smaller the steps can be — and the safer refactoring becomes. Teams should treat test suite speed as a first-class quality attribute.
4How It Works Internally — A Safe Refactoring Workflow
1. Pull latest trunk; run tests (start from green).
2. If the area lacks tests, add behaviour tests or characterisation tests.
3. Apply one refactoring — prefer IDE automation (preview the change).
4. Run fast tests; if red and the cause isn't obvious within a minute, revert.
5. Commit with a refactor: message; push frequently (trunk-based or short-lived branches).
6. CI runs the full suite and static checks.
7. Repeat until the target structure is reached; then start the feature (other hat).

4ADeep Dive — Where Automated Refactorings Are Not Enough
IDE refactorings understand Java semantics but not everything that references code:
| Hidden reference | Example | Safeguard |
|---|---|---|
| Reflection | Class.forName("com.shop.LegacyPricing") | Search for strings; integration tests |
| Spring configuration | Bean names in @Qualifier("pricingService"), @ConditionalOnProperty | Context-loading tests (@SpringBootTest smoke) |
| Serialisation | Jackson field names from getters/records; Java serialization | Contract/approval tests of JSON |
| Persistence | JPA entity/field names in JPQL strings, native SQL | Repository tests with Testcontainers |
| Templates / scripts | Thymeleaf expressions, SpEL, shell scripts | Rendering tests; grep |
| External consumers | Public APIs, events, library users | Contract tests; deprecation instead of rename |
// Guard against accidental JSON contract changes during refactoring
@Test
void orderResponseJsonIsStable() throws Exception {
String json = mapper.writeValueAsString(sampleOrderResponse());
Approvals.verifyJson(json); // fails if field names/structure change
}Architecture tests protect structural rules while you refactor:
@ArchTest
static final ArchRule domainIndependentOfSpring =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..");5APIs / Features / Syntax
| Tool | Role in safety |
|---|---|
| JUnit 5, AssertJ | Behaviour tests with expressive assertions |
| ApprovalTests / snapshot tests | Pin complex outputs (JSON, reports) |
| Testcontainers | Real databases/brokers for integration tests |
| PIT (pitest) | Mutation testing — quality of the safety net |
| JaCoCo | Coverage reports to find untested areas |
| ArchUnit | Structural rules as tests |
| IntelliJ refactorings, "Safe Delete" | Semantics-aware changes |
Git (small commits, git revert, git bisect) | Quick undo and fault isolation |
6Practical Examples
6.1 Making tests refactoring-proof
A test suite of 400 tests mocking every collaborator broke 120 tests when PricingService was split into PricingPolicy and TaxPolicy. Rewriting the tests to use real value objects and policies (mocking only I/O gateways) reduced breakage in the next refactoring to 3 tests.
6.2 Using git bisect after a bad refactoring series
When a subtle bug appeared after 25 small refactoring commits, git bisect run mvn -q test -Dtest=InvoiceTotalsTest found the offending commit in five iterations — possible only because each commit was small and green.
6.3 Worked example — safety net before refactoring a pricing module
| Step | Action | Result |
|---|---|---|
| 1 | JaCoCo coverage of pricing package | 48% line coverage; branches for VIP and bulk discounts untested |
| 2 | Characterisation tests for 30 real orders (inputs from production logs, outputs recorded) | Current behaviour pinned |
| 3 | PIT mutation testing | Mutation score 52% → surviving mutants in rounding and thresholds |
| 4 | Add targeted tests for thresholds and rounding | Mutation score 86% |
| 5 | Refactor in 18 small commits (Extract Method, Extract Class, Replace Conditional) | All tests green after each |
| 6 | Deploy behind no flags (behaviour unchanged) | No incidents |
@ParameterizedTest
@CsvFileSource(resources = "/pricing/characterisation.csv", numLinesToSkip = 1)
void priceMatchesRecordedBehaviour(String customerType, String sku,
int qty, String expectedTotal) {
assertThat(pricing.total(order(customerType, sku, qty)).amount())
.isEqualByComparingTo(expectedTotal);
}6.4 Worked example — expand–contract across modules
Renaming CustomerService.find(Long) to CustomerDirectory.byId(CustomerId) used by 40 call sites in 6 modules:
| Phase | Change | Deployable? |
|---|---|---|
| Expand | Add CustomerDirectory.byId(CustomerId); old method delegates to it | Yes |
| Migrate | Update call sites module by module (6 small PRs) | Yes after each |
| Contract | Remove old method (IntelliJ "Safe Delete" confirms no usages) | Yes |
No big-bang PR, no long-lived branch, no broken build — the whole change took two days of interleaved work.
6.5 Worked example — continuous verification pipeline
# .github/workflows/ci.yml (excerpt)
jobs:
build:
steps:
- run: ./mvnw -q verify # unit + integration tests, ArchUnit
- run: ./mvnw -q sonar:sonar -Dsonar.qualitygate.wait=true
mutation:
if: github.event_name == 'schedule' # nightly
steps:
- run: ./mvnw -q -pl pricing pitest:mutationCoverage| Gate | Threshold |
|---|---|
| Tests | All green |
| Sonar quality gate on new code | No new critical issues; duplication < 3% |
| ArchUnit | No violations |
| Mutation score (nightly, critical modules) | ≥ 80% |
6.6 Recovering when a refactoring step goes wrong
Even with small steps, something occasionally breaks. The rule is revert, don't debug — then retry with a smaller step.
| Situation | Response |
|---|---|
| Tests fail right after a step and the cause is not obvious in a minute | git checkout . / IDE local history → back to green; redo in smaller pieces |
| A failure appears several commits later | git bisect to find the step; revert that commit |
| Refactoring deployed and an incident occurs | Roll back the deployment (refactoring commits are isolated); analyse offline |
| Behaviour difference discovered that tests didn't catch | Add a test that reproduces it first, then fix — the safety net improves |
# find the refactoring commit that broke invoice totals
git bisect start HEAD v2.14.0
git bisect run ./mvnw -q -pl billing test -Dtest=InvoiceTotalsTest
git revert <bad-commit>| Enabler | Why it makes recovery cheap |
|---|---|
| Small, single-purpose commits | Revert affects only one step |
| Separate refactoring and feature commits | Reverting a refactoring doesn't remove features |
| Fast tests | Bisect iterations take seconds |
| IDE local history | Undo beyond the last commit |
Teams that practise small steps rarely need these tools — but knowing that recovery is cheap is exactly what gives developers the confidence to refactor.
7Common Problems & Mistakes
- Refactoring without tests — "it looked mechanical".
- Tests coupled to implementation (over-mocking, private method tests).
- Big steps — hours of broken code, then debugging.
- Slow test suites — developers stop running them.
- Trusting coverage as proof of a good safety net.
- Ignoring hidden references (reflection, JSON, SQL, Spring config).
8Performance & Production Considerations
| Aspect | Guidance |
|---|---|
| Test suite speed | Keep unit tests < 1–2 minutes per module; parallelise |
| Integration tests | Reuse Testcontainers; run per module |
| Mutation tests | Targeted modules, nightly |
| Production verification | Monitor error rates after deploying refactorings; canary where risk is higher |
The safety net is only useful if it is fast enough to run constantly, so manage its performance like production code. Run isolated JUnit 5 tests in parallel, split unit and integration suites between Maven Surefire and Failsafe, and reuse Testcontainers instances through singleton containers instead of starting a database for every test class. Use Gradle or Maven build caching and test only affected modules on pull requests, with the full suite on trunk. Track flaky tests and fix or quarantine them quickly, because a flaky suite teaches developers to ignore red builds. Limit PIT to changed code with its incremental analysis. After deploying refactorings, compare error rates and p99 latency with the previous release to catch behaviour changes the tests missed.
9Architecture & Design Considerations
- Design for testability (dependency injection, pure domain logic) to enable safe refactoring.
- Treat fast feedback (build and test time) as an architectural concern.
- Use architecture tests to lock in structural decisions during refactoring.
- Prefer trunk-based development with small commits over long refactoring branches.
10Interview Questions & Answers
What is the essential precondition for refactoring?
A solid, fast suite of tests that detects behaviour changes, because refactoring is by definition a change to structure that must not change behaviour, and tests are the only practical way to confirm that after every step. Fowler calls this the essential precondition. Solid means the tests check observable behaviour at stable boundaries with meaningful assertions, not just that code ran; fast means you can run them after each small change. When the area has no such tests, the first job is to write characterisation tests that capture what the code currently does, right or wrong. Without that safety net every refactoring is a gamble, and teams soon stop refactoring.
Why take small steps?
Because small steps keep the cost of a mistake tiny. If you make one refactoring, such as Extract Method or Move Method, and then run the tests, any failure is caused by that one change, so you can locate it immediately or simply revert and try a smaller step instead of debugging. Small steps also keep the code compiling and the build green, so you can commit, integrate and even deploy at almost any moment, avoiding long-lived branches and painful merges. A series of small green commits also makes later fault-finding with git bisect fast. Large leaps leave hours of broken code and failures with many possible causes.
What makes a test refactoring-friendly?
It verifies observable behaviour at a stable boundary, not how the code is arranged internally. In practice that means calling a public API or use case and asserting on results or persisted state, for example that a placed VIP order has a discounted total and is stored, rather than verifying with Mockito that a calculator was called before a repository. It uses real collaborators where they are cheap, such as value objects and policies, and mocks or fakes only I/O gateways, so moving responsibilities between classes does not break it. It is fast, deterministic thanks to fixed clocks and no network, and its failure message points to the cause. Such tests survive restructuring.
What is parallel change (expand–contract)?
Parallel change, also called expand-migrate-contract, lets you change an interface without a breaking big-bang step. In the expand phase you add the new structure alongside the old one, for example a new price(Product, PricingContext) method, make the old method delegate to it and mark it @Deprecated(forRemoval = true). In the migrate phase you move callers over one by one, keeping tests green and the system deployable after each step, possibly across several pull requests and modules. In the contract phase, once no callers remain, which IntelliJ's Safe Delete can confirm, you remove the old method. The same technique works for database columns, REST fields and event schemas.
Why is coverage not enough to trust a safety net?
Coverage only shows which lines or branches were executed by tests, not whether any assertion would fail if that code behaved differently. A test that calls a pricing method and asserts nothing gives full coverage and no protection. Mutation testing measures what you actually care about: tools such as PIT make small changes to the code, like turning > into >= or removing a method call, and run the tests; a mutant that survives reveals a behaviour change the safety net would miss. Use coverage to find untested areas, and the mutation score on critical modules, for example nightly with an 80 percent threshold, to judge the strength of the tests.
What references can automated IDE refactorings miss?
IDE refactorings understand Java's type system, so they miss anything that refers to code by string or from outside the compiled project. Typical cases are reflection such as Class.forName, Spring bean names in @Qualifier, property names in @ConditionalOnProperty and SpEL expressions, Jackson field names derived from getters or record components, Java serialisation, entity and field names inside JPQL strings or native SQL, Thymeleaf templates and scripts, and external consumers of public APIs, events or libraries. Renaming a record component can therefore silently change a JSON contract. Safeguards are context-loading smoke tests, approval tests of JSON, repository tests with Testcontainers, contract tests, and deprecation instead of renaming for public APIs.
How does CI support refactoring?
CI is the shared, objective verification step after each small change. Every push runs the full unit and integration test suite, including tests against real databases with Testcontainers, plus architecture tests with ArchUnit or Spring Modulith and a static-analysis quality gate such as SonarQube on new code. Slower checks like mutation testing run nightly on critical modules. Because refactorings are integrated as small commits on trunk or short-lived branches, a failure points to a recent, small change that can be reverted quickly. CI also catches what local runs miss, such as other modules affected by a rename. To support refactoring, the pipeline itself must stay fast, ideally under ten minutes.
What organisational practices make refactoring safe at scale?
Refactoring at scale is a property of the engineering system, so the practices are organisational. Architect for testability, with dependency injection and framework-free domain logic, so most behaviour can be tested quickly. Treat build and test time as an architectural concern and keep pipelines fast. Use trunk-based development with small, separate refactoring and feature commits, and avoid long-lived refactoring branches. Lock in structural decisions with architecture tests and quality gates, and run mutation testing on critical modules. Use feature flags and canary releases where risk is high, and monitor error rates after deployments. Finally, make refactoring part of every feature rather than a project that needs special approval.