Master the Rule Design Pattern with composite rules and open closed principle

Doradla Hari Krishna
Javarevisited
Published in
9 min readJan 11, 2024
Photo by Radowan Nakif Rehan on Unsplash

Background Story

Previously, I wrote an article on Rule Design Pattern and Open Closed Principle in 6 minutes where I explained how to create simple rules and execute them with a rule engine. Taking previous article as base, In this article, I will cover how to create composite rules and process them. Composite rules are often seen in real-world software engineering code repositories.

I will use the same example that I used in my previous article and extend from it so that it will be easy for you to follow both articles easily.

Problem

Let’s assume we have a shopping platform where users can buy things. During checkout, we want to provide different discount coupons if

  • The user's birthday and festival day is coming on purchase day.
  • The user is newly signed up, and the order purchase date is either a festival day or the user’s birthday.

All the code used here is hosted in my Github repo

Naive Approach

The naive way of doing this is to have a data object and pass it to through bunch of if conditions. If a condition is passed then we will add relevant coupon in the list of coupons variable. It looks something like

public void addCoupons(CouponData input) {
final User user = input.getUser();
if (input.getCoupons() == null) {
input.setCoupons(new ArrayList<>());
}

LocalDate todayDate = LocalDate.now();
String todayDateInString = LocalDate.now().toString();
LocalDate userBirthDay = user.getDateOfBirth();
if (todayDate.equals(userBirthDay) && festivalDates.contains(todayDateInString)) {
input.getCoupons().add(Coupon.BIRTHDAY_FESTIVE_OFFER);
System.out.println("Added BIRTHDAY_FESTIVE_OFFER coupon");
}

LocalDateTime presentDate = LocalDateTime.now();
LocalDateTime userAccountCreated = user.getCreatedAt();
long daysBetween = ChronoUnit.DAYS.between(presentDate, userAccountCreated);
if (daysBetween <= 7 && (todayDate.equals(userBirthDay) || festivalDates.contains(todayDateInString))) {
input.getCoupons().add(Coupon.NEW_USER_COMBO_OFFER);
System.out.println("Added coupon NEW_USER_COMBO_OFFER");
}
}

See how complex and dense it looks like. All the conditions, rules are being done in just inside one function. It does not clearly obeys SOLID principles where in order to add new rule for coupon, we need to make changes in this function by adding extra if condition and testing is required for whole class.

Main thing to observe here is how complex thisif (daysBetween <= 7 && (todayDate.equals(userBirthDay) || festivalDates.contains(todayDateInString))) condition looks. It includes && || in btw some rules which makes code difficult to read. Imagine writing unit tests cases for this condition. You gonna sacrifice your night😅. What do you say if we can write the same condition like this and(newUserRule, or(festiveRule, birthDayRule)).isRuleApplicable(input).

Composite Rule Design Pattern Approach

Let’s see how composite rule design pattern can helps us in decomposing the fat code and complex conditions into more modular way.

We divide above fat code to multiple rules and a rule engine which is the one decides how to process these rules.

Basically a coupon rule contains two methods isRuleApplicable(input) and applyRule(input). We gonna transform each if condition into two different methods. where we put if condition check logic in isRuleApplicable and business logic inside if block in applyRule.

  1. isRuleApplicable(input) : This method is responsible for checking if current rule is valid or not based on given data. When the rule condition becomes complex with multiple sub conditions, it is not easy to read. so we make each sub condition a separate reusable unit rule and just wire those small rules in this function. If you did not understand, trust me and read till the end. You will get more clarity seeing the code.
  2. applyRule(input) : This method is responsible for executing the particular rule business logic to calculate what coupons are available for user to use.

Let’s define our building blocks for implementing this.

Sub Rule

This just has one function to place a simple unit rule logic like daysBetween <= 7

public interface IEvaluateRule<I> {

boolean isRuleApplicable(I input);
}

ApplyRule

