I recently came across Martin Fowler's article Refactoring to an Adaptive Model, which shows how to convert application logic to a production rules system. This is useful to create portable business logic that can be interpreted in any language.
Fowler uses JavaScript and JSON in the article. As a Rubyist, I thought it would be fun to follow along using Ruby and YAML to see what the end result looked like.
This will be the first of a series of articles that will progressively build on this idea. Along the way, we'll look at some neat refactors using Ruby and try to take things a little further with some metaprogramming.
Follow along
You can view the code for this article on repl.it. In the editor, you can edit the test spec in main.rb
and click Run to see the results. Modify the code in model.yml
or recommender.rb
to change how the system works.
Background
Fowler frames the problem as having to create software to recommend cricket breeds to use in brewing potions. The software should take a specification, or spec, and compare it against a list of conditions called a model to find out which cricket breeds are needed based on the spec.
Starting out
Fowler begins with a recommendation function in JavaScript:
export default function (spec) {
let result = [];
if (spec.atNight) result.push("whispering death");
if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
if (spec.seasons && spec.seasons.includes("summer")) {
if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening");
}
if (spec.minDuration >= 150) {
if (spec.seasons && spec.seasons.includes("summer")) {
if (spec.minDuration < 350) result.push("white lightening");
else if (spec.minDuration < 570) result.push("little master");
else result.push("wall");
}
else {
if (spec.minDuration < 450) result.push("white lightening");
else result.push("little master");
}
}
return _.uniq(result);
}
A spec for this recommendation function would likely look like a list of the conditions under which the function is being run. We'll convert this JavaScript object to a Ruby hash later:
{
"atNight": true,
"seasons": ["summer"],
"country": "sparta",
"minDuration": 300
}
Let's convert the recommendation function to Ruby:
def recommender(spec)
result = []
result << "whispering death" if spec["atNight"]
result << "beefy" if spec["seasons"] && spec["seasons"].include?("winter")
if spec["seasons"] && spec["seasons"].include?("summer")
result << "white lightning" if ["sparta", "atlantis"].include?(spec["country"])
end
if spec["minDuration"] && spec["minDuration"] >= 150
if spec["seasons"] && spec["seasons"].include?("summer")
if spec["minDuration"] < 350
result << "white lightning"
elsif spec["minDuration"] < 570
result << "little master"
else
result << "wall"
end
else
if spec["minDuration"] < 450
result << "white lightning"
else
result << "little master"
end
end
end
result.uniq
end
A few things to note here: the Ruby code is a bit longer. We could shorten it a bit by using more single-line conditions but I tried to replicate the original structure as closely as possible. In the JavaScript code, we're able to use dot notation to dig down into the spec; in Ruby we must provide keys in square brackets. Also in the Ruby version, we need to check for the presence of minDuration
before querying its value, otherwise our code will throw an error.
For the rest of this article we'll work in Ruby. Feel free to reference the original article if you'd like to see the process in JavaScript.
Building a class
Let's make the recommender into a Ruby class. We'll borrow from the service object pattern and have the main method of the class be #call
:
class Recommender
def call(spec)
result = []
result << "whispering death" if spec["atNight"]
result << "beefy" if spec["seasons"] && spec["seasons"].include?("winter")
if spec["seasons"] && spec["seasons"].include?("summer")
result << "white lightning" if ["sparta", "atlantis"].include?(spec["country"])
end
if spec["minDuration"] && spec["minDuration"] >= 150
if spec["seasons"] && spec["seasons"].include?("summer")
if spec["minDuration"] < 350
result << "white lightning"
elsif spec["minDuration"] < 570
result << "little master"
else
result << "wall"
end
else
if spec["minDuration"] < 450
result << "white lightning"
else
result << "little master"
end
end
end
result.uniq
end
end
Now we can analyze a spec by running Recommender.new.call(spec)
.
Basics of the Production Rule pattern
Because our potion brewers need to run this recommendation logic on lots of different platforms, we want to convert our recommendation rules into some system-agnostic structured data like YAML.
We're going to build a production rule system by encoding the recommendation logic as a list of rules, each of which has a condition and an action.
Here are the first two rules as represented in our recommender method:
result << "whispering death" if spec["atNight"]
result << "beefy" if spec["seasons"] && spec["seasons"].include?("winter")
We can encode these rules as Ruby objects:
model = [
{
condition: -> (spec) { spec["atNight"] },
action: -> (result) { result << "whispering death" },
},
{
condition: -> (spec) { spec["seasons"] && spec["seasons"].include?("winter") },
action: -> (result) { result << "beefy" },
},
]
This is an array of hashes, each having a :condition
and :action
. The conditions and actions are stored as lambdas โ if you're not familiar with lambdas, don't worry, we'll be refactoring them out soon.
Now let's add a method to execute our new rule model. Assuming model
is the above array:
def execute_model(spec)
result = []
model
.select { |rule| rule[:condition].call(spec) }
.each { |rule| rule[:action].call(result) }
result
end
Here's what's happening. The #select
method filters out rules for which the :condition
is not fulfilled. For example, if our spec did not contain atNight: true
, then the first rule would be filtered out. The #each
method executes the code in each of the remaining rules' :action
s.
Because both of our rules have the same action (pushing a result into an array), we can simplify the :action
to contain just the resulting string, and move the array logic into the engine. We can also remove the collecting result
var in #execute_model
by using .map
instead of .each
:
model = [
{
condition: -> (spec) { spec["atNight"] },
action: "whispering death",
},
{
condition: -> (spec) { spec["seasons"] && spec["seasons"].include?("winter") },
action: "beefy",
},
]
def execute_model(spec)
model
.select { |rule| rule[:condition].call(spec) }
.map { |rule| result << rule[:action] }
end
Representing the night logic
As we refactor, let's move our rules into a YAML file and load that into our recommender. Here's the first rule in model.yml
:
- condition: atNight
action: whispering death
In recommender.rb
, let's make the YAML model available as an instance variable:
require "yaml"
class Recommender
attr_reader :model
...
private
def model
@model ||= YAML.load_file("model.yml")
end
end
We'll update our engine to understand the conditions from the YAML, which are just strings:
def execute_model(spec)
model
.select { |rule| active?(rule, spec) }
.map { |rule| result << rule[:action] }
end
def active?(rule, spec)
if rule["condition"] == "atNight"
return spec["atNight"]
end
end
To do this, we've added an #active?
method. This is where the logic for our rules will live for now.
We execute #active?
on every rule in the model. If the rule's condition
is atNight
, then we return the value of atNight
in the spec. This means if the spec contains atNight
, we return true
, otherwise we return false
. This means that the rule is considered "active" if the spec contains atNight
.
This is how we will refactor every rule. We'll encode their conditions as named strings, and use the conditions in #active?
to write logic that determines whether the rule should be considered active according to the spec.
Now that we've got one rule working with the new system, we can replace it in the original #call
method. Here's how the full Recommender class looks now:
require "yaml"
class Recommender
attr_reader :model
def call(spec)
result = []
result << execute_model(spec)
result << "beefy" if spec["seasons"] && spec["seasons"].include?("winter")
if spec["seasons"] && spec["seasons"].include?("summer")
result << "white lightning" if ["sparta", "atlantis"].include?(spec["country"])
end
if spec["minDuration"] && spec["minDuration"] >= 150
if spec["seasons"] && spec["seasons"].include?("summer")
if spec["minDuration"] < 350
result << "white lightning"
elsif spec["minDuration"] < 570
result << "little master"
else
result << "wall"
end
else
if spec["minDuration"] < 450
result << "white lightning"
else
result << "little master"
end
end
end
result.uniq
end
private
def execute_model(spec)
model
.select { |rule| active?(rule, spec) }
.map { |rule| rule["action"] }
end
def active?(rule, spec)
if rule["condition"] == "atNight"
return spec["atNight"]
end
end
def model
@model ||= YAML.load_file("model.yml")
end
end
Note that the first condition in #call
has been refactored out into #execute_model
.
Representing the season logic
Here's our second condition in Ruby:
result << "beefy" if spec["seasons"] && spec["seasons"].include?("winter")
This logic to check for the presence of seasons
before digging into it is present in a bunch of our original conditions, so let's extract it into a method:
def season_includes?(spec, arg)
spec.dig("seasons")&.include?(arg)
end
Anywhere we see spec["seasons"] && spec["seasons"].include?(...)
, we can instead use this new method. Then, in our #active?
method, we can also call this for the seasonIncludes
condition:
def active?(rule, spec)
if rule["condition"] == "atNight"
return spec["atNight"]
elsif rule["condition"] == "seasonIncludes"
return season_includes?(spec, rule["arguments"].first)
end
end
This new condition is saying: "the rule is active if the spec's 'seasons' key contains the value in the rule's arguments". But let's take out that elsif
โ we know we're eventually going to have other condition types within this method, so let's build a case
statement:
def active?(rule, spec)
case rule["condition"]
when "atNight"
return spec["atNight"]
when "seasonIncludes"
return season_includes?(spec, rule["arguments"].first)
end
end
And we can add some basic error handling with the else
case:
def active?(rule, spec)
case rule["condition"]
when "atNight"
return spec["atNight"]
when "seasonIncludes"
return season_includes?(spec, rule["arguments"].first)
else
raise "Don't know how to handle condition: #{rule["condition"]}"
end
end
Now that we have basic handling of the seasons condition, we can remove the "beefy" line from the original sequence since it's handled by #execute_model
:
result << "beefy" if spec["seasons"] && spec["seasons"].include?("winter")
Again, here's the full Recommender class as it looks now:
require "yaml"
class Recommender
attr_reader :model
def call(spec)
result = []
result << execute_model(spec)
if season_includes?(spec, "summer")
result << "white lightning" if ["sparta", "atlantis"].include?(spec["country"])
end
if spec["minDuration"] && spec["minDuration"] >= 150
if season_includes?(spec, "summer")
if spec["minDuration"] < 350
result << "white lightning"
elsif spec["minDuration"] < 570
result << "little master"
else
result << "wall"
end
else
if spec["minDuration"] < 450
result << "white lightning"
else
result << "little master"
end
end
end
result.uniq
end
private
def model
@model ||= YAML.load_file("model.yml")
end
def execute_model(spec)
model
.select { |rule| active?(rule, spec) }
.map { |rule| rule["action"] }
end
def active?(rule, spec)
case rule["condition"]
when "atNight"
return spec["atNight"]
when "seasonIncludes"
return season_includes?(spec, rule["arguments"].first)
else
raise "Don't know how to handle condition: `#{rule["condition"]}`"
end
end
def season_includes?(spec, arg)
return spec.dig("seasons")&.include?(arg)
end
end
Representing the country logic
Next up we have a slightly more complicated condition:
if season_includes?(spec, "summer")
result << "white lightning" if ["sparta", "atlantis"].include?(spec["country"])
end
Let's worry about the country logic first. As we did with the seasons, we can extract this check into a method:
def country_included_in?(spec, countries)
return countries.include?(spec.dig("country"))
end
This is like the reverse of our seasons condition โ instead of checking whether the argument is in an array, we're passing the array as the argument and checking whether the country in our spec is included in it.
Representing conjunctions
This is where things start getting fun. This rule requires us to represent both a season and a country condition. We only want our result to include "white lightning" if both of the conditions are true.
To do this, let's add a new and
condition to our model:
- condition: and
arguments:
- condition: seasonIncludes
arguments:
- winter
- condition: countryIncludedIn
arguments:
- sparta
- atlantis
action: white lightning
This rule's arguments
are themselves rules. We want this condition to be considered active if all its sub-rules are active. Since we already have a method that checks whether a rule is active, we can use a little recursion to make this conjunction work:
def active?(rule, spec)
case rule["condition"]
...
when "and"
return rule["arguments"].all? { |subrule| active?(subrule, spec) }
...
end
Ruby's #all?
method makes this relatively easy: using a block we check whether the rules in arguments
are all active. If so, the parent rule is considered active.
As usual, here's the full Recommender class at this point:
require "yaml"
class Recommender
attr_reader :model
def call(spec)
result = []
result << execute_model(spec)
if spec["minDuration"] && spec["minDuration"] >= 150
if season_includes?(spec, "summer")
if spec["minDuration"] < 350
result << "white lightning"
elsif spec["minDuration"] < 570
result << "little master"
else
result << "wall"
end
else
if spec["minDuration"] < 450
result << "white lightning"
else
result << "little master"
end
end
end
result.uniq
end
private
def model
@model ||= YAML.load_file("model.yml")
end
def execute_model(spec)
model
.select { |rule| active?(rule, spec) }
.map { |rule| rule["action"] }
end
def active?(rule, spec)
case rule["condition"]
when "atNight"
return spec["atNight"]
when "seasonIncludes"
return season_includes?(spec, rule["arguments"].first)
when "countryIncludedIn"
return country_included_in?(spec, rule["arguments"])
when "and"
return rule["arguments"].all? { |subrule| active?(subrule, spec) }
else
raise "Don't know how to handle condition: `#{rule["condition"]}`"
end
end
def season_includes?(spec, arg)
return spec.dig("seasons")&.include?(arg)
end
def country_included_in?(spec, countries)
return countries.include?(spec.dig("country"))
end
end
Here's the system we've built so far. With that, we're about halfway through the original conditions. In the next article, we'll explore how to represent the conditions dealing with durations and ranges.