Test-Driving a Decision Engine

Anuj Biyani
Apr 7 · 5 min read

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”
end
end
end

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.

Individual rules

In its simplest form, the rule takes nothing as input and outputs a decision. The framework for each rule is defined like this:

class Rule
def decision
raise(“Your rules should extend from this class and implement this method.”)
end
end

And an implementation of a rule might look like this:

class UserHasNoPostsRule < Rule
def decision
user.posts.count == 0
end
end

(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 true or false.

Where this really pays off is in your tests:

describe UserHasNoPostsRule do
context “when the user has posts” do
it “is false”
end

context “when the user has no posts” do
it “is true”
end
end

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.

class Decider
attr_reader :rules
private :rules

DEFAULT_RULES = [SomeRule.new]
def initialize(rules: DEFAULT_RULES)
@rules = rules
end
def execute
rules.all?(&:decision)
end
end

It’s tempting to say that rules.all? (or 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
def decision
true
end
end

class TestFalseRule < Rule
def decision
false
end
end

context “when all rules are true” do
it “is true” do
decider = Decider.new(rules: [TestTrueRule.new])
expect(decider.execute).to eq(true)
end
end

context “when one rule is false” do
it “is false” do
decider = Decider.new(rules: [TestFalseRule.new, TestTrueRule.new])
expect(decider.execute).to eq(false)
end
end
end

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:

class Decision
attr_reader :visible, :priority

def initialize(visible: visible, priority: priority)
@visible = visible
@priority = priority
end
end

Then an individual rule might change to:

class UserHasNoPostsCardRule
def execute
Decision.new(visible: user.posts.count == 0, priority: 1)
end
end

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:

class SomeService
attr_reader :card_rules
private :card_rules
DEFAULT_CARD_RULES = [] def initialize(rules: [DEFAULT_CARD_RULES])
@card_rules = card_rules
end
def execute
card_rules.map(&:execute).select(&:visible).sort_by(&:priority)
end
end

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 priority.

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:

class UserHasPostsCardRule
def execute
opposite_decision = UserHasNoPostsCardRule.new.execute
Decision.new(visible: !opposite_decision.visible, priority: opposite_decision.priority)
end
end

Compound rules

Suppose you need a rule that is composed of two, UserHasPostsCardRule and UserIsVerifiedCardRule. Similar to the above, I would define a new rule that calls both individual rules and returns a single decision:

class UserIsActiveCardRule
def execute
has_posts_decision = UserHasPostsCardRule.new.execute
is_verified_decision = UserIsVerifiedCardRule.new.execute
Decision.new(visible: has_posts_decision && is_verified_decision, priority: 1)
end
end

Final Thoughts

I found my final implementations to be better than the gems out there for a couple reasons:

  1. My DSL is pretty straightforward. Everyone on the team understood what the purpose was based on the names alone.
  2. 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.
  3. There isn’t a lot of boilerplate. One or two interfaces, depending on your needs, and that’s about it.
  4. 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.

One Medical Technology

Stories from Engineering, Product, and Design

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store