Explore real-world case studies of refactoring legacy Java code to apply design patterns like Strategy, Observer, and State for improved flexibility, modularity, and maintainability.
Refactoring is a critical practice in software development, allowing developers to improve the design and structure of existing code without altering its external behavior. By refactoring to design patterns, we can enhance code flexibility, maintainability, and scalability. This section presents three case studies that illustrate the transformation of legacy Java codebases through the application of design patterns: Strategy, Observer, and State.
In many legacy systems, algorithms are often hard-coded within classes, leading to rigid and inflexible code. This was the case in a financial application where different interest calculation algorithms were embedded directly within the LoanCalculator
class. This approach made it difficult to introduce new algorithms or modify existing ones without altering the core logic of the class.
Identify Hard-Coded Algorithms: The first step was to identify all the hard-coded algorithms within the LoanCalculator
class.
Define Strategy Interface: A CalculationStrategy
interface was created to define a common method, calculateInterest()
, which all strategies must implement.
Extract Strategies: Each algorithm was extracted into its own class implementing the CalculationStrategy
interface.
Integrate Strategy Pattern: The LoanCalculator
class was refactored to use a CalculationStrategy
object, allowing the strategy to be set dynamically at runtime.
Before Refactoring:
public class LoanCalculator {
public double calculateInterest(double principal, double rate, int time, String type) {
if ("simple".equals(type)) {
return (principal * rate * time) / 100;
} else if ("compound".equals(type)) {
return principal * Math.pow((1 + rate / 100), time) - principal;
}
throw new IllegalArgumentException("Unknown interest type");
}
}
After Refactoring:
public interface CalculationStrategy {
double calculateInterest(double principal, double rate, int time);
}
public class SimpleInterestStrategy implements CalculationStrategy {
public double calculateInterest(double principal, double rate, int time) {
return (principal * rate * time) / 100;
}
}
public class CompoundInterestStrategy implements CalculationStrategy {
public double calculateInterest(double principal, double rate, int time) {
return principal * Math.pow((1 + rate / 100), time) - principal;
}
}
public class LoanCalculator {
private CalculationStrategy strategy;
public LoanCalculator(CalculationStrategy strategy) {
this.strategy = strategy;
}
public double calculateInterest(double principal, double rate, int time) {
return strategy.calculateInterest(principal, rate, time);
}
}
In an event-driven system managing stock market data, components were tightly coupled, with direct method calls to update observers. This coupling made it challenging to add new observers or change notification logic without affecting the entire system.
Identify Coupled Components: The tightly coupled classes were identified, particularly those responsible for broadcasting stock price updates.
Define Observer Interfaces: An Observer
interface was introduced to standardize the update mechanism.
Implement Notification Mechanism: A Subject
interface was created to manage observers and notify them of changes.
Refactor Components: The system was refactored to use the Observer
and Subject
interfaces, decoupling the components.
Before Refactoring:
public class StockMarket {
private List<StockDisplay> displays = new ArrayList<>();
public void addDisplay(StockDisplay display) {
displays.add(display);
}
public void updateStockPrice(String stock, double price) {
for (StockDisplay display : displays) {
display.update(stock, price);
}
}
}
After Refactoring:
public interface Observer {
void update(String stock, double price);
}
public interface Subject {
void registerObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers();
}
public class StockMarket implements Subject {
private List<Observer> observers = new ArrayList<>();
private Map<String, Double> stockPrices = new HashMap<>();
public void registerObserver(Observer observer) {
observers.add(observer);
}
public void removeObserver(Observer observer) {
observers.remove(observer);
}
public void setStockPrice(String stock, double price) {
stockPrices.put(stock, price);
notifyObservers();
}
public void notifyObservers() {
for (Observer observer : observers) {
for (Map.Entry<String, Double> entry : stockPrices.entrySet()) {
observer.update(entry.getKey(), entry.getValue());
}
}
}
}
A vending machine system had complex nested conditionals to manage different states (e.g., waiting for selection, dispensing item). This made the code difficult to read and maintain, and adding new states required modifying existing logic.
Identify State-Dependent Logic: The conditional logic was analyzed to identify distinct states and transitions.
Create State Classes: State-specific classes were created, each encapsulating behavior for a particular state.
Implement State Pattern: The vending machine was refactored to delegate state-specific behavior to these classes.
Before Refactoring:
public class VendingMachine {
private String state = "waiting";
public void handleAction(String action) {
if ("waiting".equals(state)) {
if ("select".equals(action)) {
state = "selected";
System.out.println("Item selected");
}
} else if ("selected".equals(state)) {
if ("pay".equals(action)) {
state = "paid";
System.out.println("Payment received");
}
} else if ("paid".equals(state)) {
if ("dispense".equals(action)) {
state = "waiting";
System.out.println("Item dispensed");
}
}
}
}
After Refactoring:
public interface State {
void handleAction(VendingMachine machine, String action);
}
public class WaitingState implements State {
public void handleAction(VendingMachine machine, String action) {
if ("select".equals(action)) {
machine.setState(new SelectedState());
System.out.println("Item selected");
}
}
}
public class SelectedState implements State {
public void handleAction(VendingMachine machine, String action) {
if ("pay".equals(action)) {
machine.setState(new PaidState());
System.out.println("Payment received");
}
}
}
public class PaidState implements State {
public void handleAction(VendingMachine machine, String action) {
if ("dispense".equals(action)) {
machine.setState(new WaitingState());
System.out.println("Item dispensed");
}
}
}
public class VendingMachine {
private State state = new WaitingState();
public void setState(State state) {
this.state = state;
}
public void handleAction(String action) {
state.handleAction(this, action);
}
}
Refactoring to design patterns is not a one-time task but an ongoing process. Developers should continuously analyze their codebases for opportunities to apply patterns that enhance design and performance. By embracing refactoring, teams can create robust, maintainable, and scalable software systems.