Escaping the if-else nightmare in Java

Table of Contents

Introduction

As a programmer, eventually, you will have to write programs that require multi-if-else statements. When you will start writing such code you will quickly notice that big if-else blocks of code are not really readable and maintainable. In this article, I will analyze this problem based on the hypothetical problem we are trying to solve.

Problem description

For our example Let’s assume you need to write a program that raises different notifications depending on client data. So we have a bunch of clients defined like this:

public record Client(String name, int age) {}

And now, depending on client details we would like to offer a different program. In our example, for every client only one notification needs to be raised. Let’s assume that business requirements look like this:
If the client is older than 80 years old offer a program for seniors. But If the client is older than 20 years old and younger than 70 offer a program for adults. Finally, If the client is younger than 20 years old don’t offer any program and display information that we don’t offer programs for clients of this age.
Let’s write those requirements in code:

public class ProgramDecider {
    public void raiseNotificationForClient(Client client) {
        int age = client.age();
        if (age >= 80) {
            System.out.println("Weight lose program for seniors");
        } else if (age >= 20) {
            System.out.println("Weight logs program for adults");
        } else {
            System.out.println("We don't offer program for clients in your age.");
        }
    }
}

So far so good. Of course, we can improve the above code by moving those integer values to constants (and potentially to properties files). But let’s skip it for now and focus on a discussed problem. But what if requirements are more complex? Let’s assume that the client record was changed and now the client class looks like this:

public record Client(String name, int age, Duration accountAge) {}

Requirements also are different and now we offer programs for new adult clients whose account age is lower than 60 minutes. So now the if-else block would look like this:

if (age >= 80) {
    System.out.println("Weight lose program for seniors");
} else if (age >= 20 && client.accountAge().minusMinutes(60).isNegative()) {
    System.out.println("Weight logs program for adults");
} else {
    System.out.println("We don't offer program for clients in your age.");
}

As you can see if those conditions become more complex, this block of code starts to be less readable. Let’s try to refactor it. First, let’s create an interface with two methods:

public interface NutritionProgram {
    boolean shouldOfferProgramForClient(Client client);
    void offerProgram(Client client);
}

And now all implementations:

public class ProgramForAdults implements NutritionProgram {
    @Override
    public boolean shouldOfferProgramForClient(Client client) {
        return client.age() >= 20 && client.age() <= 80
                && client.accountAge().minusMinutes(60).isNegative();
    }
    @Override
    public void offerProgram(Client client) {
        System.out.println("Program for Adults");
    }
}
public class ProgramForSeniors implements NutritionProgram {
    @Override
    public boolean shouldOfferProgramForClient(Client client) {
        return client.age() > 80;
    }
    @Override
    public void offerProgram(Client client) {
        System.out.println("Program for Seniors");
    }
}
public class ProgramUnknown implements NutritionProgram {
    @Override
    public boolean shouldOfferProgramForClient(Client client) {
        return true;
    }
    @Override
    public void offerProgram(Client client) {
        System.out.println("We don't offer program for clients in your age: %s".formatted(client.age()));
    }
}

And finally, refactored class:

public class ProgramDeciderImproved {
    private final List<NutritionProgram> nutritionPrograms;
    public ProgramDeciderImproved(List<NutritionProgram> nutritionPrograms) {
        this.nutritionPrograms = nutritionPrograms;
    }
    public void raiseNotificationForClient(Client client) {
        for (NutritionProgram nutritionProgram : nutritionPrograms) {
            if (nutritionProgram.shouldOfferProgramForClient(client)) {
                nutritionProgram.offerProgram(client);
                break;
            }
        }
    }
}

We can make a couple observations about this improved solution:
1) In this new class, we operate on interfaces instead of concrete implementations. Using this approach, if requirements would change then there is no need to change this class. We also follow SOLID principles in particular the dependency inversion principle which states: “Depend upon abstractions, [not] concretions.”
2) Those classes have names that clearly describe their responsibilities. In object-oriented programming, it’s good to decouple bigger classes into smaller ones with well-defined responsibilities.

