Software Dev

Refactoring to Patterns in Java: Replace Conditionals, Extract Classes & Eliminate Duplication in Spring Boot

Refactoring is not a luxury or a clean-up task you schedule "after the sprint" — it is a continuous engineering discipline that separates maintainable codebases from legacy nightmares. Martin Fowler's Refactoring catalogue and the Gang of Four design patterns are two sides of the same coin: patterns describe where you want to be, and refactoring techniques describe how to get there safely. This guide walks through ten high-impact refactoring moves with real Spring Boot before/after examples, showing how to replace conditional tangles, extract bloated classes, and eliminate duplication — all without breaking production.

Md Sanwar Hossain April 9, 2026 21 min read Software Dev
Refactoring to Patterns in Java Spring Boot

Table of Contents

  1. Why Refactoring to Patterns?
  2. Replace Conditional with Polymorphism
  3. Replace Constructor with Factory Method
  4. Replace Type Code with Strategy Pattern
  5. Extract Class & Move Method
  6. Introduce Parameter Object
  7. Replace Temp with Query
  8. Decompose Conditional
  9. Refactoring Safety: Tests First
  10. Refactoring Checklist & Anti-Patterns

1. Why Refactoring to Patterns?

Refactoring to Patterns in Java | mdsanwarhossain.me
Refactoring to Patterns in Java — mdsanwarhossain.me

Technical debt accrues interest. A 10% shortcut today costs 40% more effort six months from now, once the team has forgotten the context, once three other services depend on the messy interface, and once the original author has moved on. The Boy Scout Rule — "always leave the campground cleaner than you found it" — is not a suggestion for perfectionism; it is a survival strategy for long-lived codebases. Small, continuous refactoring prevents the big-bang rewrites that destroy delivery velocity and team morale.

Refactoring to patterns is more targeted than general clean-up. Instead of vaguely "tidying" code, you identify a specific code smell, select a proven refactoring move from Martin Fowler's catalogue, and arrive at a design pattern that makes the structure explicit. This approach gives reviewers and future maintainers shared vocabulary: "we replaced the conditional with a Strategy" is a complete explanation of what changed and why.

For large, entangled codebases, the Strangler Fig pattern (described by Martin Fowler) provides a safe migration path: build the new, pattern-based implementation alongside the old one, redirect traffic incrementally, and remove the legacy code once the new path is proven. This avoids the "stop the world" big-bang rewrite and keeps production stable throughout the transition.

Code Smell Refactoring Move Resulting Pattern
Long if/else or switch chains on type Replace Conditional with Polymorphism Strategy / State
Complex object creation inline Replace Constructor with Factory Method Factory Method
String/int type codes controlling behaviour Replace Type Code with Strategy Strategy + Enum
Class with multiple responsibilities Extract Class & Move Method SRP-aligned services
Long parameter lists (5+ args) Introduce Parameter Object Value Object / Record
Temporary variable holding calculation Replace Temp with Query Query Method
Dense, multi-clause conditionals Decompose Conditional Named predicate methods
Context: All examples target Java 17+ and Spring Boot 3.x. Refactoring steps shown represent single, atomic commits — always commit after each safe, passing step.

2. Replace Conditional with Polymorphism

The most pervasive code smell in business-logic-heavy Java services is a chain of if/else or switch statements that branch on an object's "type" to determine behaviour. Every time a new type is added, the developer must locate the switch, insert a new branch, and pray they didn't break the other four branches that already work in production. This is the Open/Closed Principle violation at its most visible.

The canonical scenario is an OrderService.calculateDiscount() method that uses if/else to handle GOLD, SILVER, and BRONZE customer tiers. Adding a PLATINUM tier means modifying the same method that GOLD and BRONZE logic lives in — a merge conflict magnet and a regression risk.

// BEFORE: if/else chain — brittle, closed for extension
@Service
public class OrderService {

