Skip to main content

Ruling the code with Rules Design Pattern

Greetings!

As a big fan of Chain Of Responsibility design pattern I was thinking to move out the if condition into a separate method so that I can loop through chain externally. It turned out that I end up implementing Rules Design Pattern.

I first encountered the pattern few years ago in a C# article (https://www.michael-whelan.net/rules-design-pattern/).

You can find the pattern in this course patterns-library.

Rules Design Pattern

In this pattern, we decouple the business rules from their processing by encapsulating them in to separate classes.
We can add/remove rules without impacting the existing logic.

This looks much like the Chain of Responsibility except processing rule is separated. One glitch I have faced is we are unable share one rules data with another as the rule and processing are decoupled. Anyway, it is worth to try.

I'm not going re-invent an example. Instead i'll use the same example as in C# article but with minimum logic.

public class Customer {

  private LocalDate dateOfFirstPurchase;
  private LocalDate dateOfBirth;

  // constructoer, getters
}

public class DiscountCalculator {

  public int calculateDiscount(Customer customer) {
    int discount = 0;
    if (customer.getDateOfBirth().plusYears(65).isBefore(LocalDate.now())) {
      discount = 5; // senior discount 5%
    }
    if (MonthDay.from(customer.getDateOfBirth()).compareTo(MonthDay.from(LocalDate.now())) == 0) {
      discount = Math.max(discount, 10); // birthday 10%
    }
    if (Objects.nonNull(customer.getDateOfFirstPurchase())) {
      if (Period.between(customer.getDateOfFirstPurchase(), LocalDate.now()).getYears() >= 5) {
        discount = Math.max(discount, 12); // after 5 years, 12%
        if (Period.between(customer.getDateOfFirstPurchase(), LocalDate.now()).getYears() >= 10) {
          discount = Math.max(discount, 20); // after 10 years, 20%
        }
      }
    } else {
      discount = Math.max(discount, 15); // first time purchase discount of 15%
    }
    return discount;
  }
}
Let's convert this into Rules Pattern.
public class Customer {

  private LocalDate dateOfFirstPurchase;
  private LocalDate dateOfBirth;

  public Customer(LocalDate dateOfFirstPurchase, LocalDate dateOfBirth)
  {
    this.dateOfFirstPurchase = dateOfFirstPurchase;
    this.dateOfBirth = dateOfBirth;
  }

  public LocalDate getDateOfFirstPurchase() {
    return dateOfFirstPurchase;
  }

  public LocalDate getDateOfBirth() {
    return dateOfBirth;
  }

  public boolean isBirthday() {
    return MonthDay.from(dateOfBirth).compareTo(MonthDay.from(LocalDate.now())) == 0;
  }

  public boolean isSeniorCitizen() {
    return dateOfBirth.plusYears(65).isBefore(LocalDate.now());
  }

  public boolean hasBeenLoyalForYears(int years) {
    return isMember() && ChronoUnit.YEARS.between(dateOfFirstPurchase, LocalDate.now()) >= years;
  }

  public boolean isMember() {
    return Objects.nonNull(dateOfFirstPurchase);
  }

}
public interface DiscountRule {

  boolean matches(Customer customer);

  int execute();

}
public class BirthdayDiscountRule implements DiscountRule {

  @Override
  public boolean matches(Customer customer) {
    return MonthDay.from(customer.getDateOfBirth()).compareTo(MonthDay.from(LocalDate.now())) == 0;
  }

  @Override
  public int execute() {
    return 10;
  }

}
public class LoyaltyDiscountRule implements DiscountRule {

  private int years;
  private int discount;

  public LoyaltyDiscountRule(int years, int discount) {
    this.years = years;
    this.discount = discount;
  }

  @Override
  public boolean matches(Customer customer) {
    return customer.hasBeenLoyalForYears(years);
  }

  @Override
  public int execute() {
    return discount;
  }

}
public class NewCustomerDiscountRule implements DiscountRule {

  @Override
  public boolean matches(Customer customer) {
    return !customer.isMember();
  }

  @Override
  public int execute() {
    return 15;
  }

}
public class SeniorCitizenDiscountRule implements DiscountRule {

  @Override
  public boolean matches(Customer customer) {
    return customer.isSeniorCitizen();
  }

  @Override
  public int execute() {
    return 5;
  }

}
We can implement other rules the same way. Then what we need is a class to evaluate our rules.
public class DiscountEvaluator {

  private List<DiscountRule> discountRules = new ArrayList<>();

  public DiscountEvaluator() {
    discountRules.add(new SeniorCitizenDiscountRule());
    discountRules.add(new BirthdayDiscountRule());
    discountRules.add(new LoyaltyDiscountRule(5, 12));
    discountRules.add(new LoyaltyDiscountRule(10, 20));
    discountRules.add(new NewCustomerDiscountRule());
  }

  public int evaluate(Customer customer) {
    int discount = 0;
    for (DiscountRule discountRule : discountRules) {
      if (discountRule.matches(customer)) {
        discount = Math.max(discountRule.execute(), discount);
      }
    }
    return discount;
  }

}

public class App {
  public static void main(String[] args) {
    Customer customer = new Customer(LocalDate.of(2000, 10, 13), LocalDate.of(1950, 10, 13));

    DiscountEvaluator discountEvaluator = new DiscountEvaluator();
    int discount = discountEvaluator.evaluate(customer);
    // apply calculated discount
  }
}

Things to aware

We use databases in production environments. Hence those magic numbers (discount values, senior citizen age, etc) will be taken from the database. As you can see, we will not be able use Customer class like that (depending on how do you follow DDD). What we can do is to separate conditions into reusable component, may or may not with Java Predicates.

I would not create Rules chain inside the evaluator in a production codebase as this is not unit testable when we have a database. Instead I would use a separate Rules registry and inject it into the evaluator, probably using a DI framework.

This is a mini rule engine. I use this as neccessary when I code as I know what I am doing. As an example I can easily decouple depending business rules by separating them as rules which gives a decision. However do not create complex business rule engine in your code (though it is fun to do so). Because, business rules tend to change over time. It will be a waste of resources and time with always changing requirements.

Happy coding ☺


Comments