At a previous workplace, I had to add new functionality to the existing function which was responsible for sending different notification messages, based on different business rules. For each notification, there were different rules applied for different customer types.
For instance, when there is a customer who doesn’t have any eligibility for upgrading their subscription, the function would send a notification to that ineligible customer.
If (RuleForIneligibleStatus){
// send notification for ineligible customers
sendNotificationForRuleForIneligibleStatus();
} else if (RuleForOpenUpgradeOrderStatus){
// send notification for OpenUpgradeOrder customers
sendNotificationForOpenUpgradeOrderStatus()
} else if (rule3) {
// send notification for rule3
} else if (rule4) {
// send notification for rule4
…
What is the problem with if/else statements?
Imagine you have 20 more if-else statements like that! Anyone who has some experience in software development knows that more if-else clauses will be added to that function over time. This will be inevitable especially if you are working in a team that doesn’t have a refactoring culture.
When a new notification type is required, a developer will add another “if” statement because of the time pressure. After a while, you might end up with hundreds of lines of code for just one function as there will be different business logic for each rule. Due to the cyclometric complexity of the function, even the simplest change will take a lot of time.
Moreover, when there is a bug for any of the notifications, you will get lost in the if-else statements. It will also be a nightmare to test your function.
An alternative solution: Rule Design Pattern
The rule design pattern works by separating business rules from rule processing logic (Single Responsibility Principle). This makes it easy to add new rules without changing the rest of the system (Open Closed Principle). Therefore, instead of adding a new if-else statement, you create a new class for the rule that you want to add. The design pattern has 3 components:
IRule: An interface where you define the rules. There are two methods in the interface that each rule should run. The first one is the condition to run the rule (the condition in the if statement), and the second one is what action the rule will take (the action to do if the if statement is valid).
Rule1, Rule2…: Classes that you implement from IRule and create specific rules.
RuleEvaluator: The class where you add rules in the order you want to execute and execute the rules.
How did we implement the Rule Design Pattern?
We wanted to build a structure that was easier to change. We end up with a solution that is shown in the UML diagram below.
Rule interface: IEligibilityRule
In fact, what you are doing here is to define the condition and action in the if-clause as two different functions. Therefore, you can convert any if structure to specific classes, for example, RuleForEligibleStatus:
if(RuleForIneligibleStatus){ --> isRuleApplicable() function
// send notification for ineligible customers
sendNotificationForRuleForIneligibleStatus(); --> executeEligibilityRule() function
}
Specific rule example: RuleForEligibleStatus
Think of each class derived from IEligibilityRule as an if block.
isRuleApplicable represents the condition of the if statement. You define in which conditions RuleForEligibleStatus should work by implementing the isRuleApplicable interface function
executeEligibilityRule represents the body of the is a statement. If the rule is valid, you define the actions of RuleForEligibleStatus with its executeEligibilityRule function.
In short, for each class, you inherit from the IEligibilityRule interface: isRuleApplicable and executeEligibilityRule functions contain different conditions and actions respectively. Here is the example for RuleForEligibleStatus class:
public class RuleForEligibleStatus implements IEligibilityRule {
...
@Override
public boolean isRuleApplicable(Eligibility eligibility) {
return eligibility.getXXX()
.map(Offer::isEligible)
.filter(isEligible -> isEligible.equals(true))
.isPresent();
}
@Override
public void executeEligibilityRule(Eligibility eligibility, Notification notification) {
if (this.isRuleApplicable(eligibility)) {
eligibility.getXXX()
.ifPresent(details -> {
// send the notification here...
});
}
}
}
Executing rules: EligibilityRuleEvaluator
In this class, you define the rules in the EligibilityRuleEvaluator() constructor. How you run the rules depends on your business requirements.
In our project, the rules had a priority and only the first rule that is eligible would be executed. Then, the rest of the rules would be eliminated. Therefore, we check each rule one by one in evaluateRules function and execute the isRuleApplicable function to filter the applicable rules.
Here is the code snippet of the EligibilityRuleEvaluator.
public class EligibilityRuleEvaluator implements IEligibilityEvaluator {
private final List<IEligibilityRule> rules = new ArrayList<>();
/**
* Add the rules in the order you want to execute
*/
public EligibilityRuleEvaluator() {
...//rules
rules.add(new RuleForIneligibleStatus());
rules.add(new RuleForOpenUpgradeOrderStatus());
rules.add(new RuleForMultipleSubscriptions());
rules.add(new RuleForEligibleStatus());
}
@Override
public void evaluateRules(Eligibility eligibility, Notification notification) {
IEligibilityRule rule = rules.stream()
.filter(r -> r.isRuleApplicable(eligibility))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Expression does not matches any Rule"));
rule.executeEligibilityRule(eligibility, notification);
}
}
The final step
Finally, you need to call the evaluateRules function of the EligibilityRuleEvaluator class to run the rules. The following function executes eligibility rules based on the priority list defined in EligibilityRuleEvaluator.
In this way, we can implement the business rules in a more modular, maintainable and open to changes without using an if-else statements.
public void executeEligibilityRule(Notification notification, Eligibility upgradeEligibility) {
var eligibilityRuleEvaluator = new EligibilityRuleEvaluator();
eligibilityRuleEvaluator.evaluateRules(eligibility, notification);
}
Conclusion
The rule design pattern is easy to translate the business rules into separate classes. Each if-else statement represents the different rules. You only need to implement an interface that defines a method for determining if a rule is satisfied and a method for deciding what action to take.
It is important to mention that each rule should implement the same interface. If this does not suit your application needs, I highly recommend investigating other design patterns I mentioned. If you do not want to implement a rule pattern from scratch, you might also consider the rule-engine libraries.
Written by Suleyman Yildirim, an Oracle Certified Java Software Engineer with 9 years of IT experience. Suleyman has a strong background in all stages of software development efforts, including software design, development, and automated testing. He participated in various enterprise web application development projects using Spring Ecosystem. He has recently created a Practical Spring Security course for EC-Council, and also likes to share his knowledge and write about software engineering on his personal blog.
Blog: https://suleymanyildirim.org/
GitHub: https://github.com/barrida