    public BigDecimal calculateDiscount(Order order, CustomerType customerType) {
        BigDecimal price = order.getTotalPrice();
        if (customerType == CustomerType.GOLD) {
            return price.multiply(new BigDecimal("0.20")); // 20% discount
        } else if (customerType == CustomerType.SILVER) {
            return price.multiply(new BigDecimal("0.10")); // 10% discount
        } else if (customerType == CustomerType.BRONZE) {
            return price.multiply(new BigDecimal("0.05")); // 5% discount
        } else {
            return BigDecimal.ZERO; // no discount
        }
    }
}
// AFTER: Strategy interface + Spring @Component collection
public interface DiscountStrategy {
    CustomerType customerType();
    BigDecimal calculate(BigDecimal price);
}

@Component
public class GoldDiscount implements DiscountStrategy {
    @Override public CustomerType customerType() { return CustomerType.GOLD; }
    @Override public BigDecimal calculate(BigDecimal price) {
        return price.multiply(new BigDecimal("0.20"));
    }
}

@Component
public class SilverDiscount implements DiscountStrategy {
    @Override public CustomerType customerType() { return CustomerType.SILVER; }
    @Override public BigDecimal calculate(BigDecimal price) {
        return price.multiply(new BigDecimal("0.10"));
    }
}

@Component
public class BronzeDiscount implements DiscountStrategy {
    @Override public CustomerType customerType() { return CustomerType.BRONZE; }
    @Override public BigDecimal calculate(BigDecimal price) {
        return price.multiply(new BigDecimal("0.05"));
    }
}

@Service
public class OrderService {
    private final Map<CustomerType, DiscountStrategy> discountMap;

    // Spring injects all DiscountStrategy beans automatically
    public OrderService(List<DiscountStrategy> strategies) {
        this.discountMap = strategies.stream()
            .collect(Collectors.toMap(DiscountStrategy::customerType, s -> s));
    }

    public BigDecimal calculateDiscount(Order order, CustomerType customerType) {
        return discountMap
            .getOrDefault(customerType, price -> BigDecimal.ZERO)
            .calculate(order.getTotalPrice());
    }
}
Adding PLATINUM tier: Create @Component public class PlatinumDiscount implements DiscountStrategy. Spring discovers it automatically. Zero changes to OrderService. Open/Closed Principle fully satisfied.

3. Replace Constructor with Factory Method

Java Refactoring Patterns | mdsanwarhossain.me
Java Refactoring Patterns — mdsanwarhossain.me

When object creation logic grows beyond a simple constructor call — involving conditionals, validation, default injection, or choosing between multiple concrete types — the service class that holds this logic becomes responsible for both business orchestration and object construction. These are two distinct reasons to change. The "Replace Constructor with Factory Method" refactoring extracts all construction logic into a dedicated Factory class, leaving the calling service clean and testable.

Apply this refactoring when you see: more than two constructors on the same class, conditional creation scattered across multiple service methods, or the constructor containing non-trivial default assignment logic that makes unit tests brittle.

// BEFORE: Service responsible for account construction
@Service
public class UserAccountService {

    public UserAccount createAccount(String email, AccountType type, String referralCode) {
        UserAccount account;
        if (type == AccountType.PREMIUM) {
            account = new UserAccount(email, AccountType.PREMIUM);
            account.setCreditLimit(new BigDecimal("10000"));
            account.setTrialDays(90);
            account.setFeatures(List.of("ADVANCED_ANALYTICS", "PRIORITY_SUPPORT"));
        } else if (type == AccountType.STANDARD) {
            account = new UserAccount(email, AccountType.STANDARD);
            account.setCreditLimit(new BigDecimal("1000"));
            account.setTrialDays(30);
            account.setFeatures(List.of("BASIC_ANALYTICS"));
        } else {
            account = new UserAccount(email, AccountType.FREE);
            account.setCreditLimit(BigDecimal.ZERO);
            account.setTrialDays(0);
            account.setFeatures(Collections.emptyList());
        }
        if (referralCode != null) {
            account.applyReferralBonus(referralCode);
        }
        return account;
    }
}
// AFTER: Factory handles construction; service handles business logic
@Component
public class AccountFactory {

    public UserAccount create(String email, AccountType type) {
        return switch (type) {
            case PREMIUM  -> buildPremium(email);
            case STANDARD -> buildStandard(email);
            default       -> buildFree(email);
        };
    }

    private UserAccount buildPremium(String email) {
        UserAccount a = new UserAccount(email, AccountType.PREMIUM);
        a.setCreditLimit(new BigDecimal("10000"));
        a.setTrialDays(90);
        a.setFeatures(List.of("ADVANCED_ANALYTICS", "PRIORITY_SUPPORT"));
        return a;
    }

    private UserAccount buildStandard(String email) {
        UserAccount a = new UserAccount(email, AccountType.STANDARD);
        a.setCreditLimit(new BigDecimal("1000"));
        a.setTrialDays(30);
        a.setFeatures(List.of("BASIC_ANALYTICS"));
        return a;
    }

    private UserAccount buildFree(String email) {
        UserAccount a = new UserAccount(email, AccountType.FREE);
        a.setCreditLimit(BigDecimal.ZERO);
        a.setTrialDays(0);
        a.setFeatures(Collections.emptyList());
        return a;
    }
}

@Service
@RequiredArgsConstructor
public class UserAccountService {
    private final AccountFactory accountFactory;
    private final ReferralService referralService;

    public UserAccount createAccount(String email, AccountType type, String referralCode) {
        UserAccount account = accountFactory.create(email, type);
        if (referralCode != null) {
            referralService.applyBonus(account, referralCode);
        }
        return account;
    }
}
When to apply: Extract a Factory when you see >2 constructors on a class, when conditional object creation logic appears in multiple places, or when the constructor contains injected dependencies that make isolated unit tests difficult to write.

4. Replace Type Code with Strategy Pattern

Type codes — String or int constants used to control branching behaviour — are one of the oldest code smells in Java. They leak implementation detail into calling code, make refactoring search-and-replace risky, and scatter a single concept across many if statements throughout the codebase. When a payment processor checks if (type.equals("CARD")) in three different methods, changing "CARD" to "DEBIT_CARD" requires grep-and-pray archaeology.

The refactoring moves the type code to a proper Java enum, introduces a PaymentProcessor interface, and leverages Spring's @Component auto-discovery to build a dispatch map. The calling service becomes completely agnostic of payment method details.

// BEFORE: String type code drives branching in every method
@Service
public class PaymentProcessor {

    public PaymentResult charge(String type, BigDecimal amount, String token) {
        if (type.equals("CASH")) {
            return processCash(amount);
        } else if (type.equals("CARD")) {
            return processCard(amount, token);
        } else if (type.equals("CRYPTO")) {
            return processCrypto(amount, token);
        }
        throw new IllegalArgumentException("Unknown payment type: " + type);
    }

    public boolean isRefundable(String type) {
        if (type.equals("CASH"))   return false;
        if (type.equals("CARD"))   return true;
        if (type.equals("CRYPTO")) return true;
        return false;
    }
}
// AFTER: Enum + Strategy + Spring @Qualifier for production wiring

public enum PaymentType { CASH, CARD, CRYPTO }

public interface PaymentProcessor {
    PaymentType supports();
    PaymentResult charge(BigDecimal amount, String token);
    boolean isRefundable();
}

@Component
public class CashPaymentProcessor implements PaymentProcessor {
    @Override public PaymentType supports() { return PaymentType.CASH; }
    @Override public PaymentResult charge(BigDecimal amount, String token) {
        // cash handling — no token needed
        return PaymentResult.success("CASH-" + System.currentTimeMillis());
    }
    @Override public boolean isRefundable() { return false; }
}

@Component
public class CardPaymentProcessor implements PaymentProcessor {
    private final StripeClient stripeClient;
    public CardPaymentProcessor(@Qualifier("stripeClient") StripeClient stripeClient) {
        this.stripeClient = stripeClient;
    }
    @Override public PaymentType supports() { return PaymentType.CARD; }
    @Override public PaymentResult charge(BigDecimal amount, String token) {
        return stripeClient.charge(amount, token);
    }
    @Override public boolean isRefundable() { return true; }
}

