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' :actions.

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.

Adam Hollett's face against a dark wood background

Adam Hollett is a developer and technical writer at Shopify in Ottawa, Ontario.