Spring

Let’s improve this solution further using Spring. If we make our ProgramDecider bean and all implementations become beans as well then Spring will automatically create and inject a list of beans for us. The important thing is that for our example, elements need to be added to the list in a specific order. So we need to do the following changes:

@Component
public class ProgramDeciderImproved {
@Component
@Order(1)
public class ProgramForAdults implements NutritionProgram {
@Component
@Order(2)
public class ProgramForSeniors implements NutritionProgram {
@Component
@Order(3)
public class ProgramUnknown implements NutritionProgram {

And that’s all. If we use spring we can simply inject ProgramDeciderImproved bean and use it directly.

Testing

Let’s now test both solutions. First, let’s test a plain Java implementation:

public class TestWithoutSpring {
    private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream();
    @BeforeEach
    public void setUp() {
        System.setOut(new PrintStream(outputStreamCaptor));
    }
    @ParameterizedTest
    @MethodSource("arguments")
    void properNotificationIsRaised(int clientAge, Duration accountAge, String expectedProgram) {
        //given
        var programs = List.of(new ProgramForAdults(), new ProgramForSeniors(), new ProgramUnknown());
        var decider = new ProgramDeciderImproved(programs);
        var client = new Client("Peter", clientAge, accountAge);
        //when
        decider.raiseNotificationForClient(client);
        //then
        assertEquals(expectedProgram, outputStreamCaptor.toString());
    }
    static Stream<Arguments> arguments() {
        return Stream.of(
                Arguments.of(20, Duration.ofHours(5), "We don't offer program for clients in your age: 20\n"),
                Arguments.of(25, Duration.ofMinutes(20), "Program for Adults\n"),
                Arguments.of(85, Duration.ofHours(5), "Program for Seniors\n")
        );
    }
}

Notice how we first create all programs:

var programs = List.of(new ProgramForAdults(), new ProgramForSeniors(), new ProgramUnknown());

Then we inject it directly into the ProgramDeciderImproved object. From now we can use it to raise proper notifications. In our test, we verify it using different client data. We check If proper notification is raised depending on the client’s age and account age using the parametrized Junit test.

For Spring the test will differ and it will look like this:

@SpringBootTest
class TestWithSpring {
	@Autowired
	ProgramDeciderImproved programDecider;
	private final ByteArrayOutputStream outputStreamCaptor = new ByteArrayOutputStream();
	@BeforeEach
	public void setUp() {
		System.setOut(new PrintStream(outputStreamCaptor));
	}
	@ParameterizedTest
	@MethodSource("arguments")
	void properNotificationIsRaised(int clientAge, Duration accountAge, String expectedProgram) {
		//given
		var client = new Client("Peter", clientAge, accountAge);
		//when
		programDecider.raiseNotificationForClient(client);
		//then
		assertEquals(expectedProgram, outputStreamCaptor.toString());
	}
	static Stream<Arguments> arguments() {
		return Stream.of(
				Arguments.of(20, Duration.ofHours(5), "We don't offer program for clients in your age: 20\n"),
				Arguments.of(25, Duration.ofMinutes(20), "Program for Adults\n"),
				Arguments.of(85, Duration.ofHours(5), "Program for Seniors\n")
		);
	}
}

Note that this time we don’t have to create programs manually. During the creation of the ProgramDeciderImproved bean, the spring will automatically create and inject a list of nutrition programs for us. Spring creates the list in the order we defined using @Order annotation.

Strategy design pattern

The solution I described in this article is the special version of the Strategy design pattern. In our example, we choose the proper Program depending on the client data and we are doing it during the runtime.

Summary

The full code can be found on my GitHub account here: https://github.com/piotrmucha/blog/tree/main/IfElsePost So to sum it up, if your code starting to be too complex you can consider refactoring it using the Strategy design pattern that I described in this article.


Posted

in

by

Tags: