Software Dev

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.

Md Sanwar Hossain April 10, 2026 16 min read Software Dev
Safe Java Refactoring in Practice

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

  1. Refactoring vs Rewriting: A Critical Distinction
  2. The Safety Net: Writing Tests Before Refactoring
  3. When to Refactor (and When to Ship Instead)
  4. The Boy Scout Rule in Spring Boot Projects
  5. Core Technique: Extract Method
  6. Replace Magic Numbers, Introduce Parameter Object, Inline Temp
  7. Replace Conditional with Polymorphism
  8. Refactoring Legacy Spring Boot Services: Step-by-Step
  9. Refactoring Under Time Pressure
  10. Common Refactoring Mistakes
  11. 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.

Real scenario: A team at a mid-sized fintech spent 6 months rewriting their 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.

Safe Java Refactoring Workflow — Test, Refactor, Green Cycle
Safe Refactoring Workflow — incremental, test-backed steps — mdsanwarhossain.me

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
    }
}
Coverage floor: Aim for 80%+ line coverage of the class you intend to refactor before your first structural change. Use JaCoCo in your build to measure this. Classes below 60% coverage are too risky to refactor without first investing in characterization tests.

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.

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

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:

Incremental Refactoring Techniques
Incremental Refactoring Techniques — mdsanwarhossain.me

Leave a Comment

Related Posts

Software Dev

Complete Java Refactoring Techniques: 20+ Patterns to Transform Legacy Code in 2026

The exhaustive catalogue of refactoring patterns for Java developers with production examples.

Software Dev

Refactoring to Patterns in Java

How to safely evolve messy legacy code toward well-known design patterns.

Software Dev

Code Smells & Refactoring in Java

Identify and eliminate the most dangerous code smells with systematic refactoring.

Software Dev

SOLID Principles in Java: Real-World Refactoring Patterns

Apply SOLID principles as the target state for your refactoring work in Spring Boot.

Md Sanwar Hossain
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

All Posts
Back to Blog
Last updated: April 10, 2026