@Component
public class CryptoPaymentProcessor implements PaymentProcessor {
    @Override public PaymentType supports() { return PaymentType.CRYPTO; }
    @Override public PaymentResult charge(BigDecimal amount, String token) {
        // blockchain transaction submission
        return PaymentResult.success("CRYPTO-" + UUID.randomUUID());
    }
    @Override public boolean isRefundable() { return true; }
}

@Service
public class PaymentDispatcher {
    private final Map<PaymentType, PaymentProcessor> processors;

    public PaymentDispatcher(List<PaymentProcessor> processorList) {
        this.processors = processorList.stream()
            .collect(Collectors.toMap(PaymentProcessor::supports, p -> p));
    }

    public PaymentResult charge(PaymentType type, BigDecimal amount, String token) {
        return resolve(type).charge(amount, token);
    }

    public boolean isRefundable(PaymentType type) {
        return resolve(type).isRefundable();
    }

    private PaymentProcessor resolve(PaymentType type) {
        PaymentProcessor processor = processors.get(type);
        if (processor == null) throw new UnsupportedPaymentTypeException(type);
        return processor;
    }
}
Spring @Qualifier tip: When multiple beans implement the same interface (e.g., two StripeClient configurations for test vs. production), use @Qualifier("stripeClientProd") on the constructor parameter to be explicit. This avoids brittle @Primary conflicts in large application contexts.

5. Extract Class & Move Method

Extract Class Refactoring Java | mdsanwarhossain.me
Extract Class Refactoring Java — mdsanwarhossain.me

When a single class accumulates methods and fields from multiple distinct concerns, it becomes a maintenance liability. The Extract Class refactoring identifies a cohesive cluster of fields and methods within a bloated class and moves them into a new, focused class. It is the mechanical implementation of the Single Responsibility Principle, and it is the most common refactoring applied in mid-size Spring Boot services.

The trigger rule: if you can describe a class's responsibilities with the word "and" — it manages users and validates addresses and formats emails — it needs Extract Class. Each "and" is a separate reason to change, a separate test class, and a separate Spring bean.

// BEFORE: 200-line UserService — three concerns tangled together
@Service
public class UserService {

    // --- User management ---
    public User register(RegistrationRequest req) { ... }
    public User findById(UUID id) { ... }
    public void deactivate(UUID id) { ... }

    // --- Address management (separate concern!) ---
    public Address addAddress(UUID userId, AddressRequest req) { ... }
    public List<Address> getAddresses(UUID userId) { ... }
    public Address updateAddress(UUID addressId, AddressRequest req) { ... }
    public void deleteAddress(UUID addressId) { ... }

    // --- Email validation (another separate concern!) ---
    public boolean isValidEmail(String email) { ... }
    public boolean isDomainAllowed(String email) { ... }
    public String normalizeEmail(String email) { ... }
    // ... 170 more lines
}
// AFTER: Three focused classes, each with a single reason to change

// Class diagram (text):
// UserService       ──uses──▶ AddressService
//                  ──uses──▶ EmailValidator
//
// UserService: user lifecycle only
// AddressService: address CRUD only
// EmailValidator: email rules only

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final EmailValidator emailValidator;

    public User register(RegistrationRequest req) {
        emailValidator.validate(req.getEmail());
        User user = User.from(req);
        return userRepository.save(user);
    }

    public User findById(UUID id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    public void deactivate(UUID id) {
        User user = findById(id);
        user.deactivate();
        userRepository.save(user);
    }
}

@Service
@RequiredArgsConstructor
public class AddressService {
    private final AddressRepository addressRepository;
    private final UserRepository userRepository;

