15 Proven Code Refactoring Techniques to Supercharge Your Java Projects
Learn 15 practical code refactoring techniques—from extracting methods and introducing explanatory variables to applying design patterns like Strategy and Builder—that transform tangled Java code into clean, modular, and maintainable solutions, boosting readability and simplifying future enhancements.
Introduction
Many developers encounter situations where they inherit a legacy project with code that looks like a tangled mess, or their own code becomes increasingly bloated and hard to maintain as business requirements evolve. In such cases, code refactoring becomes essential. This article discusses 15 practical code refactoring techniques to help you improve your codebase.
1. Extract Method
Sometimes developers write methods that span hundreds of lines and contain various business logic, making the code hard to read, maintain, and test.
Before refactoring:
public void processOrder(Order order) {
// Validate order
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order items cannot be null");
}
if (order.getCustomer() == null) {
throw new IllegalArgumentException("Customer information cannot be null");
}
// Calculate total price
double total = 0;
for (OrderItem item : order.getItems()) {
double price = item.getPrice();
int quantity = item.getQuantity();
total += price * quantity;
}
// Apply discount
if (order.getCustomer().isVip()) {
total = total * 0.9;
}
// Save order
order.setTotal(total);
orderRepository.save(order);
// Send notification
String message = "Order " + order.getId() + " processed, total: " + total;
emailService.sendEmail(order.getCustomer().getEmail(), "Order Processed Notification", message);
smsService.sendSms(order.getCustomer().getPhone(), message);
}After refactoring:
public void processOrder(Order order) {
validateOrder(order);
double total = calculateOrderTotal(order);
total = applyDiscount(order, total);
saveOrder(order, total);
sendNotifications(order, total);
}
private void validateOrder(Order order) {
if (order == null) {
throw new IllegalArgumentException("Order cannot be null");
}
if (order.getItems() == null || order.getItems().isEmpty()) {
throw new IllegalArgumentException("Order items cannot be null");
}
if (order.getCustomer() == null) {
throw new IllegalArgumentException("Customer information cannot be null");
}
}
private double calculateOrderTotal(Order order) {
double total = 0;
for (OrderItem item : order.getItems()) {
double price = item.getPrice();
int quantity = item.getQuantity();
total += price * quantity;
}
return total;
}
private double applyDiscount(Order order, double total) {
if (order.getCustomer().isVip()) {
return total * 0.9;
}
return total;
}
private void saveOrder(Order order, double total) {
order.setTotal(total);
orderRepository.save(order);
}
private void sendNotifications(Order order, double total) {
String message = "Order " + order.getId() + " processed, total: " + total;
emailService.sendEmail(order.getCustomer().getEmail(), "Order Processed Notification", message);
smsService.sendSms(order.getCustomer().getPhone(), message);
}By extracting methods, we split a large method into several small ones, each responsible for a single function, improving readability and making each method easier to test and maintain.
2. Introduce Explaining Variable
Complex expressions can be hard to understand. Introducing a well‑named variable clarifies the intent.
Before refactoring:
public boolean isEligibleForDiscount(Customer customer, Order order) {
return (customer.getAge() >= 60 || (customer.getMembershipYears() > 5 && order.getTotal() > 1000)) && !customer.hasOutstandingPayments();
}After refactoring:
public boolean isEligibleForDiscount(Customer customer, Order order) {
boolean isSenior = customer.getAge() >= 60;
boolean isLoyalCustomerWithLargeOrder = customer.getMembershipYears() > 5 && order.getTotal() > 1000;
boolean hasNoOutstandingPayments = !customer.hasOutstandingPayments();
return (isSenior || isLoyalCustomerWithLargeOrder) && hasNoOutstandingPayments;
}The explanatory variables make the condition easier to read and understand.
3. Replace Conditional with Polymorphism
Using many if‑else or switch statements to handle different types violates the Open/Closed Principle.
Before refactoring:
public void processPayment(Payment payment) {
if (payment.getType().equals("CREDIT_CARD")) {
// credit card logic
validateCreditCard(payment);
chargeCreditCard(payment);
} else if (payment.getType().equals("PAYPAL")) {
// PayPal logic
validatePayPalAccount(payment);
chargePayPalAccount(payment);
} else if (payment.getType().equals("BANK_TRANSFER")) {
// bank transfer logic
validateBankAccount(payment);
initiateTransfer(payment);
} else {
throw new IllegalArgumentException("Unsupported payment type: " + payment.getType());
}
}After refactoring:
public interface PaymentProcessor {
void processPayment(Payment payment);
}
public class CreditCardProcessor implements PaymentProcessor {
@Override
public void processPayment(Payment payment) {
validateCreditCard(payment);
chargeCreditCard(payment);
}
}
public class PayPalProcessor implements PaymentProcessor {
@Override
public void processPayment(Payment payment) {
validatePayPalAccount(payment);
chargePayPalAccount(payment);
}
}
public class BankTransferProcessor implements PaymentProcessor {
@Override
public void processPayment(Payment payment) {
validateBankAccount(payment);
initiateTransfer(payment);
}
}
public class PaymentService {
private Map<String, PaymentProcessor> processors = new HashMap<>();
public PaymentService() {
processors.put("CREDIT_CARD", new CreditCardProcessor());
processors.put("PAYPAL", new PayPalProcessor());
processors.put("BANK_TRANSFER", new BankTransferProcessor());
}
public void processPayment(Payment payment) {
PaymentProcessor processor = processors.get(payment.getType());
if (processor == null) {
throw new IllegalArgumentException("Unsupported payment type: " + payment.getType());
}
processor.processPayment(payment);
}
}Polymorphism distributes the logic into separate classes, making the code modular and easier to extend.
4. Remove Duplicate Code
Duplicate code increases maintenance effort and the risk of bugs.
Before refactoring:
public class UserService {
public User findUserById(Long id) {
Logger logger = LoggerFactory.getLogger(UserService.class);
logger.info("Query user, ID: " + id);
User user = userRepository.findById(id);
if (user == null) {
logger.error("User not found, ID: " + id);
throw new UserNotFoundException("User not found, ID: " + id);
}
return user;
}
public User findUserByEmail(String email) {
Logger logger = LoggerFactory.getLogger(UserService.class);
logger.info("Query user, Email: " + email);
User user = userRepository.findByEmail(email);
if (user == null) {
logger.error("User not found, Email: " + email);
throw new UserNotFoundException("User not found, Email: " + email);
}
return user;
}
}After refactoring:
public class UserService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
public User findUserById(Long id) {
logger.info("Query user, ID: " + id);
return findUserOrThrow(() -> userRepository.findById(id), "User not found, ID: " + id);
}
public User findUserByEmail(String email) {
logger.info("Query user, Email: " + email);
return findUserOrThrow(() -> userRepository.findByEmail(email), "User not found, Email: " + email);
}
private User findUserOrThrow(Supplier<User> finder, String errorMessage) {
User user = finder.get();
if (user == null) {
logger.error(errorMessage);
throw new UserNotFoundException(errorMessage);
}
return user;
}
}Extracting a common method eliminates duplication, making future changes localized.
5. Introduce Parameter Object
When a method has many parameters that often appear together, encapsulate them into a single object.
Before refactoring:
public Report generateReport(String startDate, String endDate, String department, String format, boolean includeCharts) { /* ... */ }
public void emailReport(String startDate, String endDate, String department, String format, boolean includeCharts, String email) { /* ... */ }
public void saveReport(String startDate, String endDate, String department, String format, boolean includeCharts, String filePath) { /* ... */ }After refactoring:
public class ReportCriteria {
private String startDate;
private String endDate;
private String department;
private String format;
private boolean includeCharts;
// constructors, getters, setters
}
public class ReportGenerator {
public Report generateReport(ReportCriteria criteria) { /* ... */ }
public void emailReport(ReportCriteria criteria, String email) { /* ... */ }
public void saveReport(ReportCriteria criteria, String filePath) { /* ... */ }
}The parameter object reduces the number of parameters and can hold related behavior.
6. Use Strategy Pattern
When different algorithms or strategies are handled with many conditionals, the Strategy pattern provides a clean solution.
Before refactoring:
public double calculateShippingCost(Order order, String shippingMethod) {
if (shippingMethod.equals("STANDARD")) {
return order.getWeight() * 0.5;
} else if (shippingMethod.equals("EXPRESS")) {
return order.getWeight() * 1.0 + 10;
} else if (shippingMethod.equals("OVERNIGHT")) {
return order.getWeight() * 1.5 + 20;
} else {
throw new IllegalArgumentException("Unsupported shipping method: " + shippingMethod);
}
}After refactoring:
public interface ShippingStrategy {
double calculateShippingCost(Order order);
}
public class StandardShipping implements ShippingStrategy {
@Override
public double calculateShippingCost(Order order) {
return order.getWeight() * 0.5;
}
}
public class ExpressShipping implements ShippingStrategy {
@Override
public double calculateShippingCost(Order order) {
return order.getWeight() * 1.0 + 10;
}
}
public class OvernightShipping implements ShippingStrategy {
@Override
public double calculateShippingCost(Order order) {
return order.getWeight() * 1.5 + 20;
}
}
public class ShippingCalculator {
private Map<String, ShippingStrategy> strategies = new HashMap<>();
public ShippingCalculator() {
strategies.put("STANDARD", new StandardShipping());
strategies.put("EXPRESS", new ExpressShipping());
strategies.put("OVERNIGHT", new OvernightShipping());
}
public double calculateShippingCost(Order order, String shippingMethod) {
ShippingStrategy strategy = strategies.get(shippingMethod);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported shipping method: " + shippingMethod);
}
return strategy.calculateShippingCost(order);
}
}The Strategy pattern makes the shipping calculation modular and extensible.
7. Use Builder Pattern
When a class has many constructor parameters, especially optional ones, the Builder pattern simplifies object creation.
Before refactoring:
public class User {
private String username;
private String email;
private String firstName;
private String lastName;
private String phone;
// many constructors with different parameter combinations
}After refactoring:
public class User {
private String username;
private String email;
private String firstName;
private String lastName;
private String phone;
private String address;
private String city;
private String country;
private String postalCode;
private User(Builder builder) {
this.username = builder.username;
this.email = builder.email;
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.phone = builder.phone;
this.address = builder.address;
this.city = builder.city;
this.country = builder.country;
this.postalCode = builder.postalCode;
}
public static class Builder {
private final String username;
private final String email;
private String firstName;
private String lastName;
private String phone;
private String address;
private String city;
private String country;
private String postalCode;
public Builder(String username, String email) {
this.username = username;
this.email = email;
}
public Builder firstName(String firstName) { this.firstName = firstName; return this; }
public Builder lastName(String lastName) { this.lastName = lastName; return this; }
public Builder phone(String phone) { this.phone = phone; return this; }
public Builder address(String address) { this.address = address; return this; }
public Builder city(String city) { this.city = city; return this; }
public Builder country(String country) { this.country = country; return this; }
public Builder postalCode(String postalCode) { this.postalCode = postalCode; return this; }
public User build() { return new User(this); }
}
}
User user = new User.Builder("johndoe", "[email protected]")
.firstName("John")
.lastName("Doe")
.phone("1234567890")
.build();The Builder pattern makes object creation flexible and the resulting objects immutable.
8. Use Factory Method
When object creation logic is complex, a factory method isolates that logic.
Before refactoring:
public class ProductService {
public Product createProduct(String type, String name, double price) {
if (type.equals("PHYSICAL")) {
return new PhysicalProduct(name, price);
} else if (type.equals("DIGITAL")) {
return new DigitalProduct(name, price);
} else if (type.equals("SUBSCRIPTION")) {
return new SubscriptionProduct(name, price);
} else {
throw new IllegalArgumentException("Unsupported product type: " + type);
}
}
}After refactoring:
public abstract class ProductFactory {
public static Product createProduct(String type, String name, double price) {
if (type.equals("PHYSICAL")) {
return createPhysicalProduct(name, price);
} else if (type.equals("DIGITAL")) {
return createDigitalProduct(name, price);
} else if (type.equals("SUBSCRIPTION")) {
return createSubscriptionProduct(name, price);
} else {
throw new IllegalArgumentException("Unsupported product type: " + type);
}
}
private static Product createPhysicalProduct(String name, double price) {
return new PhysicalProduct(name, price);
}
private static Product createDigitalProduct(String name, double price) {
return new DigitalProduct(name, price);
}
private static Product createSubscriptionProduct(String name, double price) {
return new SubscriptionProduct(name, price);
}
}
Product product = ProductFactory.createProduct("PHYSICAL", "Book", 29.99);The factory method separates creation logic, making the code more modular and easier to extend.
9. Use Optional to Avoid NullPointerException
NullPointerException is common in Java. Using Optional makes the possibility of null explicit.
Before refactoring:
public String getUserEmail(Long id) {
User user = findUserById(id);
if (user != null) {
String email = user.getEmail();
if (email != null) {
return email;
}
}
return "unknown";
}After refactoring:
public Optional<User> findUserById(Long id) {
User user = userRepository.findById(id);
return Optional.ofNullable(user);
}
public String getUserEmail(Long id) {
return findUserById(id)
.map(User::getEmail)
.orElse("unknown");
}Optional clarifies that a method may return an empty value and provides fluent APIs for handling it.
10. Use Stream API to Simplify Collection Operations
Java 8 Stream API enables declarative processing of collections.
Before refactoring:
public List<Order> findLargeOrders(List<Order> orders) {
List<Order> largeOrders = new ArrayList<>();
for (Order order : orders) {
if (order.getTotal() > 1000) {
largeOrders.add(order);
}
}
return largeOrders;
}
public double calculateTotalRevenue(List<Order> orders) {
double total = 0;
for (Order order : orders) {
total += order.getTotal();
}
return total;
}
public List<String> getCustomerNames(List<Order> orders) {
List<String> names = new ArrayList<>();
for (Order order : orders) {
String name = order.getCustomer().getName();
if (!names.contains(name)) {
names.add(name);
}
}
return names;
}After refactoring:
public List<Order> findLargeOrders(List<Order> orders) {
return orders.stream()
.filter(order -> order.getTotal() > 1000)
.collect(Collectors.toList());
}
public double calculateTotalRevenue(List<Order> orders) {
return orders.stream()
.mapToDouble(Order::getTotal)
.sum();
}
public List<String> getCustomerNames(List<Order> orders) {
return orders.stream()
.map(order -> order.getCustomer().getName())
.distinct()
.collect(Collectors.toList());
}Streams turn imperative loops into concise, readable declarative pipelines.
11. Use Lambda Expressions to Simplify Anonymous Inner Classes
Lambda expressions provide a concise way to create anonymous functions.
Before refactoring:
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
System.out.println("Button clicked");
}
});
Collections.sort(users, new Comparator<User>() {
@Override
public int compare(User u1, User u2) {
return u1.getName().compareTo(u2.getName());
}
});After refactoring:
button.setOnClickListener(view -> {
System.out.println("Button clicked");
});
users.sort(Comparator.comparing(User::getName));Lambdas replace verbose anonymous classes with succinct functional syntax.
12. Use Method References to Simplify Lambdas
Method references further shorten simple lambda expressions.
Before refactoring:
users.stream()
.map(user -> user.getName())
.collect(Collectors.toList());
users.forEach(user -> System.out.println(user));
names.stream()
.map(name -> new User(name))
.collect(Collectors.toList());After refactoring:
users.stream()
.map(User::getName)
.collect(Collectors.toList());
users.forEach(System.out::println);
names.stream()
.map(User::new)
.collect(Collectors.toList());Method references make the code even more concise and readable.
13. Use CompletableFuture to Simplify Asynchronous Programming
CompletableFuture replaces nested callbacks with a fluent, chainable API.
Before refactoring:
userRepository.findByIdAsync(userId, new Callback<User>() {
@Override
public void onSuccess(User user) {
orderRepository.findByUserIdAsync(userId, new Callback<List<Order>>() {
@Override
public void onSuccess(List<Order> orders) {
user.setOrders(orders);
callback.onSuccess(user);
}
@Override
public void onError(Exception e) {
callback.onError(e);
}
});
}
@Override
public void onError(Exception e) {
callback.onError(e);
}
});After refactoring:
public CompletableFuture<User> processUser(Long userId) {
return userRepository.findByIdAsync(userId)
.thenCompose(user -> orderRepository.findByUserIdAsync(userId)
.thenApply(orders -> {
user.setOrders(orders);
return user;
}));
}
userService.processUser(123L)
.thenAccept(user -> {
System.out.println("User: " + user.getName());
System.out.println("Order count: " + user.getOrders().size());
})
.exceptionally(e -> {
System.err.println("Error processing user: " + e.getMessage());
return null;
});CompletableFuture turns asynchronous code into readable, linear chains.
14. Use Interface Default Methods to Simplify Implementations
Default methods allow interfaces to provide common implementations.
Before refactoring:
public interface PaymentProcessor {
void processPayment(Payment payment);
void refundPayment(Payment payment);
void cancelPayment(Payment payment);
}
public class CreditCardProcessor implements PaymentProcessor {
@Override public void processPayment(Payment payment) { /* ... */ }
@Override public void refundPayment(Payment payment) { /* ... */ }
@Override public void cancelPayment(Payment payment) { refundPayment(payment); }
}After refactoring:
public interface PaymentProcessor {
void processPayment(Payment payment);
void refundPayment(Payment payment);
default void cancelPayment(Payment payment) {
// Default implementation: cancel and refund
refundPayment(payment);
}
}
public class CreditCardProcessor implements PaymentProcessor {
@Override public void processPayment(Payment payment) { /* ... */ }
@Override public void refundPayment(Payment payment) { /* ... */ }
}
public class PayPalProcessor implements PaymentProcessor {
@Override public void processPayment(Payment payment) { /* ... */ }
@Override public void refundPayment(Payment payment) { /* ... */ }
}Default methods remove duplicated cancel logic from each implementation.
15. Use Enum Instead of Constants
Enums provide type‑safe constants and can contain related behavior.
Before refactoring:
public class OrderStatus {
public static final String PENDING = "PENDING";
public static final String PROCESSING = "PROCESSING";
public static final String SHIPPED = "SHIPPED";
public static final String DELIVERED = "DELIVERED";
public static final String CANCELLED = "CANCELLED";
}
public class Order {
private String status;
public void setStatus(String status) { this.status = status; }
public boolean isCancellable() { return status.equals(OrderStatus.PENDING) || status.equals(OrderStatus.PROCESSING); }
}After refactoring:
public enum OrderStatus {
PENDING,
PROCESSING,
SHIPPED,
DELIVERED,
CANCELLED;
public boolean isCancellable() {
return this == PENDING || this == PROCESSING;
}
}
public class Order {
private OrderStatus status;
public void setStatus(OrderStatus status) { this.status = status; }
public boolean isCancellable() { return status.isCancellable(); }
}Enums ensure compile‑time safety and can encapsulate related methods.
Remember, good code not only works correctly but should also be easy to understand, modify, and extend.
Hope these techniques help you write better code!
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Su San Talks Tech
Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
