Refactoring in Practice: Safe, Incremental Java Code Improvement Guide
Refactoring is misunderstood more often than it is practised well. Many teams conflate it with rewriting, others apply it without tests and introduce regressions, and some dedicate full "refactoring sprints" that ship nothing visible to stakeholders. This guide cuts through the confusion with a disciplined, test-protected approach to incremental code improvement — the kind that works in real Spring Boot production services under real deadline pressure.
TL;DR
Refactoring is not rewriting. It is a disciplined, test-protected process of improving code structure without changing observable behaviour. The golden rule: never refactor and change behaviour at the same time. Write tests first, then refactor under their protection.
Table of Contents
- Refactoring vs Rewriting: A Critical Distinction
- The Safety Net: Writing Tests Before Refactoring
- When to Refactor (and When to Ship Instead)
- The Boy Scout Rule in Spring Boot Projects
- Core Technique: Extract Method
- Replace Magic Numbers, Introduce Parameter Object, Inline Temp
- Replace Conditional with Polymorphism
- Refactoring Legacy Spring Boot Services: Step-by-Step
- Refactoring Under Time Pressure
- Common Refactoring Mistakes
- Interview Insights & FAQ
1. Refactoring vs Rewriting: A Critical Distinction
Rewriting replaces existing code with new code that is presumed to be better. Refactoring improves existing code in small, verifiable steps that never change its observable behaviour. These are fundamentally different activities with very different risk profiles.
The cost of rewriting is consistently underestimated. When you rewrite, you lose the institutional knowledge embedded in the existing code — the edge cases discovered over years of production incidents, the "strange" conditional that handles a real but undocumented business rule, the performance optimisation added after a production outage. Joel Spolsky famously identified big rewrites as one of the worst strategic mistakes a software company can make.
OrderService "the right way" from scratch. On release day, they discovered 37 regression bugs — edge cases the original code handled correctly through years of accumulated patches. The refactoring alternative: 3 incremental sprints targeting the three worst smell clusters, zero regressions, no rewrite, continuous delivery throughout.
The refactoring mindset is fundamentally incremental. Each step is a small, independently verifiable improvement. At any point during refactoring, the tests are green and the code is deployable. During a rewrite, neither is true for months. This is why continuous refactoring is a key pillar of high-performing delivery teams — it keeps the codebase healthy without ever stopping the delivery machine.
2. The Safety Net: Writing Tests Before Refactoring
The single most common refactoring mistake is starting without a test suite. Tests are the safety net that proves your structural changes have not altered the code's behaviour. Without them, you are operating blind — any regression is invisible until a user reports it in production.
For legacy code with no tests, Michael Feathers' concept of characterization tests (also called approval tests) is the answer. A characterization test captures what the code currently does — not what it should do — and locks that behaviour so any deviation is immediately caught. You are not testing correctness; you are creating a snapshot of existing behaviour as a refactoring floor.
// BAD: Refactoring without any safety net
// "Just refactor it and we'll see if it works in QA"
// — results in regressions found 2 weeks later with no trace
// GOOD: Characterization test written BEFORE touching the code
@SpringBootTest
class OrderServiceCharacterizationTest {
@Autowired
private OrderService orderService;
@MockBean
private OrderRepository orderRepository;
@MockBean
private InventoryRepository inventoryRepository;
@MockBean
private EmailService emailService;
@Test
@DisplayName("Characterization: placeOrder returns OrderResponse with correct total")
void characterize_placeOrder_standardItems() {
// Capture CURRENT behaviour — do not judge it, just record it
var item = new OrderItem(1L, 2, new BigDecimal("49.99"));
var request = new OrderRequest(42L, List.of(item));
when(inventoryRepository.isAvailable(1L, 2)).thenReturn(true);
when(orderRepository.save(any())).thenAnswer(inv -> {
Order o = inv.getArgument(0);
o.setId(100L);
return o;
});
var response = orderService.placeOrder(request);
assertThat(response.orderId()).isEqualTo(100L);
assertThat(response.total()).isEqualByComparingTo("107.98"); // incl. 8% tax
// This test now protects the total-calculation logic during refactoring
}
}
3. When to Refactor (and When to Ship Instead)
Every engineer faces the tension between shipping new features and improving the code they need to touch to ship them. The decision framework below gives a clear answer for the most common scenarios.
| Situation | Action |
|---|---|
| Pre-feature: code in your change path is messy | Refactor first, then add the feature. Two clean PRs. |
| Post-bug: bug was caused by tangled code | Fix the bug, then refactor the tangled section. |
| Pure refactoring sprint | Avoid — teams lose product confidence when nothing ships. |
| Throwaway prototype | Skip refactoring entirely — it will be rewritten. |
| Performance-critical hot path | Profile first with JFR/JMH, then refactor after measuring. |
| Code review reveals smell | Create a follow-up ticket; fix in the next sprint if critical. |
The most productive refactoring strategy is the "two-PR" approach: the first PR is a pure structural change (refactoring only, no behaviour change), the second PR adds the feature. Reviewers can verify each PR independently, and the structural PR is trivially safe to merge because all tests pass and no behaviour changed.
4. The Boy Scout Rule in Spring Boot Projects
Robert Martin's Boy Scout Rule states: "Leave the campground cleaner than you found it." Applied to code: every time you touch a class, make it marginally better than when you opened it. Not a complete refactoring — just 1-2 small improvements within the scope of your current change.
In practice, this means: if you add a method to OrderService as part of a feature PR, also rename the three misleading variables you notice, remove the commented-out block from 2022, and extract the 40-line validation block into a private method. The entire cleanup takes 10 minutes and keeps the class from drifting toward God Class status.
Boy Scout Rule implementation in sprint planning:
Sprint capacity: 100 story points
Feature work: 80 story points (80%)
Boy Scout budget: 20 story points (20%)
The 20% is not a "refactoring sprint" — it is distributed across
all feature PRs as micro-improvements in the classes being touched.
Result: codebase health improves every sprint without dedicated
refactoring work that ships nothing visible to stakeholders.
5. Core Refactoring Technique: Extract Method
Extract Method is the most frequently applied refactoring technique. Apply it when a method exceeds 20 lines, contains complex conditional blocks that need explanation, or mixes multiple levels of abstraction (orchestration alongside low-level business logic) in the same method body.
The rule for a well-extracted method: a reader should be able to understand its full purpose in 30 seconds from its name alone, without reading its body. If the name requires a comment to explain what it does, the name is wrong.
// BAD: 45-line processOrder() mixes orchestration, validation,
// pricing, persistence, and notification in one method body
@Service
public class OrderService {
public OrderConfirmation processOrder(OrderRequest request) {
// === VALIDATION (lines 1-12) ===
if (request == null) throw new IllegalArgumentException("Request cannot be null");
if (request.getCustomerId() == null) throw new ValidationException("Customer ID required");
Customer customer = customerRepo.findById(request.getCustomerId())
.orElseThrow(() -> new NotFoundException("Customer not found"));
if (!customer.isActive()) throw new BusinessException("Customer account is inactive");
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new ValidationException("Order must contain at least one item");
}
// === INVENTORY CHECK (lines 13-20) ===
for (OrderItem item : request.getItems()) {
boolean available = inventoryRepo.isAvailable(item.getProductId(), item.getQuantity());
if (!available) throw new InsufficientInventoryException(item.getProductId());
}
// === PRICING (lines 21-32) ===
BigDecimal subtotal = BigDecimal.ZERO;
for (OrderItem item : request.getItems()) {
Product product = productRepo.findById(item.getProductId()).orElseThrow();
subtotal = subtotal.add(product.getPrice().multiply(new BigDecimal(item.getQuantity())));
}
Discount discount = discountService.findApplicableDiscount(customer, request.getItems());
BigDecimal discountAmount = subtotal.multiply(discount.getRate());
BigDecimal total = subtotal.subtract(discountAmount).multiply(new BigDecimal("1.08"));
// === PERSISTENCE (lines 33-38) ===
Order order = new Order(customer.getId(), request.getItems(), total, OrderStatus.PENDING);
order = orderRepo.save(order);
// === NOTIFICATION (lines 39-45) ===
emailService.sendOrderConfirmation(customer.getEmail(), order);
return new OrderConfirmation(order.getId(), total, order.getCreatedAt());
}
}
// GOOD: Extract Method — each step is named, readable, independently testable
@Service
public class OrderService {
public OrderConfirmation processOrder(OrderRequest request) {
Customer customer = validateAndLoadCustomer(request);
verifyInventoryAvailability(request.getItems());
BigDecimal total = calculateOrderTotal(customer, request.getItems());
Order order = persistOrder(customer, request.getItems(), total);
notifyCustomerOfConfirmation(customer, order);
return new OrderConfirmation(order.getId(), total, order.getCreatedAt());
}
private Customer validateAndLoadCustomer(OrderRequest request) {
if (request == null) throw new IllegalArgumentException("Request cannot be null");
if (request.getCustomerId() == null) throw new ValidationException("Customer ID required");
if (request.getItems() == null || request.getItems().isEmpty()) {
throw new ValidationException("Order must contain at least one item");
}
Customer customer = customerRepo.findById(request.getCustomerId())
.orElseThrow(() -> new NotFoundException("Customer not found"));
if (!customer.isActive()) throw new BusinessException("Customer account is inactive");
return customer;
}
private void verifyInventoryAvailability(List<OrderItem> items) {
items.forEach(item -> {
if (!inventoryRepo.isAvailable(item.getProductId(), item.getQuantity())) {
throw new InsufficientInventoryException(item.getProductId());
}
});
}
private BigDecimal calculateOrderTotal(Customer customer, List<OrderItem> items) {
BigDecimal subtotal = items.stream()
.map(item -> productRepo.findById(item.getProductId()).orElseThrow()
.getPrice().multiply(new BigDecimal(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
Discount discount = discountService.findApplicableDiscount(customer, items);
return subtotal.subtract(subtotal.multiply(discount.getRate()))
.multiply(new BigDecimal("1.08"));
}
private Order persistOrder(Customer customer, List<OrderItem> items, BigDecimal total) {
return orderRepo.save(new Order(customer.getId(), items, total, OrderStatus.PENDING));
}
private void notifyCustomerOfConfirmation(Customer customer, Order order) {
emailService.sendOrderConfirmation(customer.getEmail(), order);
}
}
6. Replace Magic Numbers, Introduce Parameter Object, Inline Temp
Replace Magic Numbers with Named Constants
// BAD: magic numbers — reader has no idea what 3, 90, or 0.08 mean
if (order.getStatus() == 3) {
throw new BusinessException("Cannot modify confirmed order");
}
if (daysSinceCreated > 90) {
applyArchivePolicy(order);
}
BigDecimal tax = subtotal.multiply(new BigDecimal("0.08"));
// GOOD: named constants — self-documenting
public enum OrderStatus {
PENDING(1), PROCESSING(2), CONFIRMED(3), SHIPPED(4), DELIVERED(5);
private final int code;
OrderStatus(int code) { this.code = code; }
}
private static final int ARCHIVE_THRESHOLD_DAYS = 90;
private static final BigDecimal TAX_RATE = new BigDecimal("0.08");
if (order.getStatus() == OrderStatus.CONFIRMED) {
throw new BusinessException("Cannot modify confirmed order");
}
if (daysSinceCreated > ARCHIVE_THRESHOLD_DAYS) {
applyArchivePolicy(order);
}
BigDecimal tax = subtotal.multiply(TAX_RATE);
Introduce Parameter Object
// BAD: 6-parameter method — callers must know the exact order
public Report generateReport(LocalDate startDate, LocalDate endDate,
Long userId, String format,
boolean includeDeleted, int maxRows) { }
// GOOD: Introduce Parameter Object using a Java record
public record ReportRequest(
LocalDate startDate,
LocalDate endDate,
Long userId,
String format,
boolean includeDeleted,
int maxRows
) {
public ReportRequest {
Objects.requireNonNull(startDate, "startDate is required");
Objects.requireNonNull(endDate, "endDate is required");
if (endDate.isBefore(startDate)) throw new IllegalArgumentException("endDate must be after startDate");
}
}
public Report generateReport(ReportRequest request) { }
Inline Temp vs Keep Temp
Inline a temporary variable when it simply re-names an expression without adding clarity. Keep it when the variable name explains the purpose of a complex calculation better than the expression does. The rule: if the variable name adds information beyond what the expression communicates, keep it; otherwise inline it.
// Inline when the temp adds no information:
// BAD:
double basePrice = order.getBasePrice(); // temp adds nothing
return basePrice * 1.08;
// GOOD (inlined):
return order.getBasePrice() * 1.08;
// Keep when the temp explains a complex expression:
// GOOD (kept — name adds meaning):
BigDecimal discountedSubtotal = subtotal.subtract(subtotal.multiply(discount.getRate()));
BigDecimal totalWithTax = discountedSubtotal.multiply(TAX_RATE.add(BigDecimal.ONE));
return totalWithTax;
7. Replace Conditional with Polymorphism
This is arguably the most impactful single refactoring available to Java developers. Long if-else or switch chains on a type discriminator are a maintenance time-bomb: every new type requires finding and updating every chain in the codebase. Polymorphism eliminates this by moving type-specific logic into the type itself.
// BAD: if-else chain on notification type — adding PUSH requires editing this method
@Service
public class NotificationService {
public void send(String type, String recipient, String message) {
if ("EMAIL".equals(type)) {
emailClient.send(recipient, message);
} else if ("SMS".equals(type)) {
smsClient.sendText(recipient, message);
} else if ("PUSH".equals(type)) {
pushClient.sendPush(recipient, message);
} else {
throw new IllegalArgumentException("Unknown notification type: " + type);
}
}
}
// GOOD: NotificationStrategy interface + Spring-discovered @Component implementations
public interface NotificationStrategy {
boolean supports(String type);
void send(String recipient, String message);
}
@Component
public class EmailNotificationStrategy implements NotificationStrategy {
private final EmailClient emailClient;
public EmailNotificationStrategy(EmailClient emailClient) {
this.emailClient = emailClient;
}
@Override public boolean supports(String type) { return "EMAIL".equals(type); }
@Override
public void send(String recipient, String message) {
emailClient.send(recipient, message);
}
}
@Component
public class SmsNotificationStrategy implements NotificationStrategy {
private final SmsClient smsClient;
public SmsNotificationStrategy(SmsClient smsClient) {
this.smsClient = smsClient;
}
@Override public boolean supports(String type) { return "SMS".equals(type); }
@Override
public void send(String recipient, String message) {
smsClient.sendText(recipient, message);
}
}
@Component
public class PushNotificationStrategy implements NotificationStrategy {
private final PushClient pushClient;
public PushNotificationStrategy(PushClient pushClient) {
this.pushClient = pushClient;
}
@Override public boolean supports(String type) { return "PUSH".equals(type); }
@Override
public void send(String recipient, String message) {
pushClient.sendPush(recipient, message);
}
}
// Dispatcher — Spring discovers all strategies automatically via List injection
@Service
public class NotificationService {
private final List<NotificationStrategy> strategies;
public NotificationService(List<NotificationStrategy> strategies) {
this.strategies = strategies;
}
public void send(String type, String recipient, String message) {
strategies.stream()
.filter(s -> s.supports(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No strategy for: " + type))
.send(recipient, message);
}
}
Adding a WhatsApp notification is now a new @Component class with zero changes to NotificationService. This is the OCP principle realised through refactoring from an existing if-else chain.
8. Refactoring Legacy Spring Boot Services: Step-by-Step
The following walkthrough applies the complete refactoring workflow to a realistic 300-line OrderService. This is the systematic process, not the ad-hoc "clean it up as you go" approach that often produces partial improvement and new smells.
Step 1: Write characterization tests
Target: 80%+ line coverage before any structural change
Tools: JUnit 5 + Mockito + JaCoCo coverage report
Step 2: Run automated smell detection
Tools: SonarQube, IntelliJ Code Inspections, PMD
Identify: Long methods, high cyclomatic complexity, duplicates
Step 3: Extract small private methods for readability
Goal: reduce public method length to < 15 lines each
Each private method = one clear, named responsibility
Step 4: Extract separate service classes
PaymentValidationService — validation logic
InventoryReservationService — inventory checks
OrderNotificationService — email/SMS dispatch
OrderPricingService — discount + tax calculation
Step 5: Introduce interfaces for injected services
OrderService now depends on IOrderNotificationService,
not on the concrete EmailService class.
Step 6: Run all tests — 100% green
If any test fails, revert the last step.
Never accumulate failing tests during refactoring.
// Step 4 result — OrderService after extraction (clean, testable)
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentValidationService paymentValidationService;
private final InventoryReservationService inventoryReservationService;
private final OrderPricingService orderPricingService;
private final OrderNotificationService orderNotificationService;
// Constructor injection (all interfaces, not concretions)
public OrderService(OrderRepository orderRepository,
PaymentValidationService paymentValidationService,
InventoryReservationService inventoryReservationService,
OrderPricingService orderPricingService,
OrderNotificationService orderNotificationService) {
this.orderRepository = orderRepository;
this.paymentValidationService = paymentValidationService;
this.inventoryReservationService = inventoryReservationService;
this.orderPricingService = orderPricingService;
this.orderNotificationService = orderNotificationService;
}
public OrderConfirmation placeOrder(OrderRequest request) {
paymentValidationService.validate(request);
inventoryReservationService.reserve(request.getItems());
BigDecimal total = orderPricingService.calculateTotal(request);
Order saved = orderRepository.save(Order.from(request, total));
orderNotificationService.notifyPlaced(saved);
return OrderConfirmation.from(saved);
}
}
// Test now requires only 4 focused mocks — not 12 tangled ones
9. Refactoring Under Time Pressure
Real development happens under real deadlines. The following micro-refactors take under 5 minutes each and are safe to include in any PR without dedicated review discussion.
- Rename with IDE tools (Shift+F6): rename a misleading variable or method. Instant, complete, zero risk.
- Extract method (Ctrl+Alt+M): select a 10-line block, hit extract method, give it a clear name. Under 2 minutes.
- Replace magic number: introduce a named constant for any literal that requires a comment to explain. Under 1 minute.
- Remove dead code: delete a commented-out block or unused private method. Under 30 seconds.
- Inline a confusing temp variable: remove the temp and use the expression directly where it reads more clearly.
Safe bets under time pressure — refactors that cannot break behaviour: pure rename, extract method with no side effects, replace magic literal with named constant, remove dead code. Unsafe under time pressure: changing method signatures, restructuring inheritance hierarchies, changing database access patterns, modifying transactional boundaries. These require a dedicated PR and dedicated test validation.
10. Common Refactoring Mistakes
- Changing behaviour during refactoring: the most dangerous mistake. If you discover a bug while refactoring, create a ticket and fix it in a separate PR. Mixing structural and behavioural changes makes regressions untraceable and defeats the purpose of characterization tests.
- Big-bang refactoring (50 files in one PR): impossible to review, high risk to merge, catastrophic if it needs a revert. Break all refactoring into a sequence of independent, deployable PRs. Each PR touches one class or one cluster of related classes.
- Refactoring without tests: without tests, every structural change is a leap of faith. You will discover regressions in QA or production, not in your IDE. No exceptions: write characterization tests first.
- Unit-testing implementation details: testing private method names, testing that a specific private method was called — these are brittle tests that break on refactoring. Test observable behaviour: inputs, outputs, and side effects visible to callers.
- Performance regressions from wrapping primitives: introducing value objects and parameter objects for everything adds allocation pressure in high-throughput paths. Measure with JMH benchmarks before wrapping primitives in performance-critical loops.
11. Interview Insights & FAQ
Q: What is the difference between refactoring and rewriting?
A: Refactoring is a behaviour-preserving transformation: the external behaviour of the code is unchanged, only its internal structure improves. Rewriting replaces code wholesale with new code. Refactoring is lower risk because each step is verified by tests and the system remains deployable throughout. Rewriting is higher risk because the system is non-deployable for the duration and institutional knowledge is lost.
Q: How do you refactor without breaking existing functionality?
A: Write characterization tests before touching the code. Use IDE refactoring tools (Rename, Extract Method) that guarantee reference-safe transformations. Make one small change at a time and run the full test suite after each change. Never accumulate multiple unfixed failures.
Q: When is Extract Method not appropriate?
A: Extract Method is not appropriate when it would create a method with side effects that are not obvious from its name, when it would require passing too many parameters (indicating the sub-task does not have true cohesion with the extraction context), or when the extracted code is so tightly tied to the local context that it reduces readability rather than improving it.
What is the safest first refactoring step for legacy code?
Write characterization tests. Before touching a single line of implementation, capture the current behaviour in tests. This creates the safety net that allows all subsequent structural changes to be verified instantly. Only then begin the smallest possible structural improvement — usually Extract Method on the longest method in the class.
How often should you refactor?
Continuously, not periodically. Apply the Boy Scout Rule on every PR: leave any class you touch slightly cleaner than you found it. Reserve 20% of sprint capacity for micro-improvements in code your team is already touching for features. Avoid dedicated refactoring sprints — they demoralise teams and erode product confidence.
Is refactoring the same as technical debt repayment?
Technical debt is broader than code structure: it includes outdated dependencies, missing tests, inadequate documentation, and architectural decisions that have become liabilities. Refactoring addresses structural technical debt. It should be complemented by dependency upgrade sprints, test coverage campaigns, and architectural review sessions to address the full debt landscape.
Can refactoring improve performance?
Rarely directly, but often indirectly. Well-structured code is easier to profile and optimise. A 1,200-line God Class method is impossible to profile meaningfully; five 50-line extracted methods with clear boundaries show their hotspots immediately in JFR flight recordings. Always profile before optimising, and always measure after optimising.
Key Takeaways:
- The golden rule: never change behaviour and structure in the same commit. Ever.
- Write characterization tests before touching any legacy code — they are your refactoring safety net.
- Extract Method is the single most useful refactoring — apply it whenever a method exceeds 20 lines or mixes abstraction levels.
- Replace Conditional with Polymorphism eliminates entire categories of Shotgun Surgery by co-locating type-specific logic.
- Apply the Boy Scout Rule on every PR — leave the code slightly cleaner than you found it without a dedicated refactoring sprint.
- Use IDE refactoring tools (not manual find-replace) for renames and extractions to guarantee completeness.
Leave a Comment
Related Posts
Complete Java Refactoring Techniques: 20+ Patterns to Transform Legacy Code in 2026
The exhaustive catalogue of refactoring patterns for Java developers with production examples.
Refactoring to Patterns in Java
How to safely evolve messy legacy code toward well-known design patterns.
Code Smells & Refactoring in Java
Identify and eliminate the most dangerous code smells with systematic refactoring.
SOLID Principles in Java: Real-World Refactoring Patterns
Apply SOLID principles as the target state for your refactoring work in Spring Boot.