Refactoring Strategies
Refactoring is the disciplined practice of restructuring code to improve its internal structure without changing its external behavior. Effective refactoring reduces complexity, improves maintainability, and makes future changes easier - all while preserving functionality.
Core Principles
- Preserve Behavior: Refactoring changes structure, never behavior - tests must pass before and after
- Small Steps: Make incremental changes with tests passing between each step
- Test Coverage First: Ensure comprehensive tests before refactoring to catch regressions
- Version Control Safety: Commit frequently to create rollback points
- Automated Tools: Use IDE refactoring tools rather than manual edits when possible
- Continuous Process: Refactor opportunistically during regular development, not in isolation
When to Refactor
Refactoring serves different purposes at different times. Understanding the right context ensures maximum impact.
Red-Green-Refactor (TDD Cycle)
Test-Driven Development includes refactoring as an integral step after making tests pass.
Process:
- Red: Write a test for new functionality - it fails because the feature doesn't exist yet
- Green: Write the simplest code to make the test pass - don't worry about elegance
- Refactor: Improve the code's design while keeping tests passing - remove duplication, clarify names, extract methods
// Red: Write the test
@Test
void shouldCalculateDiscountedPrice() {
Product product = new Product("Widget", new BigDecimal("100.00"));
BigDecimal discounted = product.applyDiscount(new BigDecimal("10"));
assertThat(discounted).isEqualByComparingTo(new BigDecimal("90.00"));
}
// Green: Make it pass (simple implementation)
public BigDecimal applyDiscount(BigDecimal discountPercent) {
return price.subtract(price.multiply(discountPercent).divide(new BigDecimal("100")));
}
// Refactor: Extract magic number, improve clarity
private static final BigDecimal PERCENTAGE_DIVISOR = new BigDecimal("100");
public BigDecimal applyDiscount(BigDecimal discountPercent) {
BigDecimal discountAmount = calculateDiscountAmount(discountPercent);
return price.subtract(discountAmount);
}
private BigDecimal calculateDiscountAmount(BigDecimal discountPercent) {
return price.multiply(discountPercent).divide(PERCENTAGE_DIVISOR);
}
The refactored version is more readable and maintainable. The calculation is clearer, the magic number is eliminated, and the discount calculation can be tested independently if needed.
Why this matters: TDD refactoring is safe because tests provide immediate feedback. Each small change is verified instantly. This tight feedback loop enables confident restructuring without fear of breaking functionality.
See Unit Testing for comprehensive TDD guidance.
Opportunistic Refactoring (Boy Scout Rule)
"Leave the code better than you found it." When touching code for any reason, make small improvements.
When adding a feature:
// While adding new payment method validation, you encounter:
public void processPayment(Payment p) {
if (p.amt <= 0) throw new Exception("Invalid");
// process payment
}
// Opportunistic refactoring during feature work:
public void processPayment(Payment payment) {
validatePaymentAmount(payment.getAmount());
// your new feature code
// process payment
}
private void validatePaymentAmount(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidPaymentException("Payment amount must be positive");
}
}
Guidelines:
- Keep refactoring scope small (5-10 minutes max)
- Stay related to your current change
- Don't refactor distant code just because you noticed it
- If larger refactoring is needed, create a separate task
Benefits: Continuous small improvements compound over time without dedicated refactoring effort. Code quality improves organically as developers work in different areas.
See Technical Debt Management for the Boy Scout Rule in context of debt paydown.
Preparatory Refactoring
Refactor existing code to make the upcoming change easier - "make the change easy, then make the easy change."
Scenario: You need to add credit card payment support to a system that only handles bank transfers.
// Current tightly coupled code
public class PaymentService {
public void processPayment(Payment payment) {
// Hard-coded bank transfer logic
BankTransferRequest request = new BankTransferRequest(
payment.getAccountNumber(),
payment.getRoutingNumber(),
payment.getAmount()
);
bankTransferClient.execute(request);
}
}
// Preparatory refactoring: Extract interface before adding credit cards
public interface PaymentMethod {
void execute(Payment payment);
}
public class BankTransferPaymentMethod implements PaymentMethod {
public void execute(Payment payment) {
BankTransferRequest request = new BankTransferRequest(
payment.getAccountNumber(),
payment.getRoutingNumber(),
payment.getAmount()
);
bankTransferClient.execute(request);
}
}
public class PaymentService {
private final Map<PaymentType, PaymentMethod> paymentMethods;
public void processPayment(Payment payment) {
PaymentMethod method = paymentMethods.get(payment.getType());
method.execute(payment);
}
}
// Now adding credit card support is easy
public class CreditCardPaymentMethod implements PaymentMethod {
public void execute(Payment payment) {
CreditCardRequest request = new CreditCardRequest(
payment.getCardNumber(),
payment.getCvv(),
payment.getAmount()
);
creditCardProcessor.process(request);
}
}
Process:
- Refactor to create extension points (interfaces, abstractions)
- Verify tests still pass
- Add new functionality using new structure
- Much easier than trying to add credit card support to the original tight coupling
Scheduled Refactoring Time
Dedicate specific time to address larger refactoring efforts that don't fit in opportunistic windows.
When to schedule:
- Code quality metrics show problematic areas (high complexity, low coverage)
- File changes frequently and has high defect density (high churn + complexity)
- Multiple developers report difficulty understanding or modifying code
- Preparing for a large feature that requires better structure
How to schedule:
- Allocate 20% of sprint capacity to refactoring work (see Technical Debt Management)
- Create refactoring tickets with clear scope and acceptance criteria
- Pair refactoring with related feature work when possible
Justification to stakeholders: Frame refactoring in terms of business impact - "This refactoring will reduce time to implement payment features by 40%" or "This reduces production incidents in checkout by addressing the underlying structural problems."
Common Refactoring Patterns
These patterns address frequent code smells and quality issues. Understanding them enables recognizing opportunities and applying solutions systematically.
Extract Method
Problem: Long methods that do too much - hard to understand, test, and reuse.
Solution: Extract portions into well-named helper methods that reveal intent.
// Before: Long method doing multiple things (15 lines, complexity 8)
function processOrder(order: Order): OrderResult {
if (!order.items || order.items.length === 0) {
return { success: false, error: 'Empty order' };
}
let total = 0;
for (const item of order.items) {
total += item.price * item.quantity;
}
if (order.discountCode) {
const discount = getDiscount(order.discountCode);
total = total * (1 - discount);
}
if (total > order.customer.creditLimit) {
return { success: false, error: 'Exceeds credit limit' };
}
saveOrder(order, total);
sendConfirmationEmail(order.customer.email);
return { success: true, orderId: order.id };
}
// After: Extracted methods (more readable, testable)
function processOrder(order: Order): OrderResult {
const validationError = validateOrder(order);
if (validationError) return validationError;
const total = calculateOrderTotal(order);
const creditCheckError = checkCreditLimit(total, order.customer);
if (creditCheckError) return creditCheckError;
finalizeOrder(order, total);
return { success: true, orderId: order.id };
}
function validateOrder(order: Order): OrderResult | null {
if (!order.items || order.items.length === 0) {
return { success: false, error: 'Empty order' };
}
return null;
}
function calculateOrderTotal(order: Order): number {
let total = calculateItemsTotal(order.items);
if (order.discountCode) {
total = applyDiscount(total, order.discountCode);
}
return total;
}
function calculateItemsTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
function applyDiscount(total: number, discountCode: string): number {
const discount = getDiscount(discountCode);
return total * (1 - discount);
}
function checkCreditLimit(total: number, customer: Customer): OrderResult | null {
if (total > customer.creditLimit) {
return { success: false, error: 'Exceeds credit limit' };
}
return null;
}
function finalizeOrder(order: Order, total: number): void {
saveOrder(order, total);
sendConfirmationEmail(order.customer.email);
}
Benefits:
- Main method reveals high-level flow at a glance
- Each extracted method can be tested independently
- Names serve as inline documentation
- Extracted methods can be reused elsewhere
- Reduces cyclomatic complexity of main method
When to apply: Methods longer than 20-30 lines or doing more than one conceptual task.
Replace Conditional with Polymorphism
Problem: Complex switch/case or if/else chains that handle different types.
Solution: Replace conditionals with polymorphism - each type knows how to handle itself.
// Before: Switch statement that grows with each new type
public BigDecimal calculateShipping(Order order) {
switch (order.getShippingMethod()) {
case STANDARD:
if (order.getWeight() < 5) return new BigDecimal("5.00");
return new BigDecimal("10.00");
case EXPRESS:
if (order.getWeight() < 5) return new BigDecimal("15.00");
return new BigDecimal("25.00");
case OVERNIGHT:
if (order.getWeight() < 5) return new BigDecimal("30.00");
return new BigDecimal("50.00");
default:
throw new IllegalArgumentException("Unknown shipping method");
}
}
// After: Polymorphic strategy pattern
public interface ShippingStrategy {
BigDecimal calculate(Order order);
}
public class StandardShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(Order order) {
return order.getWeight() < 5
? new BigDecimal("5.00")
: new BigDecimal("10.00");
}
}
public class ExpressShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(Order order) {
return order.getWeight() < 5
? new BigDecimal("15.00")
: new BigDecimal("25.00");
}
}
public class OvernightShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(Order order) {
return order.getWeight() < 5
? new BigDecimal("30.00")
: new BigDecimal("50.00");
}
}
public class ShippingCalculator {
private final Map<ShippingMethod, ShippingStrategy> strategies;
public ShippingCalculator() {
strategies = Map.of(
ShippingMethod.STANDARD, new StandardShipping(),
ShippingMethod.EXPRESS, new ExpressShipping(),
ShippingMethod.OVERNIGHT, new OvernightShipping()
);
}
public BigDecimal calculate(Order order) {
ShippingStrategy strategy = strategies.get(order.getShippingMethod());
if (strategy == null) {
throw new IllegalArgumentException("Unknown shipping method");
}
return strategy.calculate(order);
}
}
Benefits:
- Adding new shipping methods requires creating a new class, not modifying existing code (Open/Closed Principle)
- Each strategy is independently testable
- No conditional complexity in main code
- Type-safe - compiler ensures all shipping methods have implementation
When to apply: Switch statements or if/else chains based on type that appear in multiple places or grow with each new type.
Extract Class
Problem: Class doing too much, violating Single Responsibility Principle.
Solution: Extract related behavior into a separate class.
// Before: User class handling too many concerns
public class User {
private String username;
private String email;
private String passwordHash;
// User management
public void updateProfile(String email) { }
public void changePassword(String newPassword) { }
// Address management
private String street;
private String city;
private String zipCode;
public void updateAddress(String street, String city, String zip) { }
public String getFormattedAddress() { }
// Payment management
private String cardNumber;
private String cardExpiry;
public void updatePaymentMethod(String cardNumber, String expiry) { }
public boolean validatePaymentMethod() { }
// Notification preferences
private boolean emailNotifications;
private boolean smsNotifications;
public void setNotificationPreferences(boolean email, boolean sms) { }
}
// After: Extracted cohesive classes
public class User {
private String username;
private String email;
private String passwordHash;
private Address address;
private PaymentMethod paymentMethod;
private NotificationPreferences notificationPreferences;
public void updateProfile(String email) {
this.email = email;
}
public void changePassword(String newPassword) {
this.passwordHash = hashPassword(newPassword);
}
public void updateAddress(Address newAddress) {
this.address = newAddress;
}
public void updatePaymentMethod(PaymentMethod method) {
this.paymentMethod = method;
}
public void updateNotificationPreferences(NotificationPreferences prefs) {
this.notificationPreferences = prefs;
}
}
public class Address {
private String street;
private String city;
private String zipCode;
public String getFormattedAddress() {
return String.format("%s, %s %s", street, city, zipCode);
}
public boolean isValidZipCode() {
return zipCode.matches("\\d{5}");
}
}
public class PaymentMethod {
private String cardNumber;
private String cardExpiry;
public boolean isValid() {
return isCardNumberValid() && isNotExpired();
}
private boolean isCardNumberValid() {
// Luhn algorithm validation
}
private boolean isNotExpired() {
// Expiry date validation
}
}
public class NotificationPreferences {
private boolean emailEnabled;
private boolean smsEnabled;
public boolean shouldNotifyByEmail() {
return emailEnabled;
}
public boolean shouldNotifyBySms() {
return smsEnabled;
}
}
Benefits:
- Each class has a single, well-defined responsibility
- Easier to test - Address validation doesn't require creating a full User
- Reduces coupling - changes to payment methods don't affect address handling
- Improves reusability - Address can be used in other contexts (Order, Branch, etc.)
When to apply: Class has many instance variables and methods that cluster around different concepts, or class has grown beyond 200-300 lines.
Introduce Parameter Object
Problem: Methods with long parameter lists are hard to read and maintain.
Solution: Group related parameters into a cohesive object.
// Before: Long parameter list (7 parameters!)
function createPayment(
accountId: string,
amount: number,
currency: string,
description: string,
reference: string,
timestamp: Date,
ipAddress: string
): Payment {
// Create payment
}
// Calling code is hard to read
const payment = createPayment(
'ACC123',
100.00,
'USD',
'Monthly subscription',
'REF-001',
new Date(),
'192.168.1.1'
);
// After: Parameter object groups related data
interface PaymentRequest {
accountId: string;
amount: number;
currency: string;
description: string;
reference: string;
timestamp: Date;
ipAddress: string;
}
function createPayment(request: PaymentRequest): Payment {
// Create payment
}
// Calling code is clearer
const payment = createPayment({
accountId: 'ACC123',
amount: 100.00,
currency: 'USD',
description: 'Monthly subscription',
reference: 'REF-001',
timestamp: new Date(),
ipAddress: '192.168.1.1'
});
Benefits:
- Named parameters make calls self-documenting
- Easy to add new parameters (add field to object, provide default)
- Parameter validation can be centralized in the object
- Parameter objects can have helper methods
When to apply: Methods with more than 3-4 parameters, especially when parameters are logically related.
Replace Magic Numbers with Named Constants
Problem: Unexplained numeric literals throughout code reduce readability.
Solution: Extract magic numbers into well-named constants that explain their purpose.
// Before: Magic numbers scattered throughout
public class FeeCalculator {
public BigDecimal calculate(BigDecimal amount) {
if (amount.compareTo(new BigDecimal("1000")) < 0) {
return amount.multiply(new BigDecimal("0.03"));
} else if (amount.compareTo(new BigDecimal("10000")) < 0) {
return amount.multiply(new BigDecimal("0.025"));
} else {
return amount.multiply(new BigDecimal("0.02"));
}
}
}
// After: Named constants explain meaning
public class FeeCalculator {
private static final BigDecimal TIER_1_THRESHOLD = new BigDecimal("1000");
private static final BigDecimal TIER_2_THRESHOLD = new BigDecimal("10000");
private static final BigDecimal TIER_1_FEE_RATE = new BigDecimal("0.03"); // 3%
private static final BigDecimal TIER_2_FEE_RATE = new BigDecimal("0.025"); // 2.5%
private static final BigDecimal TIER_3_FEE_RATE = new BigDecimal("0.02"); // 2%
public BigDecimal calculate(BigDecimal amount) {
if (amount.compareTo(TIER_1_THRESHOLD) < 0) {
return calculateFee(amount, TIER_1_FEE_RATE);
} else if (amount.compareTo(TIER_2_THRESHOLD) < 0) {
return calculateFee(amount, TIER_2_FEE_RATE);
} else {
return calculateFee(amount, TIER_3_FEE_RATE);
}
}
private BigDecimal calculateFee(BigDecimal amount, BigDecimal rate) {
return amount.multiply(rate);
}
}
Benefits:
- Code is self-documenting - names explain what numbers mean
- Centralized definition - changing a threshold updates all usages
- Prevents typos - mistyping
0.03as0.003is easy, but constant provides single source of truth - Enables configuration - constants can be promoted to configuration when needed
When to apply: Any numeric literal (except 0, 1, -1) that appears more than once or whose meaning isn't immediately obvious.
Replace Nested Conditionals with Guard Clauses
Problem: Deep nesting makes code hard to follow and increases cognitive complexity.
Solution: Use early returns (guard clauses) to handle edge cases first, leaving main logic unnested.
// Before: Nested conditions create "arrow" code
fun processPayment(payment: Payment?): PaymentResult {
if (payment != null) {
if (payment.amount > BigDecimal.ZERO) {
if (isAccountValid(payment.accountId)) {
if (hasSufficientBalance(payment)) {
if (!isDuplicate(payment)) {
return executePayment(payment)
} else {
return PaymentResult.duplicate()
}
} else {
return PaymentResult.insufficientFunds()
}
} else {
return PaymentResult.invalidAccount()
}
} else {
return PaymentResult.invalidAmount()
}
}
return PaymentResult.nullPayment()
}
// After: Guard clauses flatten structure
fun processPayment(payment: Payment?): PaymentResult {
if (payment == null) return PaymentResult.nullPayment()
if (payment.amount <= BigDecimal.ZERO) return PaymentResult.invalidAmount()
if (!isAccountValid(payment.accountId)) return PaymentResult.invalidAccount()
if (!hasSufficientBalance(payment)) return PaymentResult.insufficientFunds()
if (isDuplicate(payment)) return PaymentResult.duplicate()
return executePayment(payment)
}
Benefits:
- Reduced cognitive complexity - reader only thinks about one condition at a time
- Main logic stands out at end - happy path is clear
- Easier to add new validations - insert new guard clause without touching existing logic
- Eliminates else blocks - control flow is explicit
When to apply: Functions with nesting depth >2, especially validation or error-checking logic.
Refactoring Safely
Refactoring changes code structure, so ensuring behavioral preservation is critical. These practices minimize risk.
Comprehensive Test Coverage First
Never refactor without tests. Tests are your safety net, proving behavior remains unchanged.
// BAD: Refactoring without tests
public class LegacyPaymentProcessor {
// 500 lines of complex, untested code
}
// Attempting to refactor this is dangerous without tests
// GOOD: Add characterization tests first
@Test
void shouldProcessStandardPayment() {
// Characterization test: captures current behavior
Payment payment = createStandardPayment();
Result result = legacyProcessor.process(payment);
// Even if you don't understand the code, capture what it does now
assertThat(result.getStatus()).isEqualTo("PROCESSED");
assertThat(result.getFeeAmount()).isEqualByComparingTo(new BigDecimal("2.50"));
}
@Test
void shouldRejectInvalidAccount() {
Payment payment = createPaymentWithInvalidAccount();
Result result = legacyProcessor.process(payment);
assertThat(result.getStatus()).isEqualTo("REJECTED");
assertThat(result.getErrorCode()).isEqualTo("INVALID_ACCOUNT");
}
// Once you have comprehensive tests, refactor safely
Characterization tests document current behavior without requiring understanding of implementation. They capture what the code does now (even if that's buggy) so you can verify refactoring doesn't change it. After refactoring, you can fix bugs with confidence that you're only changing intended behavior.
See Unit Testing for test writing guidance and Mutation Testing for validating test quality.
Coverage targets before refactoring:
- Critical code: 90%+ coverage
- Complex code: 80%+ coverage
- Legacy code: At least 70% coverage
Use code coverage tools to measure and track coverage.
Small, Incremental Steps
Make one change at a time. After each step, run tests and verify they pass.
// Step 1: Extract method (run tests, verify green)
function processOrder(order: Order): void {
const total = calculateTotal(order.items); // Extracted
applyDiscount(order, total);
finalizeOrder(order, total);
}
function calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Tests pass ✓
// Step 2: Extract another method (run tests, verify green)
function processOrder(order: Order): void {
const total = calculateTotal(order.items);
const discountedTotal = applyDiscount(order, total); // Extracted
finalizeOrder(order, discountedTotal);
}
function applyDiscount(order: Order, total: number): number {
if (!order.discountCode) return total;
const discount = getDiscountAmount(order.discountCode, total);
return total - discount;
}
// Tests pass ✓
// Step 3: Continue extracting one piece at a time
Why small steps matter: If tests fail, you know exactly which change caused it. Large refactorings with many simultaneous changes make debugging difficult - you can't isolate the breaking change.
Rollback strategy: If tests fail and the fix isn't obvious within 5 minutes, revert the change (git checkout .) and try again with a smaller step.
Use Automated Refactoring Tools
IDEs provide automated refactorings that correctly handle all references, imports, and edge cases.
Safe automated refactorings:
- Rename (variable, method, class): Updates all references automatically
- Extract Method: Identifies correct parameters and return types
- Move Method/Class: Updates imports and package references
- Change Signature: Updates all call sites with new parameters
- Inline Variable/Method: Replaces usages with definition
// Manual rename: Error-prone, might miss references
// Before:
public void calc(Payment p) { }
public void process() {
calc(pmt); // Oops, forgot to rename this reference
}
// Automated rename (IntelliJ: Right-click → Refactor → Rename)
// Safely renames all references
public void calculate(Payment payment) { }
public void process() {
calculate(payment); // Automatically updated
}
Keyboard shortcuts (IntelliJ IDEA):
- Extract Method:
Ctrl+Alt+M(Windows/Linux),Cmd+Option+M(Mac) - Rename:
Shift+F6 - Change Signature:
Ctrl+F6(Windows/Linux),Cmd+F6(Mac) - Extract Variable:
Ctrl+Alt+V(Windows/Linux),Cmd+Option+V(Mac)
See IDE Setup for configuring IDE refactoring tools.
Version Control as Safety Net
Commit frequently during refactoring to create rollback points.
Recommended commit strategy:
# Initial commit: Working code with tests
git add .
git commit -m "Add tests for PaymentProcessor before refactoring"
# After each successful refactoring step
git add .
git commit -m "Extract calculateTotal method"
# Tests pass
git add .
git commit -m "Extract applyDiscount method"
# Tests pass
git add .
git commit -m "Extract finalizeOrder method"
# If something breaks and you can't fix quickly
git reset --hard HEAD # Rollback to last commit
Benefits:
- Granular history shows refactoring progression
- Easy rollback to last known-good state
- Each commit is a savepoint
- Can cherry-pick specific refactorings if needed
PR/MR strategy: For large refactorings, create separate commits for each logical step. This makes review easier - reviewers can see the transformation incrementally rather than a massive diff.
See Git Workflow for commit best practices.
Legacy Code Refactoring
Legacy code - code without tests or with unclear structure - requires special strategies.
Characterization Tests
Before refactoring legacy code, write tests that capture current behavior (even if it's wrong).
Process:
// Step 1: Write a test that you think should pass
@Test
void shouldCalculateFee() {
LegacyFeeCalculator calculator = new LegacyFeeCalculator();
BigDecimal fee = calculator.calculate(new BigDecimal("100.00"));
// Guess: Fee is probably 3% = $3.00
assertThat(fee).isEqualByComparingTo(new BigDecimal("3.00"));
}
// Step 2: Run the test - it fails
// Expected: 3.00
// Actual: 3.50
// Step 3: Update assertion to match actual behavior
@Test
void shouldCalculateFee() {
LegacyFeeCalculator calculator = new LegacyFeeCalculator();
BigDecimal fee = calculator.calculate(new BigDecimal("100.00"));
// Document current behavior (even if we don't understand why 3.50)
assertThat(fee).isEqualByComparingTo(new BigDecimal("3.50"));
}
// Step 4: Repeat for many scenarios to build comprehensive test suite
// Step 5: Now refactor safely, keeping tests green
Purpose: Characterization tests lock down current behavior so you can refactor structure without changing outcomes. After refactoring, if behavior should change (bug fix), you update tests intentionally.
How many tests? Aim for:
- Each code path exercised at least once
- Boundary conditions covered
- Known edge cases included
- 70-80% code coverage minimum
Strangler Fig Pattern
When dealing with large legacy systems, incrementally replace old code with new implementations rather than "big bang" rewrites.
Pattern named after strangler fig vines: These vines grow around trees, eventually replacing them entirely. Similarly, new code grows around legacy code, eventually replacing it.
Implementation:
// Step 1: Create abstraction layer
public interface PaymentProcessor {
PaymentResult process(Payment payment);
}
// Step 2: Wrap existing legacy code
public class LegacyPaymentProcessor implements PaymentProcessor {
private final OldPaymentSystem oldSystem;
@Override
public PaymentResult process(Payment payment) {
// Delegate to existing legacy code (unchanged)
return oldSystem.processPayment(payment);
}
}
// Step 3: Implement new version alongside legacy
public class ModernPaymentProcessor implements PaymentProcessor {
@Override
public PaymentResult process(Payment payment) {
// New, clean implementation
validatePayment(payment);
executePayment(payment);
auditPayment(payment);
return PaymentResult.success();
}
}
// Step 4: Router gradually shifts traffic
public class PaymentProcessorRouter implements PaymentProcessor {
private final PaymentProcessor legacyProcessor;
private final PaymentProcessor modernProcessor;
private final FeatureFlags featureFlags;
@Override
public PaymentResult process(Payment payment) {
if (featureFlags.useModernPaymentProcessor()) {
try {
// Send 10% of traffic to new implementation
return modernProcessor.process(payment);
} catch (Exception e) {
// Fallback to legacy on errors during migration
logger.error("Modern processor failed, falling back to legacy", e);
return legacyProcessor.process(payment);
}
}
// 90% still uses legacy
return legacyProcessor.process(payment);
}
}
// Step 5: Gradually increase feature flag percentage
// Week 1: 10% modern
// Week 2: 25% modern (if no issues)
// Week 3: 50% modern
// Week 4: 100% modern
// Step 6: Remove legacy code entirely
Benefits:
- Low risk - legacy code continues working during migration
- Incremental validation - catch issues with small percentage of traffic
- Reversible - can dial back if problems emerge
- Continuous delivery - each phase can be deployed independently
When to use: Large legacy systems (>10,000 lines), critical production code, or systems without tests where rewrite risk is high.
See Feature Flags for implementing gradual rollout strategies and Technical Debt for the strangler pattern in debt paydown context.
Seam Identification
Seams are places where behavior can be changed without editing code at that location - enablers for testing and refactoring.
Types of seams:
Object seams (dependency injection):
// Before: Hard-coded dependency (no seam)
public class OrderService {
private EmailService emailService = new EmailService(); // Hard-coded!
public void processOrder(Order order) {
// Can't test without sending real emails
emailService.send(order.getCustomerEmail(), "Order confirmed");
}
}
// After: Dependency injection creates seam
public class OrderService {
private final EmailService emailService;
public OrderService(EmailService emailService) { // Seam: can inject mock
this.emailService = emailService;
}
public void processOrder(Order order) {
emailService.send(order.getCustomerEmail(), "Order confirmed");
}
}
// Now testable
@Test
void shouldSendConfirmationEmail() {
EmailService mockEmail = mock(EmailService.class);
OrderService service = new OrderService(mockEmail);
service.processOrder(order);
verify(mockEmail).send("[email protected]", "Order confirmed");
}
Preprocessing seams (environment variables, feature flags):
// Seam: Behavior changes based on environment variable
const paymentProcessor = process.env.USE_MOCK_PAYMENTS
? new MockPaymentProcessor()
: new RealPaymentProcessor();
// In tests, set USE_MOCK_PAYMENTS=true to avoid real payment calls
Identifying seams in legacy code: Look for:
- Constructor calls that could become parameters
- Static method calls that could become instance methods
- Global state that could become injected dependencies
- Hard-coded values that could become configuration
Creating seams enables testing, which enables safe refactoring. It's often the first step in legacy code improvement.
Parallel Run
For critical systems, run old and new implementations simultaneously, comparing results.
public class SafetyNetPaymentProcessor implements PaymentProcessor {
private final PaymentProcessor legacyProcessor;
private final PaymentProcessor newProcessor;
private final ComparisonService comparisonService;
@Override
public PaymentResult process(Payment payment) {
// Primary: Use legacy (trusted)
PaymentResult legacyResult = legacyProcessor.process(payment);
// Secondary: Run new implementation (don't trust yet)
CompletableFuture.runAsync(() -> {
try {
PaymentResult newResult = newProcessor.process(payment);
// Compare results
if (!comparisonService.resultsMatch(legacyResult, newResult)) {
logger.warn("New processor diverged from legacy: {}",
comparisonService.describeDifference(legacyResult, newResult));
metricsService.recordDivergence(payment, legacyResult, newResult);
}
} catch (Exception e) {
logger.error("New processor threw exception", e);
metricsService.recordError(payment, e);
}
});
// Return legacy result (safe)
return legacyResult;
}
}
Process:
- Run both implementations in parallel
- Return result from trusted (legacy) implementation
- Log any differences between results
- Investigate divergences to fix new implementation
- Once results consistently match (99.9%+), switch to new implementation
- Remove legacy code
When to use: Mission-critical systems (payment processing, financial calculations) where bugs would have severe business impact.
Managing Refactoring in Pull Requests
Large refactoring PRs are difficult to review. These strategies improve reviewability.
Separate Refactoring from Feature Work
Bad approach: Mix refactoring with feature development in one PR.
PR #123: Add credit card payment support
Files changed: 47
Lines changed: +1,847, -923
Changes include:
- Refactored existing payment code
- Added credit card processing
- Renamed variables for clarity
- Extracted helper methods
- Implemented new feature
Reviewer thinks: "I can't tell which changes are pure refactoring vs. new behavior. This would take hours to review thoroughly."
Good approach: Separate commits or PRs.
PR #123: Refactor payment processing to support multiple payment types
Files changed: 12
Lines changed: +342, -289
Pure refactoring:
- Extract PaymentMethod interface
- Implement BankTransferPaymentMethod
- Update PaymentService to use strategy pattern
- All tests green, behavior unchanged
PR #124: Add credit card payment support
Files changed: 5
Lines changed: +156, -12
Depends on: PR #123
New feature:
- Implement CreditCardPaymentMethod
- Add credit card validation
- Update tests for credit card scenarios
Benefits:
- Refactoring PR: Reviewer verifies tests pass, no behavior change
- Feature PR: Reviewer focuses on new functionality
- Clearer git history
- Easier rollback if feature has issues
Keep Changes Focused
One logical change per commit/PR:
# Good: Focused commits
git commit -m "Extract PaymentValidator class"
git commit -m "Replace conditional with polymorphism in ShippingCalculator"
git commit -m "Introduce parameter object for payment creation"
# Bad: Kitchen sink commit
git commit -m "Various improvements"
# Changes: 15 files, multiple refactorings mixed together
Incremental Refactoring Strategy
For large refactorings that would create massive PRs, break into smaller PRs:
Phase 1 PR: Extract interfaces
Phase 2 PR: Implement strategy pattern for existing logic
Phase 3 PR: Add new payment type using new structure
Phase 4 PR: Migrate remaining code to new structure
Phase 5 PR: Remove deprecated legacy code
Each PR is reviewable in isolation (<400 lines changed). See Pull Requests for PR sizing guidance.
Refactoring Tools
IDE Refactoring Features
IntelliJ IDEA (Java, Kotlin):
- Extract Method/Variable/Constant
- Inline Method/Variable
- Rename (safe across entire codebase)
- Change Signature (updates all call sites)
- Move Class/Method
- Extract Interface/Superclass
- Pull Members Up/Push Members Down
VS Code (TypeScript, JavaScript):
- Extract to function/constant/type
- Rename symbol (all references)
- Move to new file
- Organize imports
- Convert to arrow function/regular function
Xcode (Swift):
- Extract Method
- Rename
- Extract Variable
- Create Extension
See IDE Setup for configuring refactoring shortcuts.
Static Analysis Tools
Tools identify code smells and refactoring opportunities:
SonarQube:
- Highlights code smells (complexity, duplication)
- Suggests refactorings
- Tracks technical debt
ESLint/Prettier (JavaScript/TypeScript):
- Auto-fix code style issues
- Detect code smells
- Enforce consistent formatting
Checkstyle/SpotBugs (Java):
- Identify problematic patterns
- Detect complexity hotspots
- Flag duplication
See Code Quality Metrics for comprehensive tooling guidance.
AST-Based Refactoring
For large-scale automated refactorings:
jscodeshift (JavaScript/TypeScript):
// Automated refactoring: Rename all occurrences of method across codebase
module.exports = function(fileInfo, api) {
const j = api.jscodeshift;
const root = j(fileInfo.source);
// Find all call expressions to oldMethodName and rename
root.find(j.CallExpression, {
callee: { name: 'oldMethodName' }
}).forEach(path => {
path.value.callee.name = 'newMethodName';
});
return root.toSource();
};
// Run across entire codebase
jscodeshift -t rename-transform.js src/
OpenRewrite (Java):
// Automated recipe: Migrate Spring Boot 2.x to 3.x
public class SpringBoot3Migration extends Recipe {
@Override
public String getDisplayName() {
return "Migrate Spring Boot 2.x to 3.x";
}
@Override
public List<Recipe> getRecipeList() {
return Arrays.asList(
new UpgradeSpringBootDependencies(),
new RenamePackages(),
new UpdateDeprecatedAPIs()
);
}
}
// Run migration (Gradle)
./gradlew rewriteRun --no-daemon
When to use: Large codebases (>100k lines) where manual refactoring would take weeks, or organization-wide API migrations.
Anti-Patterns to Avoid
Refactoring Without Tests
Problem: Changing structure without verification breaks functionality silently.
Example:
// Refactoring without tests
class OrderService {
fun calculateTotal(items: List<Item>): Double {
var total = 0.0
for (item in items) {
total += item.price * item.quantity
// Oops! Forgot to apply discount in refactored code
}
return total
}
}
// Original had discount logic - refactoring broke it, but no tests to catch it
Solution: Write tests first, verify they pass, then refactor. See Comprehensive Test Coverage First.
Large, Unfocused Refactorings
Problem: Changing too much at once makes debugging failures impossible.
Solution: Small, incremental steps with tests passing between each change. See Small, Incremental Steps.
Refactoring as Procrastination
Problem: Using refactoring to avoid delivering features.
Example: "Before I add this button, I need to refactor the entire component architecture" (when a simple change would suffice).
Solution: Apply preparatory refactoring only when it genuinely simplifies the upcoming change. If refactoring takes longer than implementing the feature in current structure, do the feature first, then refactor if needed.
Ignoring Context
Problem: Blindly applying patterns without understanding whether they improve the specific situation.
Example: Introducing complex design patterns (Factory, Builder, Strategy) for simple, stable code that won't change.
Solution: Refactor based on actual pain points (frequent bugs, slow feature development, difficulty understanding) rather than perceived "cleanliness."
Further Reading
Internal Resources
- Code Quality Metrics - Identifying refactoring opportunities through metrics
- Technical Debt Management - Strategic approaches to refactoring legacy code
- Unit Testing - Building test coverage for safe refactoring
- Mutation Testing - Validating test quality before refactoring
- Code Review - Reviewing refactoring PRs
- Pull Requests - Structuring refactoring PRs for reviewability
External Resources
- "Refactoring: Improving the Design of Existing Code" by Martin Fowler - Definitive catalog of refactoring patterns with detailed explanations
- "Working Effectively with Legacy Code" by Michael Feathers - Strategies for refactoring code without tests
- "Refactoring to Patterns" by Joshua Kerievsky - Connecting refactoring to design patterns
- "Clean Code" by Robert C. Martin - Code quality principles that guide refactoring decisions
Tools and Platforms
- IntelliJ IDEA Refactoring - Comprehensive refactoring tool documentation
- VS Code Refactoring - TypeScript/JavaScript refactoring features
- jscodeshift - Large-scale JavaScript refactoring automation
- OpenRewrite - Automated Java refactoring recipes
Summary
Key Takeaways:
- Preserve Behavior: Refactoring changes structure, never behavior - tests must pass throughout
- Test Coverage First: Never refactor code without comprehensive tests providing a safety net
- Small, Incremental Steps: Make one change at a time with tests passing between steps
- Use Automated Tools: IDE refactorings handle edge cases better than manual edits
- Version Control Safety Net: Commit frequently to create rollback points
- Legacy Code Requires Special Care: Characterization tests, strangler pattern, and seam identification enable safe improvement
- Separate Refactoring from Features: Keep refactoring PRs separate from feature PRs for easier review
- Context Matters: Refactor based on actual pain points, not theoretical cleanliness
- Continuous Process: Opportunistic refactoring during feature work compounds over time
- Measure Impact: Use code quality metrics to validate refactoring improves maintainability