This is an abstract class implemeting above sub rule interface. Concreate class implementing this has both isRuleApplicable and applyRule capability. We use multiple sub unit rules to create one composite rule and isRuleApplicable contains this composite rule. If isRuleApplicable evaluates to true then applyRule is called which calculates the coupon to be added.

public abstract class ApplyRule<I, O> implements IEvaluateRule<I> {

public abstract O applyRule(I input);
}

Rule Engine

public interface ICouponRuleEngine {

void addRule(ApplyRule rule);

void removeRule(ApplyRule rule);

void evaluateRules(CouponData input);
}

Rule Engine has three declared methods to add, remove and evaluating the rules. If you observe we can only add rules created from ApplyRule type so that those has both isRuleApplicable and applyRule methods.

Just continue reading 2 mins more to understand better..

Lets see how we can implement above mentioned 2 complex Rules from ApplyRule and sub rules from IEvaluateRule. All the code i have pasted here is present in my Github repo

First of all,

Lets create 3 subrules which are to find if user is newly signed up, if order day is festive day, if order day is user’s birthday.

New User sub unit rule

we are checking if user has signed up on our platform in last 7 days. daysBetween <= 7 present in above OldWay if condition went into this class isRuleApplicable method

public class NewUserCouponRule implements IEvaluateRule<CouponData> {

public NewUserCouponRule() {
}

@Override
public boolean isRuleApplicable(CouponData input) {
final User user = input.getUser();

LocalDateTime presentDate = LocalDateTime.now();
LocalDateTime userAccountCreated = user.getCreatedAt();

long daysBetween = ChronoUnit.DAYS.between(presentDate, userAccountCreated);

return daysBetween <= 7;
}
}

Festive day sub unit rule

we are checking if today is a festival day in isRuleApplicable method. festivalDates.contains(todayDate) present in above OldWay if condition went into this class isRuleApplicable method

public class FestivalOfferCouponRule implements IEvaluateRule<CouponData> {
private static final List<String> festivalDates = List.of("2023-01-01", "2023-10-02");

public FestivalOfferCouponRule() {
}

@Override
public boolean isRuleApplicable(CouponData input) {
String todayDate = LocalDate.now().toString();
return festivalDates.contains(todayDate);
}
}

Birth Day sub unit rule

we are checking if todays date is equal to users birthday in isRuleApplicable method. todayDate.equals(userBirthDay) present in above OldWay if condition went into this class isRuleApplicable method.

public class BirthDaySpecialCouponRule implements IEvaluateRule<CouponData> {

public BirthDaySpecialCouponRule() {
}

@Override
public boolean isRuleApplicable(CouponData input) {
final User user = input.getUser();

LocalDate todayDate = LocalDate.now();
LocalDate userBirthDay = user.getDateOfBirth();

return todayDate.equals(userBirthDay);
}
}

Let’s create composite rules like AndRule , OrRule

AndRule

This takes list of sub unit rules that we created above and returns false if any one of the sub rule turns out to be false.

At the end that is what and job is right

public class AndRule<I> implements IEvaluateRule<I> {
private final List<IEvaluateRule<I>> rules;

public AndRule(List<IEvaluateRule<I>> rules) {
this.rules = rules;
}

@Override
public boolean isRuleApplicable(I input) {
for (IEvaluateRule<I> rule : rules) {
if (!rule.isRuleApplicable(input)) {
return false;
}
}

return true;
}
}

OrRule

This takes list of sub unit rules that we created above and returns true if any one of the sub rule turns out to be true.

At the end that is what or job is right

public class OrRule<I> implements IEvaluateRule<I> {
private final List<IEvaluateRule<I>> rules;

public OrRule(List<IEvaluateRule<I>> rules) {
this.rules = rules;
}

@Override
public boolean isRuleApplicable(I input) {
for (IEvaluateRule<I> rule : rules) {
if (rule.isRuleApplicable(input)) {
return true;
}
}
return false;
}
}

