Blog

Basic Rules Engine Design Pattern

In today’s posts, I want to share with you one of my favorite design patterns. I’ve used this design pattern in countless situations in multiple different languages, but it is the most useful when creating and managing rules.

In the following simplified scenario, I want to validate a Recording object that has multiple versions. Depending on the version a different set of rules needs to run.

public class Recording {

public enum RECORDING_VERSIONS{
v1,
v2
}
private RECORDING_VERSIONS version;
private String title;
private String composer;
private String duration;
private String artist;
private String lyrics;
// Assume getters and setters
}

In version one (v1) only the title and the artist need to be not blank, but in version two (v2) title, artist, and composer are all not blank.

The quickest way that usually comes to mind to write this validator is as follows.

public class Validator {

    private boolean isBlankOrNull(String str){
        return str == null || str == "";
    }

    public List<String> validateRecordings(Recording recording){

        if (recording == null){
            return Arrays.asList("Recording was null");
        }

        List<String> errors = new ArrayList<String>();

        if (Recording.RECORDING_VERSIONS.v1.equals(recording.getVersion())){
            // DO version one rules
            if(isBlankOrNull(recording.getTitle())){
                errors.add("Recording title is blank or null");
            }

            if(isBlankOrNull(recording.getArtist())){
                errors.add("Recording artist is blank or null");
            }
        }else if (Recording.RECORDING_VERSIONS.v2.equals(recording.getVersion())){
            // DO version two rules
            if(isBlankOrNull(recording.getTitle())){
                errors.add("Recording title is blank or null");
            }


            if(isBlankOrNull(recording.getArtist())){
                errors.add("Recording artist is blank or null");
            }
            if(isBlankOrNull(recording.getComposer())){
                errors.add("Recording composer is blank or null");
            }
        } else {
            throw new RuntimeException("Unsupported version type " + recording.getVersion());
        }

        return errors;
    }
}

This approach creates a rat’s nest of if statements where the outer if’s check the version and the nested if states test each rule. This can quickly increase the code’s complexity and make it harder to read and test. Particular if

  • More versions are added
  • The data model is expanded
  • Recording subclasses are added

A designed approach

The following approach creates more class artifacts but separates the versions into their own classes, which checks the recording to see if the rule should be run.

Simple Rules Engine Design

In the above diagram, we see an interface IRule with two methods

  • process: which actually runs the rule’s logic
  • shouldProcess: which checks the data to see if the rule should be run

In the code scenario below these methods are renamed to runRule and shouldRun.

public interface IRule {
public boolean shouldRun(Recording recording);
public List runRule(Recording recording);
}

Each rule implements this interface so that the Rules Engine can process the rule. When implementing this pattern in languages such as JavaScript or Python there are no interfaces so you will need to take care that these methods are defined correctly. When coding in these languages I will often create a class that defines these methods and throw an error if they aren’t overridden by any classes that extend it.

The rules engine then creates a collection of these implemented rules to be looped over.

public class Validator {

    private final List<IRule> rules;
    public Validator(){
        rules = Collections.unmodifiableList(Arrays.asList(new VersionOneRules(), new VersionTwoRules()));
    }

    public List<String> validateRecordings(Recording recording){
        if (recording == null){
            return Arrays.asList("Recording is null");
        }

        List<String> errors = new ArrayList<String>();
        for ( IRule rule : rules){
            if(rule.shouldRun(recording)){
                errors.addAll(rule.runRule(recording));
            }
        }
        return errors;
    }
}

Notice how each rule is instantiated into the list when the validator is constructed. Dependency injection can be used to populate the rules list if the code uses an application framework such as Spring. In the validateRecordings method a for loop is used to loop through the rules. Each rule is checked against the data and ran if the rule’s conditions are met, otherwise the rule is ignored.

Let now look at the rules

public class VersionOneRules implements IRule {

    public boolean shouldRun(Recording recording) {
        return Recording.RECORDING_VERSIONS.v1.equals(recording.getVersion());
    }

    public List<String> runRule(Recording recording) {
        List<String> errors = new ArrayList<String>();
        // DO version one rules
        if(RulesUtilities.isBlankOrNull(recording.getTitle())){
            errors.add("Recording title is blank or null");
        }

        if(RulesUtilities.isBlankOrNull(recording.getArtist())){
            errors.add("Recording artist is blank or null");
        }
        return errors;
    }
}

Rule one contains the same logic as the v1 path of the old validator. The shouldRun method checks for the recording’s version.

public class VersionTwoRules implements IRule {
    public boolean shouldRun(Recording recording) {
        return Recording.RECORDING_VERSIONS.v2.equals(recording.getVersion());
    }

    public List<String> runRule(Recording recording) {
        List<String> errors = new ArrayList<String>();
        if(RulesUtilities.isBlankOrNull(recording.getTitle())){
            errors.add("Recording title is blank or null");
        }

        if(RulesUtilities.isBlankOrNull(recording.getArtist())){
            errors.add("Recording artist is blank or null");
        }
        if(RulesUtilities.isBlankOrNull(recording.getComposer())){
            errors.add("Recording composer is blank or null");
        }
        return errors;
    }
}

Like v1, the v2 rule isolates the version two logic and uses shouldRun to check if this rule should be applied to the recording.

What has this accomplished?

Now that each rule’s logic has been isolated, we can easily look at each version’s ruleset. This allows for easier testing as one file/class can be tested in isolation and separate from the validation framework. If additional recording versions are added, updating the code is as easy as creating a new Rule class for that version and adding it to the rules list. If a version’s logic is changed it can be easily located and tested with the confidence that other versions were not impacted.

Conclusion

The rules engine is the easiest example to show how to use this pattern, but it becomes a valuable pattern anytime a long if-else or switch statement needs to be replaced. It is also useful when data may match multiple conditions and have multiple processes ran against it. Ultimately it improves readability, maintainability, and testability of your code.

The code for these examples can be found athttps://github.com/djchi82/rulesdesignpattern

Categories: Blog, Software Development

Tags: , , , ,

Ryan Van Fleet
12 Nov, 2019