Rule Engine Pattern Part 2 - Function as First-Class Citizen

Yini Yin
6 min readJul 29, 2020

Inheritance is not cheap

In last episode we started off this idea of Rule Engine as a way to separate policy versus operation. We implement the Rule Engine using class and inheritance. Class offers a great deal of encapsulation through which we have this clear sense of boundary that wraps around a single purpose unit. We also enjoy a vast array of language features that comes with it, e.g. private state, behaviours, local functions, virtual methods, etc. Inheritance however, is one of the strongest couplings in object-oriented paradigm, if not careful, it could make our solution over-complicated, or worse, counter productive.

I always reason about inheritance as a way to express classification or taxonomy when the nature of problem domain bears the characteristics of specialisation and specification which warrants us to model it this way. Think about specialisation as a set of the same thing but each one expresses a slightly different flavour, whereas specification is about molding. Inheritance is suitable for modelling problems that are lack of behaviours but rich in states or side effects. Whereas composition is the other way around.

It is hard to get inheritance right. Also, it is hard to justify whether or not the tight coupling is a good investment upfront because it is very hard to get our intuition correct for the problem we try to model upfront. And if you are a library writer, it is a big ask for your end users to have to inherit your APIs in order to use your library. Most problem domains that I have experienced are heterogeneous, very few that are a 100% match to a model of hierarchy expressed intuitively by inheritance. So we may apply other less sophisticated or less intrusive techniques while we let the pattern arise as we iterate through development cycle. Because it is always easier to refactor from simpleness to complicatedness than complicatedness to simpleness. So what do we do if we don’t use inheritance?

Function as Data

If we want simplicity, we have to look to Functional Programming. As its name suggests, Functional Programming is all about functions. What I like about FP is the way we treat computation as if it is a piece of data! Pause, and think about it! We can pass it, receive it, return it, store it, mutate it, etc. This enables us to look at our Rule Engine from a completely different angle. We can think about our rules as a computation unit that takes some input, returns some side effect. They don’t have to be taxonomically related, no particular lineage or common specification. They can simply be a bunch of computations we want to run.

Rule Engine Lite

Code below is pretty much all there is for our functional style Rule Engine! Rules are Funcs that get passed in to a rule runner in which they get run one by one, short circuits when first failed rule is encountered. There are two constraints we purposefully impose on:

All rules need to return a predefined type of result which is basically meta data of how the rule went, e.g. passed or not, reason why failed, etc. This result type could be implemented as generics type parameter if we want more flexibility.

All rules that run under a particular engine instance must run under the same Rule Context.

We call it Rule Context but not state, because state implies potential mutability, whereas context is generally immutable. We want to make a distinctive separation as what is mutable and what is not when it comes to dealing with side effect which will elaborate a bit later.

Here is how we use it in this fictional application that allows users to apply for credit cards on planet Mars.

Here is the context that all rules run under:

We have a set of rules called Eligibility which checks against various preliminaries e.g. user must be over 18.

We have another set of rules that checks if users meet the Mars residency criteria:

Each method inside a rule collection class is a rule function. The rule collection classes e.g. EligibilityRule, MarsRule, act as a wrapper that closes rules within and exposes them as a list of Func<ClusterRuleCtx, RuleResult>. Each rule should fulfil a single purpose and a single purpose only. Rules that are motivated by a common theme should be grouped into a collection.

Multiple rule collections then can be aggregated into a rule cluster. Rule -> Collection -> Cluster, so that we can dice and slice when dealing with extreme large number of rules.

Then we run those rules off the extension method of the Rule Engine.

With functional style Rule Engine, we are able to remove the verbosity that comes with inheritance Rule Engine, but retain most of the good parts e.g. Rule boundary (single purpose), open-close principle, etc., without costing an arm to strong coupling!

Who doesn’t love Side Effect?!

Side effect gets a bad rap in Functional Programming but…what good is it if our programs don’t produce side effect? Essentially what we do as a software engineer day in and day out is all about side effect. You may argue we could completely avoid side effect by pure functions: x + y = z. Well, your local system may not have side effect, but the returned value that your system produces must have a receiving end. So what the system at the receiving end is going to do when they receive your value? If it stores it, it is a state change. If it displays it on web, stdout, print, etc, any form of medium really, it is a state change. If it swallows it as an input and never has any returned value after, then it is basically a closed system that has no observability can be made to the state or effect of the system, but what good is it if all our system does is operations like x + y = n/a? If it passes its received value to another system, we basically repeat the cycle again.

So in a useful system, it is useful to have side effect. It is a matter of how do we manage it. In Rule Engine, we need to make a hard separation between rules that produce side effect versus rules that are simply pure functions operate under an immutable context.

As every rule might produce its own side effect, we need a storage for all their side effects. The storage I choose is a dictionary with the type of side effect as its key. If multiple rules produce the same type, the rule was run last overwrites previous side effect. If we want to memorise all rules’ side effects regardless, we could use rule’s name as the key and a tuple(Type type, object val) as the value for example. Very similar to previous FirstFails rule runner, the only difference is we memorise side effect as we go:

The result type of the rule contains a side effect tuple(Type type, object val):

The reason I decided to lose the side effect’s type, stores it into an object, then link it to its type as a key, is because each rule may have a different side effect type, as far as I am aware of, unlike JS, there is no data structure available in C# that has the flexibility to store any type of objects without losing their type information. Also store it as a Dictionary<Type, object> is easier to perform operations such as Pick in below example:

Sky’s the Limit

So far we have seen only one type of rule runner that runs rules one at the time. There are many other types as far as you can imagine! Here I show you a few hints:

Rule runner runs all rules:

Async short circuit rule runner:

Parallel async rule runner:

Last if You have Lasted Thus Far

Functional style Rule Engine is a natural fit for problems that can be solved computationally i.e. by a composite of functions. Whereas inheritance or class style Rule Engine is where we want to describe the taxonomical relationships expressed in the problem. There are certainly other approaches demanded by the nature of the problem. Whichever way we go about implementing it, very important we draw clear boundaries (responsibilities) between various components: Rules which define our business requirements, immutable Rule Context, Rule Engine which governs how rules should be run, and side effect.

--

--

Yini Yin

Try not to be a jack of all trades always end up being one. Dev@Barin