I often come across code snippets 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 depends on the weather. This kind of code is both difficult to test and maintain. In this short article, I’ll show how to refactor it using a Map.

What is the Problem?

Using conditional structures can be a sign of poor design. Indeed, as new cases need to be handled, this code will grow indefinitely, and the same piece of code may have to be modified repeatedly. Inevitably, a time will come when the code becomes so bloated that it is difficult to add new behaviour. This constitutes a violation of the Open Closed Principle, which stipulates that software should be open for extension but closed for modification. In other words, you should be able to add new behaviour without modifying the existing implementation.

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 needs to be executed (the value). Therefore, we can 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. The mapping between the weather and the action to perform is explicit, and it’s no longer necessary to modify the start method algorithm very often: each new case only requires a new entry in the Map.

Nonetheless, this doesn’t solve all issues. The class still needs modification to add a new entry. To address this, the Map can be passed as a parameter to 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 becomes the responsibility of another class.

Note about Spring Framework

If you are using the Spring Framework and the Day class is a @Component, you can inject the Map as you would 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 and reduces the complexity of a method efficiently. The code should reveal intention and avoid being bloated with conditional structures when unnecessary.