    public Address addAddress(UUID userId, AddressRequest req) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));
        Address address = Address.from(req, user);
        return addressRepository.save(address);
    }

    public List<Address> getAddresses(UUID userId) {
        return addressRepository.findByUserId(userId);
    }

    public Address updateAddress(UUID addressId, AddressRequest req) {
        Address address = addressRepository.findById(addressId)
            .orElseThrow(() -> new AddressNotFoundException(addressId));
        address.update(req);
        return addressRepository.save(address);
    }
}

@Component
public class EmailValidator {
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[\\w._%+\\-]+@[\\w.\\-]+\\.[a-zA-Z]{2,}$");
    private static final Set<String> BLOCKED_DOMAINS =
        Set.of("mailinator.com", "guerrillamail.com");

    public void validate(String email) {
        if (!EMAIL_PATTERN.matcher(email).matches())
            throw new InvalidEmailException(email);
        String domain = email.substring(email.indexOf('@') + 1).toLowerCase();
        if (BLOCKED_DOMAINS.contains(domain))
            throw new BlockedEmailDomainException(domain);
    }

    public String normalize(String email) {
        return email.trim().toLowerCase();
    }
}
IntelliJ shortcut: Select the methods and fields you want to move, then use Refactor → Extract Delegate or Refactor → Move (F6). IntelliJ will update all call sites and generate the delegation automatically, making this a safe, automated refactoring step.

6. Introduce Parameter Object

A method signature with six or more parameters is one of the most reliable indicators of a design problem. The caller must remember the exact order of arguments (was it userId before orderId or after?), and the compiler cannot catch argument-order bugs. The situation worsens over time as new parameters are appended to the signature, making old callers out of date and new callers confused about which arguments are optional.

Introduce Parameter Object groups the cohesive parameters into a named class or Java record. The resulting method signature is self-documenting, the record provides type safety, and adding a new field to the request doesn't require changing every call site's argument list.

// BEFORE: 6-parameter method — confusing, error-prone
@Service
public class OrderPlacementService {

    // Which String is orderId? Which is discountCode? Which is currency?
    public OrderConfirmation placeOrder(
            String orderId,
            Long userId,
            String discountCode,
            String currency,
            Address shippingAddress,
            Address billingAddress) {
        // ...
    }
}
// AFTER: Parameter Object encapsulates the request
public record OrderRequest(
    String orderId,
    Long userId,
    String discountCode,
    String currency,
    Address shippingAddress,
    Address billingAddress
) {
    // Compact constructor adds validation at the boundary
    public OrderRequest {
        Objects.requireNonNull(orderId,          "orderId is required");
        Objects.requireNonNull(userId,           "userId is required");
        Objects.requireNonNull(currency,         "currency is required");
        Objects.requireNonNull(shippingAddress,  "shippingAddress is required");
        if (currency.length() != 3)
            throw new IllegalArgumentException("currency must be ISO 4217 code");
    }

    // billingAddress defaults to shippingAddress when not provided
    public Address effectiveBillingAddress() {
        return billingAddress != null ? billingAddress : shippingAddress;
    }
}

@Service
@RequiredArgsConstructor
public class OrderPlacementService {

    public OrderConfirmation placeOrder(OrderRequest request) {
        // Clean, readable, IDE autocomplete works perfectly
        applyDiscount(request.discountCode(), request.userId());
        return fulfillOrder(request);
    }

    private OrderConfirmation fulfillOrder(OrderRequest request) {
        // ...
        return new OrderConfirmation(request.orderId(), "CONFIRMED");
    }
}
Java record advantage: Records (record OrderRequest(...)) are immutable by default, generate equals(), hashCode(), and toString() automatically, and work seamlessly with Spring's @RequestBody deserialization (Jackson supports records in Spring Boot 3.x). Use a compact constructor to enforce business invariants at object-creation time.

7. Replace Temp with Query

Temporary variables that hold the result of an expression are often an opportunity to improve readability and reusability. When a temporary variable's sole purpose is to give a name to a calculation, replacing it with a private query method makes that calculation accessible from other methods in the same class, makes the intent explicit in the code, and allows the IDE and JIT compiler to inline it when appropriate.

This refactoring is particularly effective in Spring service methods where a price calculation or eligibility check is used multiple times across different methods — the temp variable approach duplicates the logic, while the query method centralises it.

// BEFORE: Temp variable — logic duplicated when needed elsewhere
@Service
public class PricingService {

    public InvoiceLine computeLineItem(OrderItem item) {
        double temp = item.getBasePrice() * (1.0 - item.getDiscountRate());
        if (temp > 100.0) {
            // apply bulk discount
            temp = temp * 0.95;
        }
        double tax = temp * 0.18;
        return new InvoiceLine(item.getSku(), temp, tax);
    }

    public boolean isEligibleForFreeShipping(OrderItem item) {
        // Oops — copy-pasted the same calculation
        double discounted = item.getBasePrice() * (1.0 - item.getDiscountRate());
        return discounted > 50.0;
    }
}
// AFTER: Query methods — single source of truth, self-documenting
@Service
public class PricingService {

    public InvoiceLine computeLineItem(OrderItem item) {
        double netPrice = getNetPrice(item);
        double tax      = getTaxAmount(item);
        return new InvoiceLine(item.getSku(), netPrice, tax);
    }

    public boolean isEligibleForFreeShipping(OrderItem item) {
        return getDiscountedBasePrice(item) > 50.0;
    }

    // --- Query methods: named, reusable, easily testable in isolation ---

    private double getDiscountedBasePrice(OrderItem item) {
        return item.getBasePrice() * (1.0 - item.getDiscountRate());
    }

    private double getNetPrice(OrderItem item) {
        double discounted = getDiscountedBasePrice(item);
        return discounted > 100.0 ? discounted * 0.95 : discounted;
    }

    private double getTaxAmount(OrderItem item) {
        return getNetPrice(item) * 0.18;
    }
}
IntelliJ tip: Select the expression assigned to the temp variable, then press Ctrl+Alt+M (Extract Method). IntelliJ will create the query method, replace all usages, and keep the method's return type consistent — a fully automated, zero-risk refactoring step.

8. Decompose Conditional

Complex if conditions that span multiple clauses connected by && and || are notoriously difficult to read, reason about, and test. When a condition like if (order.status == PENDING && customer.creditScore > 600 && inventory.isAvailable(order.productId)) appears in a service method, the reader must parse three separate domain concepts simultaneously, and the test must set up three separate preconditions to cover each branch.

Decompose Conditional extracts each clause into a named boolean method. The name explains the intent of the check; the body contains the mechanism. This produces code that reads like business prose and gives you three individually testable predicates instead of one opaque compound expression.

// BEFORE: Dense compound conditional — hard to read and test
@Service
public class OrderEligibilityService {

    public boolean canFulfill(Order order, Customer customer) {
        if (order.getStatus() == OrderStatus.PENDING
                && customer.getCreditScore() > 600
                && !customer.hasOutstandingBalance()
                && inventoryClient.isAvailable(order.getProductId(), order.getQuantity())
                && order.getDeliveryDate().isAfter(LocalDate.now().plusDays(1))) {
            return true;
        }
        return false;
    }
}
// AFTER: Named predicate methods — reads like business requirements
@Service
@RequiredArgsConstructor
public class OrderEligibilityService {
    private final InventoryClient inventoryClient;

    public boolean canFulfill(Order order, Customer customer) {
        return isOrderPending(order)
            && hasGoodCredit(customer)
            && hasNoPendingBalance(customer)
            && isInStock(order)
            && hasValidDeliveryDate(order);
    }

    private boolean isOrderPending(Order order) {
        return order.getStatus() == OrderStatus.PENDING;
    }

    private boolean hasGoodCredit(Customer customer) {
        return customer.getCreditScore() > 600;
    }

    private boolean hasNoPendingBalance(Customer customer) {
        return !customer.hasOutstandingBalance();
    }

    private boolean isInStock(Order order) {
        return inventoryClient.isAvailable(order.getProductId(), order.getQuantity());
    }

