At multiple companies, I’ve had to build relatively simple decision engines. Something like “given this series of conditions, do we send the customer down one flow or another?” I’ve evaluated a couple Ruby gems before, but found the syntax so obtuse I just couldn’t get on board with using them. My initial attempts at building them from scratch, however, also didn’t work out very well. The issue wasn’t so much with the implementation as it was with the tests; invariably, they would boil down to:
context “if the user has no posts” do
context “and the user has no friends” do
context “and the user has linked an external account” do
context “and some other condition”
Writing this alone was a headache, let alone changing a nested condition a few months down the line.
With a bit of structure and separation of concerns, however, I was able to transform this into something easy to understand, composable, and maintainable. And it’s easy to implement in a variety of languages (I’ve used this pattern in an Angular-driven Typescript app and a Rails API). It boils down to this: have one class that takes all of your rules as input and outputs a decision, and have one class per rule that can be tested independently of each other.
In its simplest form, the rule takes nothing as input and outputs a decision. The framework for each rule is defined like this:
raise(“Your rules should extend from this class and implement this method.”)
And an implementation of a rule might look like this:
class UserHasNoPostsRule < Rule
user.posts.count == 0
(I’m purposefully ignoring performance optimizations like N+1 handling and memoizing for this sample code, but don’t forget about it in your actual code.)
This actually suffices for a lot of cases. You might pass into the initializer an API service, or your global store, if the rule is in a frontend client that needs to fetch some data. Or you might have to pass in the decision from another rule if you have rules that depend on rules.
Either way, you want to distill the inputs to your rule down to only the things it cares about, and output just
Where this really pays off is in your tests:
describe UserHasNoPostsRule do
context “when the user has posts” do
it “is false”
context “when the user has no posts” do
it “is true”
Note that there’s nothing in here about any other rule; that belongs in
describe OtherRule’s tests. And because each rule is tested independently, if you want to delete a rule, you can just delete the entire block of tests; no line-by-line changes needed!
Gluing the rules together
The next step is to act on all of your rules. Here we don’t need an interface like the Rule class as there is only one top-level class, and its only job is to decide “yes” or “no” based on all of the individual rules.
DEFAULT_RULES = [SomeRule.new] def initialize(rules: DEFAULT_RULES)
@rules = rules
end def execute
It’s tempting to say that
rules.any?) is too simplistic for the real world. In practice, I’ve found this works surprisingly often as long as you push additional logic down to the individual rules. You want to avoid, at all costs, logic in your decider that is based on an individual rule. Why? Because that allows your tests to be delightfully simple:
describe Decider do
class TestTrueRule < Rule
class TestFalseRule < Rule
context “when all rules are true” do
it “is true” do
decider = Decider.new(rules: [TestTrueRule.new])
context “when one rule is false” do
it “is false” do
decider = Decider.new(rules: [TestFalseRule.new, TestTrueRule.new])
Through dependency injection, we can create a fake rule with no internal logic and then pass that in. Our real rules are tested independently, so we don’t need to re-test them here; we just need to test the gluing logic, and the above tests do that perfectly.
Getting more complex with your engine
Suppose your engine has more intricate needs: in one scenario I needed to output whether or not a “card” was visible and the order in which it should be presented to the user. The above structure can actually adapt to this without much difficulty.
Changes to your rules
I added a class like this to give structure to the output of my rules:
attr_reader :visible, :priority
def initialize(visible: visible, priority: priority)
@visible = visible
@priority = priority
Then an individual rule might change to:
Decision.new(visible: user.posts.count == 0, priority: 1)
Still easy to test, (re-)compose, and understand. I was able to work with fixed priority for a given rule, but you could throw some logic in there if need be.
Changes to your decider
It’s actually not really a decider anymore so you probably want to choose a more generic name, but the only functional difference is in your execute method:
private :card_rules DEFAULT_CARD_RULES =  def initialize(rules: [DEFAULT_CARD_RULES])
@card_rules = card_rules
end def execute
Now we filter by the
visible cards and sort by priority. You can probably imagine how the tests look; it’s still got the clean separation of concerns from before, but now our tests on
SomeService also assert on the order and our individual rules assert on the
Negating a rule
The simplest way to do this is to define a new rule like
UserHasPostsCardRule (the opposite of our rule above,
UserHasNoPostsCardRule), call the original rule, and invert the decision:
opposite_decision = UserHasNoPostsCardRule.new.execute
Decision.new(visible: !opposite_decision.visible, priority: opposite_decision.priority)
Suppose you need a rule that is composed of two,
UserIsVerifiedCardRule. Similar to the above, I would define a new rule that calls both individual rules and returns a single decision:
has_posts_decision = UserHasPostsCardRule.new.execute
is_verified_decision = UserIsVerifiedCardRule.new.execute Decision.new(visible: has_posts_decision && is_verified_decision, priority: 1)
I found my final implementations to be better than the gems out there for a couple reasons:
- My DSL is pretty straightforward. Everyone on the team understood what the purpose was based on the names alone.
- Further, since it’s not a gem, you can just choose names that work for you. If your team tends to use different jargon, go with that.
- There isn’t a lot of boilerplate. One or two interfaces, depending on your needs, and that’s about it.
- It’s highly composable, and testable. A great example of separation of concerns.
If you’ve got use cases that you don’t think fit, let me know! Maybe this structure isn’t as effective as I thought, or maybe there’s a creative way to still make it work.