Rule design patterns to use

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.

Ruled design pattern graph 2

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.

Ruled design pattern graph 1

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

Find your next developer within days, not months

We can help you deliver your product faster with an experienced remote developer. All from €31.90/hour. Only pay if you’re happy with your first week.

In a short 25-minute call, we would like to:

  • Understand your development needs
  • Explain our process to match you with qualified, vetted developers from our network
  • Share next steps to finding the right match, often within less than a week

Not sure where to start?

Let’s have a chat

First developer starts within days. No aggressive sales pitch.