    private boolean hasValidDeliveryDate(Order order) {
        return order.getDeliveryDate().isAfter(LocalDate.now().plusDays(1));
    }
}
Benefit Detail
Readability Method names communicate intent; body shows mechanism
Testability Each predicate is independently unit-testable with a single assertion
Reusability Predicates can be composed or reused in other eligibility checks
Debuggability Breakpoints can target a single clause without splitting the condition
Maintainability Adding a new eligibility rule is one new method, not a mutation to a dense expression

9. Refactoring Safety: Tests First

Refactoring without a test suite is renovation without scaffolding — you are changing load-bearing walls without knowing which ones hold up the roof. The safety net is a comprehensive set of tests that cover the observable behaviour of the code before any structural change is made. Martin Fowler's definition of refactoring explicitly requires that existing tests pass after every step; if a refactoring step breaks a test, either the refactoring introduced a bug, or the test was wrong.

// Step 1: Write characterisation tests BEFORE refactoring
@SpringBootTest
class OrderServiceRefactoringTest {

    @Autowired OrderService orderService;

    @Test
    void goldCustomerShouldReceive20PercentDiscount() {
        Order order = Order.of(new BigDecimal("100.00"));
        BigDecimal discount = orderService.calculateDiscount(order, CustomerType.GOLD);
        assertThat(discount).isEqualByComparingTo("20.00");
    }

    @Test
    void silverCustomerShouldReceive10PercentDiscount() {
        Order order = Order.of(new BigDecimal("100.00"));
        BigDecimal discount = orderService.calculateDiscount(order, CustomerType.SILVER);
        assertThat(discount).isEqualByComparingTo("10.00");
    }

    @Test
    void unknownCustomerTypeShouldReceiveZeroDiscount() {
        Order order = Order.of(new BigDecimal("100.00"));
        BigDecimal discount = orderService.calculateDiscount(order, CustomerType.BRONZE);
        assertThat(discount).isEqualByComparingTo("5.00");
    }
}
// All tests pass on the BEFORE code → safe to begin refactoring

Beyond JUnit tests, a complete refactoring safety strategy includes:

PIT mutation testing: Add pitest-maven plugin to your pom.xml and run mvn test-compile org.pitest:pitest-maven:mutationCoverage. A mutation score >80% on the refactored class is a strong signal that the refactoring was behaviour-preserving. See Mutation Testing with PIT for a full setup guide.

10. Refactoring Checklist & Anti-Patterns

A disciplined refactoring process prevents the two most common failure modes: introducing regressions through structural changes, and "refactoring" the codebase into an over-engineered pattern museum that is harder to understand than the original. The checklist below distils the practices that distinguish safe, high-value refactoring from risky, low-value churn.

Refactoring Safety Checklist

Refactoring Anti-Patterns

Anti-Pattern Description Consequence
Big Bang Refactoring Rewriting large parts of the system at once in a separate long-lived branch Massive merge conflict; high regression risk; team loses context
Refactoring Without Tests Restructuring code with no safety net to verify behaviour is preserved Silent regressions that reach production; loss of stakeholder trust
Premature Pattern Introduction Adding Strategy, Factory, or Observer to code that has only one implementation and is unlikely to grow Over-engineering; new team members spend hours tracing indirection for simple logic
Refactoring Under Pressure Performing structural changes during a live incident or feature-freeze sprint Rushed decisions; incomplete steps; regressions deployed under time pressure
Mixing Refactoring and Features Combining structural changes with new feature additions in the same commit or PR Impossible to bisect bugs; reviewers cannot tell what is refactoring vs. new behaviour
Gold Plating Extracting classes and interfaces beyond what the problem domain requires, justified as "future flexibility" YAGNI violation; maintenance burden exceeds the flexibility benefit that never materialises
Rule of Three for Patterns: Introduce an abstraction (interface, strategy, factory) the second time you need it, not the first. The third occurrence confirms the abstraction is genuinely warranted. This pragmatic heuristic prevents both premature abstraction and repetitive duplication.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: April 9, 2026