Before delving into what the pattern is, it is important to know where the inspiration is drawn from — Unix Rule of Separation. If you haven’t read it, I strongly recommend you go through it.
Part two of the Rule Engine story is available now here. Although it is not necessary to read the first part, but it helps set the context as why we need to think about our Rule Engine in a different paradigm.
Motivation
I work in Rest API space quite a bit, I find myself constantly have to check a bunch of business rules to determine the eligibility of an operation. For example, a set of criteria needs to be met for a customer to be able to apply for a certain product, e.g. must be over 18 years old, must be a resident, must have a pre-approved credit limit, etc. Most people, at least from the code I have seen, will naturally relate the solution to if-else, which is one way of thinking about it. It is okay if we have a small set of business rules to check, it can, however, quickly become unmanageable when trying to apply it to more complex business rules.
In below contrived example in C#, we need to check the conditions of the zoo animals and report on their wellbeing. Let’s give if-else a go and I will list a few problems I’ve personally experienced.
Does not follow Single Responsibility and Open Close Principle
This is the most gnarly issue of all, yet the most subtle. It only makes itself apparent when changes come in as we iterate through sprints our understanding of the problem domain deepens.
Even in this contrived example, in each validation method we have multiple if statements responsible for different checks. In some real production code that I have seen, started off benign and small, and over the course of development, these validation methods were getting more and more complicated in order to cope with complex requirements, e.g. if statement blocks span over a few dozen lines of code some even hundreds, some have async operations, sub routines and nested if statements, just to name a few.
These complicated if statements tend to cover a vast number of different kinds of requirements. And as requirements change, and they change for different reasons at different rates, then we have a problem. Because in order to meet the new requirements, we have to ‘open’ up the existing code, tweak it, add new bits in and pray that we don’t introduce any bugs, and our unit tests still all pass, but only if there is a such suite of tests that could exercise through all the possible combinations of execution paths. Hard to test — this is another sign that our code is NOT structured in the manner of small, independent units of logic.
Drown in a sea of if-statements
I have come across in production code, that a single validation method littered with if-else statements that last for hundreds lines of code. To make matter worse, some even have seven or eight conditions in a single if statement. I must say whoever that is able to read it must be half human half compiler.
Well, this is not a problem if you can read the code below:
Ad-hoc priority management
Say we care more about the health conditions of animals more than shelter capacity so we want to return the health validation report first, for that to happen, we have to fiddle around the if statements copy and paste block of code does health check up to the top.
This lack of priority management opens up the possibility of errors. Even worse when sections of the if-else blocks interlinked or share state.
Lack of state management
There is no easy way to share state or to export the side effect after validations have run.
In programming, what we do in essence, is read or mutate state and the logic to do so. So state is pretty important. The side effect of one validation unit might become the input state of subsequent or other validation units. For example, we have validation for scavenger fish which input state is depending of other species output state.
Lack of variety of logical operations
There is no declarative way to specify how do we want validation units to be run. For example, we may want to our validations to run sequentially and short circuit when we hit the first fails. We may want to run all the validation units does not matter they fail or not. We may even want them to run asynchronously in parallel.
The anatomy of Rule Engine
Context
Context is a shared state that a collection of rules are running in. Context could be separated out as its own engine agnostic state machine mutable or immutable.
Rule
A block of code, a computation or logic unit that follows Single Responsibility and offloads side effect to Context if any.
Some think that Single Responsibility means doing one thing. However, the advocate of SOLID Robert C. Martin who started off SOLID (although he did not invent the abbreviation) says that SR means single reason to change. In real production code, I found the latter is more practical because it is literally impossible to make each single method or class do just one thing or we might get buried in what I call the function-inception — layers of layers those little one liner functions although do one thing but really have no point because the function names does not give us more understanding than that one liner code itself! And we’re really just abusing Stack in memory.
Also it makes sense because the whole idea of SOLID is being agile — how to go about responding to change. And if a unit of code has a single reason to change it is highly likely all its sub-units have the same frequency of change. Although it is doing multiple things, because this single reason, all its sub-units are cohesively glued together under the same theme.
Engine
A generic, business rule agnostic unit of mechanism that runs a collection of rules in a given configuration — async, pipe, first-in-first-run, parallel, exclusive-or, logical-and, etc.
The key is to separate the business rules vs running the rules. So rules and rule-runner can evolve independently.
Let’s see the rule engine then we demonstrate how to use it.
Non async sequential Rule Engine
There are many ways to write an engine and this is one way. You may extend the concept by adding async or parallel runner etc.
This engine has two configurations: hits the first failed rule then short-circuits; and runs all rules returns a dictionary contains any rules that failed.
Let’s look at the rules and rule collection.
CatRules collection inherits from RuleEngine. In rule collection’s constructor, it takes in its context and list out all the rules we want the engine to run. And by given a numeric number as priority when each rule is initiated.
Each rule is a single class that inherits from Rule<T> and should only test against a single business rule. Name the rule class carefully as its name will be returned if it fails.
Rules
With this approach, each business rule is a single Rule class, we won’t have trouble to tell if we have missed any rules. And as business requirement changes, the related rules can be changed or removed without breaking other rules. New rules can be added in at will with confidence old rules will still work. It demonstrates the effect of Open and Close principle.
Usage
Closing note
In this article we only demonstrate a Rule Engine that suitable for running non async sequential rules. However, Rule Engine can come in many different forms — Pipe Engine for example, as its name suggests, rules are run in a first in first run manner. Rule X’s side effect may or may not become Rule Y’s input state, it is your choice. As in a Parallel Engine implementation, the emphasis is rules are run independently of each other with no state or side effect shared among them.
In a very complex business domain, we may find ourselves needing a concept of what I call — Rule Engine Tribe. Where Rules and Engines in each Tribe serve a holistic purpose or theme. In a Tribe, we may have several state machines(contexts) for different rule collections to run under. So you may have a Lead Engine that aggregates rule collections and states under a Tribe. You may have several Tribes running in the same system that communicate to each other by some predefined protocols or interfaces.
However, please do not go overboard on this Rule Engine idea. Learn our business domain and understand it well, use the simplest way we can think of to solve the problem. Then evolve toward a more complex implementation slowly only when proven a simpler one is impracticable.