This project aims at providing a set of tools to build complex and evolving business rules. It helps when:
- You decided to split the computation into smaller parts working together.
- You need to keep old versions of the rules working.
- You want to persist the rules leading to a result.
- You want to make your tests independent from your production data.
- You want to introspect the computation of a result.
How does it work
The idea is very similar to function composition or Rack's middlewares. It is a layering abstraction where each layer works for the next layers in order for the whole to produce a single result.
An engine respond to #call, taking a context in argument. It produces a
result that is extracted from the context by the #result method. Before
doing that, the engine apply its rules.

Each rule have two methods: #trigger? and #apply. #apply runs only when
trigger? is true. #apply writes stuff to the context for other rules and
for the engine to produce the result.

A typical usage for this kind of engine is to use it to compute the price of a service or a good. But, this is not limited to that use-case as an engine would be able to produce any kind of results.
How does it look
Here is an example from the Elephant Carpaccio kata. The specs are:
Accept 3 inputs from the user:
- How many items
- Price per item
- 2-letter state code
Output the total price. Give a discount based on the total price, add state tax based on the state and the discounted price.
require "brule"
module Pricing
class Engine < Brule::Engine
def result
context.fetch(:price)
end
end
class OrderTotal < Brule::Rule
context_reader :unit_price, :item_count
context_writer :price
def apply
self.price = unit_price * item_count
end
end
class Discount < Brule::Rule
config_reader :rates
context_accessor :price, :discount_rate, :discount_amount
def trigger?
!applicable_discount.nil?
end
def apply
self.discount_rate = applicable_discount.last
self.discount_amount = (price * discount_rate).ceil
self.price = price - discount_amount
end
private
def applicable_discount
rates
.sort_by { |order_value, _| order_value * -1 }
.find { |order_value, _| order_value <= context.fetch(:price) }
end
end
class StateTax < Brule::Rule
config_reader :rates
context_reader :state
context_accessor :price, :state_tax
def apply
tax_rate = rates.fetch(state)
self.state_tax = (price * tax_rate).ceil
self.price = price + state_tax
end
end
end
require "bigdecimal"
require "bigdecimal/util"
engine = Pricing::Engine.new(
rules: [
Pricing::OrderTotal.new,
Pricing::Discount.new(
rates: [
[ 1_000_00, "0.03".to_d ],
[ 5_000_00, "0.05".to_d ],
[ 7_000_00, "0.07".to_d ],
[ 10_000_00, "0.10".to_d ],
[ 50_000_00, "0.15".to_d ],
],
),
Pricing::StateTax.new(
rates: {
"UT" => "0.0685".to_d,
"NV" => "0.0800".to_d,
"TX" => "0.0625".to_d,
"AL" => "0.0400".to_d,
"CA" => "0.0825".to_d,
},
),
],
)
result = engine.call(
item_count: 100,
unit_price: 100_00,
state: "NV",
)
# Access the main result
result # => 9_720_00 ($9,720.00)
# Access the context
engine.context.fetch_values(
:discount_rate, # => 0.1 (10%)
:discount_amount, # => 1_000_00 ($1,000.00)
:state_tax, # => 720_00 ($720.00)
)
# Access the history
engine.history(key: :price) # => [
# => [:initial, nil],
# => [#<struct Pricing::OrderTotal ...>, 10_000_00],
# => [#<struct Pricing::Discount ...>, 9_000_00],
# => [#<struct Pricing::StateTax ...>, 9_720_00],
# => ]
What does it bring to the table
If you compare this approach with a simple method like this one:
require "bigdecimal"
require "bigdecimal/util"
STATE_TAXES = {
"UT" => "0.0685".to_d,
"NV" => "0.0800".to_d,
"TX" => "0.0625".to_d,
"AL" => "0.0400".to_d,
"CA" => "0.0825".to_d,
}
DISCOUNT_RATES = [
[ 50_000_00, "0.15".to_d ],
[ 10_000_00, "0.10".to_d ],
[ 7_000_00, "0.07".to_d ],
[ 5_000_00, "0.05".to_d ],
[ 1_000_00, "0.03".to_d ],
[ 0, "0.00".to_d ],
]
def pricing(item_count, unit_price, state)
price = item_count * unit_price
discount_rate = DISCOUNT_RATES.find { |limit, _| limit <= price }.last
state_tax = STATE_TAXES.fetch(state)
price * (1 - discount_rate) * (1 + state_tax)
end
... then you'll find significant differences:
- Over-abstraction versus under-abstraction
- 1st example uses the layering abstraction provided by the gem
- 2nd example uses nothing, YAGNI
- Generalization vs specialization
- 1st approach is more generic and could handle changes
- 2nd approach is more specialized and would require a rewrite
- Complexity versus brievety
- 1st example is more verbose and thus is harder to grasp
- 2nd example is more concise and fits in the head
- Configuration versus constants
- 1st example relies on rule's configuration
- 2nd example relies on
STATE_TAXESandDISCOUNT_RATES
- Observability vs black-box
- 1st example allows to provide more information (
state_taxand so on) - 2nd example only gives a single result
- 1st example allows to provide more information (
- Data-independent versus hard-coded values
- 1st example considers as much logic as possible as data
- 2nd example mixes data and logic together (with hidden dependencies and assumptions)
- Temporal extensibility or versionning
- 1st example can compute the price using different rules
- Without discounts or with different discount rate per client
- With tax rates from on year or another
- 2nd example will have to introduce options, or even different methods
- Testability
- 1st example could be tested at various levels without any mocks
- 2nd example have to mock hidden dependencies from the implementation
Overall, it is about finding the right level of abtsraction. This tiny framework helps you by providing you a little abstraction. Even if you're not using this gem directly, it can give you some ideas behind it.