Finally, let’s have a small utility class for ease of use while constructing the composite rule.

RuleUtils

public class RuleUtils {

public static IEvaluateRule and(IEvaluateRule... rules) {
return new AndRule(List.of(rules));
}

public static IEvaluateRule or(IEvaluateRule... rules) {
return new OrRule(List.of(rules));
}
}

and or takes any no of rules with , separated(varargs) and return AndRule, OrRule objects. With the help of this util methods we can write composite rules as and(rule1, rule2, or(rule3, rule4) instead of new AndRule(rule1, rule2, new OrRule(rule3, rule4) . This improvs readability of the code.

Imagine if this pattern is asked in your next interview. Hope this motivates you to read further! 😉

Now that we have all building blocks ready, let’s create complex rules using above sub unit rules and util methods for requirements

  • The user’s birthday is coming on any festival day
  • The user is newly signed up, and the order purchase date is either a festival day or the user’s birthday.

The user’s birthday is coming on any festival day

public class BirthdayFestiveComboCouponRule extends ApplyRule<CouponData, Void> {

public BirthdayFestiveComboCouponRule() {
}

@Override
public boolean isRuleApplicable(CouponData input) {
IEvaluateRule birthDayRule = new BirthDaySpecialCouponRule();
IEvaluateRule festiveRule = new FestivalOfferCouponRule();

return and(birthDayRule, festiveRule).isRuleApplicable(input);
}

@Override
public Void applyRule(CouponData input) {
if (input.getCoupons() == null) {
input.setCoupons(new ArrayList<>());
}

input.getCoupons().add(Coupon.BIRTHDAY_FESTIVE_OFFER);
System.out.println("Added BIRTHDAY_FESTIVE_OFFER coupon");
return null;
}
}

In the above `BirthdayFestiveComboCouponRule`,

Improvement we made here is instead of having

if (todayDate.equals(userBirthDay) && festivalDates.contains(todayDateInString)),

we have

and(birthDayRule, festiveRule).isRuleApplicable(input)

isRuleApplicable function we have used BirthDaySpecialCouponRule, FestivalOfferCouponRule composition with and . Below things happen when isRuleApplicable method of this class is called

  • and(new BirthDaySpecialCouponRule(), new FestivalOfferCouponRule()) will return AndRule object storing the two sub rules passed in its object rules field.
  • .isRuleApplicable(input) calls AndRule class isRuleApplicable method. In that method we loop through each sub unit rule and call sub units rule isRuleApplicable method. If two sub unit rules returns true then AndRule returns true else false.

applyRule function simply adds the BIRTHDAY_FESTIVE_OFFER coupon to user’s coupons list. next requirement is

The user is newly signed up, and the order purchase date is either a festival day or the user’s birthday.

Similar to above, this looks like

public class NewUserComboCouponRule extends ApplyRule<CouponData, Void> {

public NewUserComboCouponRule() {
}

@Override
public boolean isRuleApplicable(CouponData input) {
IEvaluateRule newUserRule = new NewUserCouponRule();
IEvaluateRule festiveRule = new FestivalOfferCouponRule();
IEvaluateRule birthDayRule = new BirthDaySpecialCouponRule();

return and(newUserRule, or(festiveRule, birthDayRule)).isRuleApplicable(input);
}

@Override
public Void applyRule(CouponData input) {
if (input.getCoupons() == null) {
input.setCoupons(new ArrayList<>());
}

input.getCoupons().add(Coupon.NEW_USER_COMBO_OFFER);

System.out.println("Added NEW_USER_COMBO_OFFER coupon");
return null;
}
}

Improvement we made here is instead of having
if (daysBetween <= 7 && (todayDate.equals(userBirthDay) || festivalDates.contains(todayDateInString)))

we have

and(newUserRule, or(festiveRule, birthDayRule)).isRuleApplicable(input)

As of now we have rules designed. Lets take a look at rule engine called DefaultCouponRuleEngine code implements ICouponRuleEngine which operates above rules.

public class DefaultCouponRuleEngine implements ICouponRuleEngine {

private List<ApplyRule> rules;

public DefaultCouponRuleEngine() {
rules = new ArrayList<>();
}

public void addRule(ApplyRule rule) {
this.rules.add(rule);
}

public void removeRule(ApplyRule rule) {
this.rules.remove(rule);
}

@Override
public void evaluateRules(CouponData input) {
rules.forEach(rule -> {
if (rule.isRuleApplicable(input)) {
rule.applyRule(input);
}
});
}
}

Wherever we want to get coupons in our service, we use this engine to add, remove rules. once we have added required rules, we call evaluateRules function which basically loops through each rule, checks if it is applicable at current time and applies rule accordingly by calling applyRule() method of rule implementation class.

Let’s implement Driverclass on how to tie all the above rules, engine and make a working usecase.

public class Driver {

public static void main(String[] args) {
// Initialize user data object
User user = new User();
user.setCreatedAt(LocalDateTime.now());
user.setUserName("user1");
user.setDateOfBirth(LocalDate.now());
user.setTotalOrders(20);
CouponData couponData = new CouponData();
couponData.setUser(user);

/// Old way
System.out.println("With Old way");
OldWay oldWay = new OldWay();
oldWay.addCoupons(couponData);

System.out.println(String.format("Coupons available for user: %s are %s", user.getUserName(), couponData.getCoupons().toString()));


System.out.println("------------------------------");

/// New way
System.out.println("With New way");
ICouponRuleEngine couponRuleEngine = new DefaultCouponRuleEngine();
couponRuleEngine.addRule(new NewUserComboCouponRule());
couponRuleEngine.addRule(new BirthdayFestiveComboCouponRule());

// Evaluate rules
couponData.setCoupons(null);
couponRuleEngine.evaluateRules(couponData);

// Result
System.out.println(String.format("Coupons available for user: %s are %s", user.getUserName(), couponData.getCoupons().toString()));
}
}

If you run the above driver class, following is the output

With Old way
Added coupon NEW_USER_COMBO_OFFER
Coupons available for user: user1 are [NEW_USER_COMBO_OFFER]
------------------------------
With New way
Added NEW_USER_COMBO_OFFER coupon
Coupons available for user: user1 are [NEW_USER_COMBO_OFFER]

Based on CouponData created, user birthday is set to present date and account created is also set to present date so both BirthDaySpecial and NewUser rule has satisfied the condition and NEW_USER_COMBO_OFFER coupon got added.

Conclusion

  1. Above, i have demonstrated with very simple sub unit rules and composite rules but in real software engineering job based on the requirements, sub unit rule logic and coupon creation logic can become complex. At such places this can be used to write good maintainable, modular and extensible code.
  2. The Sub unit rules created are reusable. If we need to make a change in how to new user rule of todayDate ≤ 7 to 10 then changing only in NewUserRule is enough. In old way we need to make change in every condition.
  3. Now writing unit test cases for these classes is very easy. you can write UT’s by class by class and mock behaviors of each rule independently.
  4. Imagine you got a new business rule requirement tomorrow then you just need to implement a new concrete class from ApplyRule. This aligns with one of SOLID principles called Open Closed Principle i.e Open for extension but closed for modification.
  5. Lastly using design pattern is fancy but we should not use for each and every problem. If the rule logic and coupon calculation is one line then we can go ahead with simple if blocks. Before using any design pattern make sure you are not over engineering the code.

Thank you for reading!! I hope you liked the content and learned something useful. Feel free to follow me on LinkedIn and Medium for more such content.

PS: Have uploaded full code in Github repo for your reference.

--

--

Doradla Hari Krishna
Javarevisited

Software Engineer who likes writing about tech concepts