Complete Java Refactoring Techniques: 20+ Patterns to Transform Legacy Code in 2026
Legacy Java codebases accumulate technical debt silently — until a simple feature takes weeks. This comprehensive guide covers 20+ refactoring techniques from Martin Fowler's catalog, each illustrated with Java & Spring Boot before/after examples so you can clean up real production code starting today.
TL;DR — Key Takeaways
- Refactoring ≠ rewriting — small, behaviour-preserving transformations backed by tests
- Extract Method is the most-used refactoring; apply it whenever a method exceeds ~20 lines
- Replace Conditional with Polymorphism eliminates brittle switch/if-else chains using the Strategy pattern
- Introduce Parameter Object reduces long parameter lists into cohesive DTOs
- Always cover code with tests before refactoring — IDE automated refactorings are your safety net
Table of Contents
1. What is Refactoring?
Martin Fowler defines refactoring as "a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behaviour." The operative word is disciplined — not hacking, not rewriting, not fixing bugs. Each refactoring step is a small, verifiable transformation proven safe by an automated test suite.
Refactoring vs Rewriting
| Dimension | Refactoring | Rewriting |
|---|---|---|
| Risk | Low — incremental | High — big-bang |
| Existing behaviour | Preserved | Often broken / re-discovered |
| Business continuity | Feature delivery continues | Delivery freezes |
| When to use | Continuously, alongside features | Only when architecture is fundamentally broken |
When to Refactor — Code Smell Triggers
- Long Method: Methods exceeding 20–30 lines become hard to reason about — apply Extract Method.
- God Class: A class with 1,000+ lines and 50+ fields knows too much — apply Extract Class.
- Long Parameter List: More than 3–4 parameters — apply Introduce Parameter Object.
- Duplicate Code: The same logic copied across two or more places — apply Extract Method and move to the right class.
- Switch Statements / Long If-Else Chains: Logic branching on a type tag — apply Replace Conditional with Polymorphism.
- Feature Envy: A method that uses more data from another class than its own — apply Move Method.
- Primitive Obsession: Using String for email, int for money — apply Replace Data Value with Object.
The Boy Scout Rule
Robert C. Martin popularised the Boy Scout Rule for software: "Always leave the code cleaner than you found it." You don't need a dedicated refactoring sprint. Every time you touch a file to add a feature or fix a bug, apply one small refactoring. Over months this transforms a codebase organically without stopping feature delivery.
2. Composing Methods
Composing methods is about packaging code into well-named, single-purpose units. Most refactoring activity happens here. A well-composed method tells a story in domain language, delegating implementation details to descriptively named helper methods.
Extract Method
When: A code fragment can be grouped together and given a name that explains its intent. Pull it into a separate method. This is the single most valuable refactoring in Fowler's entire catalog.
// BEFORE — 40-line method, low cohesion
@Service
public class OrderService {
public void processOrder(Order order) {
// validate
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order has no items");
}
if (order.getCustomerId() == null) {
throw new IllegalArgumentException("Customer ID is required");
}
// calculate total
BigDecimal total = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
total = total.add(item.getPrice().multiply(
BigDecimal.valueOf(item.getQuantity())));
}
if (order.getCouponCode() != null) {
total = total.multiply(BigDecimal.valueOf(0.9));
}
order.setTotal(total);
// persist and notify
orderRepository.save(order);
emailService.sendConfirmation(order);
}
}
// AFTER — Extract Method applied three times
@Service
public class OrderService {
public void processOrder(Order order) {
validateOrder(order);
order.setTotal(calculateTotal(order));
orderRepository.save(order);
emailService.sendConfirmation(order);
}
private void validateOrder(Order order) {
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order has no items");
}
if (order.getCustomerId() == null) {
throw new IllegalArgumentException("Customer ID is required");
}
}
private BigDecimal calculateTotal(Order order) {
BigDecimal total = order.getItems().stream()
.map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return order.getCouponCode() != null
? total.multiply(BigDecimal.valueOf(0.9))
: total;
}
}
Inline Method
When: A method body is just as clear as the method name itself, or the method is only called in one place and adds no clarity. Inline it to reduce indirection.
// BEFORE — unnecessary delegation
public boolean isEligibleForDiscount(Customer customer) {
return moreThanFiveOrders(customer);
}
private boolean moreThanFiveOrders(Customer customer) {
return customer.getOrderCount() > 5;
}
// AFTER — Inline Method
public boolean isEligibleForDiscount(Customer customer) {
return customer.getOrderCount() > 5;
}
Extract Variable
When: A complex expression is hard to understand at a glance. Extract it into a named local variable that documents its purpose.
// BEFORE — opaque expression
if (order.getItems().size() > 0 && customer.getAge() >= 18
&& !customer.isBanned() && inventory.hasStock(order)) { ... }
// AFTER — Extract Variable
boolean hasItems = order.getItems().size() > 0;
boolean isAdult = customer.getAge() >= 18;
boolean isActiveCustomer = !customer.isBanned();
boolean isInStock = inventory.hasStock(order);
if (hasItems && isAdult && isActiveCustomer && isInStock) { ... }
Split Temporary Variable
When: A temporary variable is assigned more than once and serves two different purposes — it violates the single-responsibility principle for variables. Split it into two clearly named variables.
// BEFORE — 'temp' plays two roles
double temp = order.getBasePrice();
temp = temp * (1 - getDiscountRate(order)); // discount price
temp = temp * (1 + TAX_RATE); // taxed price
// AFTER — Split Temporary Variable
double discountedPrice = order.getBasePrice() * (1 - getDiscountRate(order));
double finalPrice = discountedPrice * (1 + TAX_RATE);
3. Moving Features Between Objects
Good object-oriented design is about putting behaviour where the data lives. These refactorings move methods and fields to the classes that use them most, reducing coupling and increasing cohesion.
Move Method
When: A method uses more data from another class than from its own (Feature Envy smell). Move it to the class it "envies".
// BEFORE — OrderService computing shipping; it uses ShippingAddress data only
@Service
public class OrderService {
public BigDecimal calculateShipping(Order order) {
ShippingAddress addr = order.getShippingAddress();
if ("EXPRESS".equals(addr.getShippingType())) return BigDecimal.valueOf(15.00);
if (addr.getCountryCode().equals("BD")) return BigDecimal.valueOf(2.50);
return BigDecimal.valueOf(10.00);
}
}
// AFTER — Move Method to ShippingAddress where it belongs
public class ShippingAddress {
public BigDecimal calculateShippingCost() {
if ("EXPRESS".equals(this.shippingType)) return BigDecimal.valueOf(15.00);
if ("BD".equals(this.countryCode)) return BigDecimal.valueOf(2.50);
return BigDecimal.valueOf(10.00);
}
}
// OrderService now delegates:
BigDecimal shipping = order.getShippingAddress().calculateShippingCost();
Extract Class
When: One class does the work of two (God Class smell). Extract a cohesive subset of fields and methods into a new class.
// BEFORE — God Class mixing order logic and payment logic
public class OrderService {
public void placeOrder(Order order) { /* order logic */ }
public void refundOrder(Long orderId) { /* payment logic */ }
public void chargeCreditCard(PaymentDetails pd) { /* payment logic */ }
public void processPayPal(PayPalDetails pd) { /* payment logic */ }
}
// AFTER — Extract Class: PaymentService handles all payment concerns
@Service
public class OrderService {
private final PaymentService paymentService;
public void placeOrder(Order order) {
// order logic only
paymentService.charge(order.getPaymentDetails());
}
}
@Service
public class PaymentService {
public void charge(PaymentDetails pd) { /* ... */ }
public void refund(Long orderId) { /* ... */ }
public void processPayPal(PayPalDetails pd) { /* ... */ }
}
Hide Delegate (Law of Demeter)
When: Client code calls into chains like order.getCustomer().getAddress().getCity(). Hide the chain by adding a delegation method to Order.
// BEFORE — violates Law of Demeter
String city = order.getCustomer().getAddress().getCity();
// AFTER — Hide Delegate on Order
public class Order {
public String getCustomerCity() {
return customer.getAddress().getCity();
}
}
// Client:
String city = order.getCustomerCity();
4. Organizing Data
Data organisation refactorings turn raw primitives and unstructured collections into rich domain objects with proper encapsulation. They reduce Primitive Obsession and make domain rules explicit.
Replace Data Value with Object
When: A primitive type (String, int) carries domain rules — like a valid email format or a non-negative price. Replace it with a dedicated value class that enforces those rules at the boundary.
// BEFORE — String carries email semantics; validation is scattered
public class Customer {
private String email; // could be "not-an-email" with no guard
}
// AFTER — Replace Data Value with Object
public final class Email {
private final String value;
public Email(String value) {
if (value == null || !value.matches("^[^@]+@[^@]+\\.[^@]+$"))
throw new IllegalArgumentException("Invalid email: " + value);
this.value = value.toLowerCase();
}
public String getValue() { return value; }
@Override public String toString() { return value; }
}
public class Customer {
private Email email; // invariant: always a valid email
}
Encapsulate Field
When: A field is public. Make it private and provide accessor methods so the class controls all access to its state.
// BEFORE — public field, anyone can set any value
public class Product {
public BigDecimal price; // no guard possible
}
// AFTER — Encapsulate Field
public class Product {
private BigDecimal price;
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) {
if (price.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Price cannot be negative");
this.price = price;
}
}
Introduce Parameter Object
When: Multiple methods share the same group of parameters — a classic Long Parameter List smell. Group them into a data transfer object.
// BEFORE — 5-param list repeated in every method signature
public void registerCustomer(String name, String email,
String phone, String countryCode,
String referralCode) { ... }
public void updateCustomer(Long id, String name, String email,
String phone, String countryCode,
String referralCode) { ... }
// AFTER — Introduce Parameter Object: CustomerDto
public record CustomerDto(
String name,
String email,
String phone,
String countryCode,
String referralCode
) {}
public void registerCustomer(CustomerDto dto) { ... }
public void updateCustomer(Long id, CustomerDto dto) { ... }
Replace Array with Object
When: You use an array (or Object[]) as a tuple where each index has a specific meaning. Replace it with a proper class.
// BEFORE — index 0 = name, index 1 = score (fragile)
String[] result = new String[]{"Alice", "95"};
System.out.println("Winner: " + result[0] + " Score: " + result[1]);
// AFTER — Replace Array with Object
public record QuizResult(String playerName, int score) {}
QuizResult result = new QuizResult("Alice", 95);
System.out.println("Winner: " + result.playerName() + " Score: " + result.score());
5. Simplifying Conditional Expressions
Complex conditionals are one of the top sources of bugs in Java codebases. These refactorings replace opaque boolean logic with expressive, polymorphic designs.
Decompose Conditional
When: The condition and the then/else branches each deserve their own named method.
// BEFORE — complex condition inline
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}
// AFTER — Decompose Conditional
if (isWinter(date)) {
charge = winterCharge(quantity);
} else {
charge = summerCharge(quantity);
}
private boolean isWinter(LocalDate d) { return d.isBefore(SUMMER_START) || d.isAfter(SUMMER_END); }
private BigDecimal winterCharge(int qty) { return qty * winterRate + winterServiceCharge; }
private BigDecimal summerCharge(int qty) { return qty * summerRate; }
Replace Conditional with Polymorphism
When: You have a switch or if-else chain that varies behaviour based on a type code. Replace with a polymorphic class hierarchy (Strategy pattern).
// BEFORE — switch on OrderType grows with every new type
public BigDecimal getDiscount(Order order) {
switch (order.getType()) {
case "STANDARD": return BigDecimal.ZERO;
case "PREMIUM": return order.getTotal().multiply(new BigDecimal("0.10"));
case "VIP": return order.getTotal().multiply(new BigDecimal("0.20"));
default: throw new IllegalArgumentException("Unknown type: " + order.getType());
}
}
// AFTER — Replace Conditional with Polymorphism (Strategy)
public interface DiscountStrategy {
BigDecimal calculate(Order order);
}
public class StandardDiscount implements DiscountStrategy {
public BigDecimal calculate(Order order) { return BigDecimal.ZERO; }
}
public class PremiumDiscount implements DiscountStrategy {
public BigDecimal calculate(Order order) {
return order.getTotal().multiply(new BigDecimal("0.10"));
}
}
public class VipDiscount implements DiscountStrategy {
public BigDecimal calculate(Order order) {
return order.getTotal().multiply(new BigDecimal("0.20"));
}
}
// OrderService
public BigDecimal getDiscount(Order order) {
return order.getDiscountStrategy().calculate(order);
}
Introduce Null Object
When: You repeatedly check for null before calling a method. Introduce a Null Object that provides safe default behaviour, eliminating the checks entirely.
// BEFORE — null checks scattered everywhere
if (customer.getRewardProgram() != null) {
customer.getRewardProgram().addPoints(order.getTotal());
}
// AFTER — Introduce Null Object
public class NullRewardProgram implements RewardProgram {
@Override
public void addPoints(BigDecimal amount) { /* no-op */ }
@Override
public int getPoints() { return 0; }
}
// Customer always returns a non-null RewardProgram:
public RewardProgram getRewardProgram() {
return rewardProgram != null ? rewardProgram : new NullRewardProgram();
}
// Client — no null check needed:
customer.getRewardProgram().addPoints(order.getTotal());
Consolidate Conditional Expression
When: Multiple conditionals that produce the same result can be combined into a single expression — and then extracted into a named method.
// BEFORE — three separate ifs, same action
if (customer.isSuspended()) return BigDecimal.ZERO;
if (customer.isOnProbation()) return BigDecimal.ZERO;
if (!customer.isVerified()) return BigDecimal.ZERO;
return calculateFullDiscount(customer);
// AFTER — Consolidate Conditional Expression + Extract Method
if (isIneligibleForDiscount(customer)) return BigDecimal.ZERO;
return calculateFullDiscount(customer);
private boolean isIneligibleForDiscount(Customer c) {
return c.isSuspended() || c.isOnProbation() || !c.isVerified();
}
6. Making Method Calls Simpler
These refactorings clean up method signatures and enforce the Command-Query Separation (CQS) principle: a method either changes state or returns data, but never both.
Rename Method
When: The method name doesn't clearly express what it does. Use IntelliJ's Shift+F6 for a safe, project-wide rename.
// BEFORE
public List<Order> getOrders(Long id) { ... }
// AFTER — name reveals intent
public List<Order> findOrdersByCustomerId(Long customerId) { ... }
Separate Query from Modifier (CQS)
When: A method both returns a value and changes object state. Split it into a separate query (no side effect) and a command (no return value).
// BEFORE — violates CQS: queries AND modifies
public Order finalizeAndGetOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(OrderStatus.FINALIZED); // side effect!
orderRepository.save(order);
return order;
}
// AFTER — Separate Query from Modifier
public void finalizeOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(OrderStatus.FINALIZED);
orderRepository.save(order);
}
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
}
Parameterize Method
When: Several methods do similar things but with different literal values. Combine them into one method with a parameter.
// BEFORE — three near-identical methods
public void applyTenPercentDiscount(Order o) { o.setDiscount(0.10); }
public void applyFifteenPercentDiscount(Order o) { o.setDiscount(0.15); }
public void applyTwentyPercentDiscount(Order o) { o.setDiscount(0.20); }
// AFTER — Parameterize Method
public void applyDiscount(Order order, double rate) {
order.setDiscount(rate);
}
7. Dealing with Generalization
Inheritance hierarchies tend to get messy over time. These refactorings reorganise class hierarchies so behaviour lives at the right level of abstraction.
Pull Up Method / Pull Up Field
When: Identical or very similar methods exist in sibling subclasses. Move them to the common superclass to eliminate duplication.
// BEFORE — same getName() in both subclasses
public class DomesticShipment extends Shipment {
public String getName() { return "Domestic Shipment"; }
}
public class InternationalShipment extends Shipment {
public String getName() { return "International Shipment"; }
}
// AFTER — Pull Up Field (using abstract method for variation)
public abstract class Shipment {
public abstract String getName(); // pull up as abstract method
}
// Or if the method is truly identical, pull up as a concrete method in Shipment.
Push Down Method
When: A method on a superclass is only relevant to one subclass. Push it down to that subclass to keep the superclass clean.
// BEFORE — generateCustomsForm only applies to InternationalShipment
public abstract class Shipment {
public CustomsForm generateCustomsForm() { /* only used by International */ }
}
// AFTER — Push Down Method
public class InternationalShipment extends Shipment {
public CustomsForm generateCustomsForm() { /* ... */ }
}
// Superclass Shipment no longer has this method.
Extract Interface
When: Clients only use a subset of a class's methods. Extract an interface representing that subset to decouple clients from the full implementation.
// BEFORE — OrderController depends on the full OrderService
@RestController
public class OrderController {
private final OrderService orderService;
// only calls findById and findAll — doesn't use processOrder, cancel, refund...
}
// AFTER — Extract Interface
public interface OrderQueryPort {
Order findById(Long id);
List<Order> findAll(Pageable pageable);
}
@Service
public class OrderService implements OrderQueryPort { /* ... */ }
@RestController
public class OrderController {
private final OrderQueryPort orderQuery; // minimal dependency
}
Replace Inheritance with Delegation
When: A subclass uses only a small part of the superclass interface, or the inheritance relationship isn't truly an IS-A. Switch to composition.
// BEFORE — PriorityQueue extends LinkedList, but ISN'T a LinkedList
public class PriorityQueue extends LinkedList<Task> {
public Task poll() { /* custom priority logic */ }
}
// AFTER — Replace Inheritance with Delegation
public class PriorityQueue {
private final LinkedList<Task> store = new LinkedList<>();
public void add(Task t) { store.add(t); store.sort(Comparator.comparing(Task::getPriority)); }
public Task poll() { return store.pollFirst(); }
public boolean isEmpty() { return store.isEmpty(); }
}
8. Refactoring in Practice: A Spring Boot Legacy Case Study
Let's walk through a real-world scenario: a monolithic OrderController that has accumulated 500 lines of business logic directly in the controller. This is a common anti-pattern in Spring Boot codebases that grew organically over years. We'll apply 5 refactoring techniques step by step.
The Starting Point — Problematic OrderController
// BEFORE — 500-line God Controller (representative excerpt)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired private OrderRepository orderRepository;
@Autowired private ProductRepository productRepository;
@Autowired private CustomerRepository customerRepository;
@Autowired private EmailService emailService;
@Autowired private PaymentGateway paymentGateway;
@PostMapping
public ResponseEntity<?> createOrder(@RequestBody Map<String, Object> payload,
HttpServletRequest request) {
// Extract and validate fields inline
String customerId = (String) payload.get("customerId");
if (customerId == null) return ResponseEntity.badRequest().body("Missing customerId");
Customer customer = customerRepository.findById(Long.parseLong(customerId)).orElse(null);
if (customer == null) return ResponseEntity.badRequest().body("Customer not found");
List<Map<String, Object>> items = (List) payload.get("items");
if (items == null || items.isEmpty()) return ResponseEntity.badRequest().body("No items");
// Calculate total inline
BigDecimal total = BigDecimal.ZERO;
for (Map<String, Object> item : items) {
Long productId = Long.parseLong((String) item.get("productId"));
int qty = Integer.parseInt((String) item.get("quantity"));
Product p = productRepository.findById(productId).orElseThrow();
total = total.add(p.getPrice().multiply(BigDecimal.valueOf(qty)));
}
String coupon = (String) payload.get("couponCode");
if ("SAVE10".equals(coupon)) total = total.multiply(new BigDecimal("0.9"));
if ("SAVE20".equals(coupon)) total = total.multiply(new BigDecimal("0.8"));
// Payment
String cardNumber = (String) payload.get("cardNumber");
if (cardNumber == null) return ResponseEntity.badRequest().body("Missing card");
boolean charged = paymentGateway.charge(cardNumber, total);
if (!charged) return ResponseEntity.status(402).body("Payment failed");
// Persist
Order order = new Order();
order.setCustomer(customer);
order.setTotal(total);
order.setStatus("PLACED");
orderRepository.save(order);
// Notify
emailService.sendOrderConfirmation(customer.getEmail(), order.getId(), total);
return ResponseEntity.ok(Map.of("orderId", order.getId()));
}
// ... 450 more lines below
}
Step 1 — Extract Method: isolate logical blocks
// Within OrderController, extract validateCustomer, calculateOrderTotal, processPayment
private Customer validateCustomer(String customerId) {
if (customerId == null) throw new BadRequestException("Missing customerId");
return customerRepository.findById(Long.parseLong(customerId))
.orElseThrow(() -> new NotFoundException("Customer not found"));
}
private BigDecimal calculateOrderTotal(List<Map<String, Object>> items, String coupon) {
BigDecimal total = items.stream()
.map(item -> {
Product p = productRepository.findById(Long.parseLong((String)item.get("productId"))).orElseThrow();
return p.getPrice().multiply(BigDecimal.valueOf(Integer.parseInt((String)item.get("quantity"))));
})
.reduce(BigDecimal.ZERO, BigDecimal::add);
return applyCoupon(total, coupon);
}
Step 2 — Move Method: move business logic to OrderService
// Move calculateOrderTotal and applyCoupon to OrderService
@Service
public class OrderService {
public BigDecimal calculateTotal(List<OrderItemDto> items, String coupon) {
BigDecimal total = items.stream()
.map(i -> productRepository.findById(i.productId()).orElseThrow().getPrice()
.multiply(BigDecimal.valueOf(i.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
return couponService.apply(total, coupon);
}
public Order placeOrder(CreateOrderRequest req) {
Customer customer = customerService.getById(req.customerId());
BigDecimal total = calculateTotal(req.items(), req.couponCode());
paymentService.charge(req.cardNumber(), total);
return orderRepository.save(Order.of(customer, total));
}
}
Step 3 — Extract Class: split PaymentService out
// Extract payment concerns into dedicated PaymentService
@Service
public class PaymentService {
private final PaymentGateway gateway;
public void charge(String cardNumber, BigDecimal amount) {
boolean success = gateway.charge(cardNumber, amount);
if (!success) throw new PaymentFailedException("Card charge failed for amount: " + amount);
}
}
Step 4 — Introduce Parameter Object: replace Map payload with typed DTO
// Replace raw Map<String,Object> with a validated record
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty List<OrderItemDto> items,
String couponCode,
@NotBlank String cardNumber
) {}
public record OrderItemDto(
@NotNull Long productId,
@Min(1) int quantity
) {}
// Controller becomes thin:
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
Order order = orderService.placeOrder(request);
return ResponseEntity.ok(OrderResponse.from(order));
}
Step 5 — Replace Conditional with Polymorphism: coupon strategy
// Replace if/else coupon chain with Strategy
public interface CouponStrategy {
BigDecimal apply(BigDecimal amount);
String code();
}
@Component
public class Save10Coupon implements CouponStrategy {
public BigDecimal apply(BigDecimal amount) { return amount.multiply(new BigDecimal("0.90")); }
public String code() { return "SAVE10"; }
}
@Component
public class Save20Coupon implements CouponStrategy {
public BigDecimal apply(BigDecimal amount) { return amount.multiply(new BigDecimal("0.80")); }
public String code() { return "SAVE20"; }
}
@Service
public class CouponService {
private final Map<String, CouponStrategy> strategies;
public CouponService(List<CouponStrategy> list) {
this.strategies = list.stream()
.collect(Collectors.toMap(CouponStrategy::code, Function.identity()));
}
public BigDecimal apply(BigDecimal amount, String code) {
return strategies.getOrDefault(code, a -> a).apply(amount);
}
}
Before vs After — Metrics
| Metric | Before | After |
|---|---|---|
| OrderController lines | 500+ | ~40 |
| Cyclomatic complexity (createOrder) | 18 | 3 |
| Unit test coverage | 12% | 91% |
| Time to add a new discount type | ~2 hours (touch 3 files, risk regression) | ~15 min (add a new CouponStrategy @Component) |
9. IDE Tooling for Java Refactoring
Manual refactoring is error-prone. Modern Java IDEs automate the transformation steps safely, letting you focus on design decisions rather than mechanical edits. Always prefer IDE-automated refactorings over manual text editing — they update all references simultaneously.
IntelliJ IDEA — Essential Shortcuts
| Refactoring | Shortcut (macOS) | Shortcut (Windows/Linux) |
|---|---|---|
| Extract Method | ⌥⌘M | Ctrl+Alt+M |
| Extract Variable | ⌥⌘V | Ctrl+Alt+V |
| Rename | ⇧F6 | Shift+F6 |
| Move Class / Method | F6 | F6 |
| Extract Interface | Refactor menu → Extract Interface | Refactor menu → Extract Interface |
| Introduce Parameter Object | Refactor → Introduce Parameter Object | Refactor → Introduce Parameter Object |
| Inline Method / Variable | ⌥⌘N | Ctrl+Alt+N |
| Change Signature | ⌘F6 | Ctrl+F6 |
Eclipse & VS Code
- Eclipse: Alt+Shift+M (Extract Method), Alt+Shift+R (Rename). Full refactoring menu via Alt+Shift+T on any selection.
- VS Code + Extension Pack for Java: Right-click → Refactor… for Extract Method, Extract Variable, Rename. Language Server Protocol (LSP) powered by Eclipse JDT provides full refactoring support.
- Tip: Run all tests before AND after each automated refactoring, even though IDE refactorings are theoretically safe — edge cases with reflection or annotation processors can break silently.
Static Analysis Integration — SonarQube & SpotBugs
- SonarQube detects code smells automatically: Cognitive Complexity > 15, Methods > 30 lines, Classes > 200 lines — use these as refactoring backlogs.
- SpotBugs finds null dereference patterns and mutable field exposures that signal missing Encapsulate Field or Null Object refactorings.
- ArchUnit lets you write architecture tests that enforce your refactored design: "No class in the controller package may access a repository directly" — preventing future drift back to God Controllers.
- Workflow: Add SonarQube to your CI pipeline (GitHub Actions), review the code smell report weekly, and prioritise refactoring the highest-complexity methods in the hot path.
// ArchUnit test: controllers must not call repositories directly
@AnalyzeClasses(packages = "com.example.app")
class ArchitectureTest {
@ArchTest
static final ArchRule noDirectRepoAccessFromController =
noClasses().that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..repository..");
}
Refactoring Workflow — The Safe Sequence
- Identify a code smell (SonarQube, code review, or reading the code).
- Write or verify tests cover the code you're about to change.
- Apply one small, IDE-automated refactoring.
- Run the full test suite — all green.
- Commit the refactoring as a standalone commit (separate from feature changes).
- Repeat — do not batch multiple refactorings into one commit.
Refactoring is a professional discipline, not a luxury. The teams that ship fast and stay sane in large Java codebases are those that refactor continuously, commit to clean code as a team norm, and use their IDE automation rather than trying to be clever with manual edits. Apply one technique per day from this catalog and your codebase will look unrecognisable — in the best possible way — within a quarter.