I often encounter pieces of code that look like this:
public class Day {
public void start(Weather weather) {
switch(weather) {
case RAINY:
takeAnUmbrella();
break;
case SUNNY:
takeAHat();
break;
case STORMY:
stayHome();
break;
default:
doNothing();
break;
}
}
}
Here, a specific action must be taken depending on the weather. This kind of code is pretty hard to test and maintain. This short article aims to refactor it using a Map
.
What is the Problem?
Using conditional structures can be a sign of bad design. Indeed, as new cases have to be handled, this code will grow indefinitely, and the same piece of code will have to be modified repeatedly. Inevitably, a time will come when the code will be so bloated that it will be difficult to add new behaviour. This is a violation of the Open Closed Principle which stipulates that the code should be open for extension but closed for modification. In other words, you should be able to add new behaviour to your code without modifying it.
Transform the Imperative Algorithm into Data
By analyzing this code, it becomes clear that this algorithm is no more than a Map
: for each weather (the key), a piece of code has to be executed (the value). Therefore, it is possible to perform a first refactoring iteration to make this conceptual Map
concrete:
public class Day {
private final Map<Weather, Runnable> startOfTheDayActions = new HashMap<>();
public Day() {
startOfTheDayActions.put(Weather.RAINY, this::takeAnUmbrella);
startOfTheDayActions.put(Weather.SUNNY, this::takeAHat);
startOfTheDayActions.put(Weather.STORMY, this::stayHome);
}
public void start(Weather weather) {
startOfTheDayActions.getOrDefault(weather, this::doNothing).run();
}
}
The code is now already much clearer. Indeed, the mapping between the weather and the action to perform is explicit, and it isn’t necessary to modify the start
method very often: each new case only requires a new entry in the Map
.
Nonetheless, this doesn’t solve all issues. In fact, the class still has to be modified to add a new entry. So, to go further, the Map
can be passed as a parameter of the constructor.
public class Day {
private final Map<Weather, Runnable> startOfTheDayActions = new HashMap<>();
public Day(Map<Weather, Runnable> startOfTheDayActions) {
this.startOfTheDayActions = startOfTheDayActions;
}
public void start(Weather weather) {
startOfTheDayActions.getOrDefault(weather, this::doNothing).run();
}
}
Now, the only responsibility of the class is to use the mapping to perform the correct action. This mapping is now the responsibility of another class.
Note about Spring Framework
If you are using Spring Framework and the Day
class is a @Component
, you can simply inject the Map
as any other dependency.
@Component
public class Day {
private final Map<Weather, Runnable> startOfTheDayActions = new HashMap<>();
public Day(@Qualifier("startOfTheDayActions") Map<Weather, Runnable> startOfTheDayActions) {
this.startOfTheDayActions = startOfTheDayActions;
}
public void start(Weather weather) {
startOfTheDayActions.getOrDefault(weather, this::doNothing).run();
}
}
@Configuration
public class ActionConfig {
@Bean("startOfTheDayActions")
public Map<Weather, Runnable> startOfTheDayActions() {
Map<Weather, Runnable> actions = new HashMap<>();
// Create mapping
return actions;
}
}
Conclusion
This refactoring is easy to perform but can reduce the complexity of a method very efficiently. Indeed, the code should reveal intention and not be bloated with conditional structures when unnecessary.