Strategic - Painless Strategy Pattern in Ruby and Rails
MIT License
(Featured in DCR Programming Language)
if
/case
conditionals can get really hairy in highly sophisticated business domains.
Object-oriented inheritance helps remedy the problem, but dumping all
logic variations in domain model subclasses can cause a maintenance nightmare.
Thankfully, the Strategy Pattern as per the Gang of Four book solves the problem by externalizing logic via composition to separate classes outside the domain models.
Still, there are a number of challenges with "repeated implementation" of the Strategy Pattern:
strategic
solves these problems by offering:
Strategic
enables you to make any existing domain model "strategic",
externalizing all logic concerning algorithmic variations into separate strategy
classes that are easy to find, maintain and extend while honoring the Open/Closed Principle and avoiding conditionals.
In summary, if you make a class called TaxCalculator
strategic by including the Strategic
mixin module, now you are able to drop strategies under the tax_calculator
directory sitting next to the class (e.g. tax_calculator/us_strategy.rb
, tax_calculator/canada_strategy.rb
) while gaining extra API methods to grab strategy names to present in a user interface (.strategy_names
), set a strategy (#strategy=(strategy_name)
or #strategy_name=(strategy_name)
), and/or instantiate TaxCalculator
directly (.new(*initialize_args)
), with default strategy (.new_with_default_strategy(*initialize_args)
), or with a strategy from the get-go (.new_with_strategy(strategy_name, *initialize_args)
). Finally, you can simply invoke strategy methods on the main strategic model (e.g. tax_calculator.tax_for(39.78)
).
Strategic
module in the Class to strategize: TaxCalculator
class TaxCalculator
include Strategic
# strategies implement tax_for(amount) method that can be invoked indirectly on strategic model
end
Now, you can add strategies under this directory without having to modify the original class: tax_calculator
Add strategy classes having names ending with Strategy
by convention (e.g. UsStrategy
) under the namespace matching the original class name (TaxCalculator::
as in tax_calculator/us_strategy.rb
representing TaxCalculator::UsStrategy
) and including the module (Strategic::Strategy
):
All strategies get access to their context (strategic model instance), which they can use in their logic.
class TaxCalculator::UsStrategy
include Strategic::Strategy
def tax_for(amount)
amount * state_rate(context.state)
end
# ... other strategy methods follow
end
class TaxCalculator::CanadaStrategy
include Strategic::Strategy
def tax_for(amount)
amount * (gst(context.province) + qst(context.province))
end
# ... other strategy methods follow
end
(note: if you use strategy inheritance hierarchies, make sure to have strategy base classes end with StrategyBase
to avoid getting picked up as strategies)
tax_calculator = TaxCalculator.new(args)
tax_calculator.strategy = 'us'
4a. Alternatively, instantiate the strategic model with a strategy to begin with:
tax_calculator = TaxCalculator.new_with_strategy('us', args)
4b. Alternatively in Rails, instantiate or create an ActiveRecord model with strategy_name
column attribute included in args (you may generate migration for strategy_name
column via rails g migration add_strategy_name_to_resources strategy_name:string
):
tax_calculator = TaxCalculator.create(args) # args include strategy_name
tax = tax_calculator.tax_for(39.78)
Default strategy for a strategy name that has no strategy class is nil
unless the DefaultStrategy
class exists under the model class namespace or the default_strategy
class attribute is set.
This is how to set a default strategy on a strategic model via class method default_strategy
:
class TaxCalculator
include Strategic
default_strategy 'canada'
# ... initialize and other methods
end
tax_calculator = TaxCalculator.new(args)
tax = tax_calculator.tax_for(39.78)
If no strategy is selected and you try to invoke a method that belongs to strategies, Ruby raises an amended method missing error informing you that no strategy is set to handle the method (in case it was a strategy method).
Add the following to bundler's Gemfile
.
gem 'strategic', '~> 1.2.0'
Or manually install and require library.
gem install strategic -v1.2.0
require 'strategic'
Steps:
Strategic
(e.g. def TaxCalculator; include Strategic; end
tax_calculator/
)tax_calculator/us_strategy.rb
) (default is assumed as tax_calculator/default_strategy.rb
unless customized with default_strategy
class method):Strategic::Strategy
moduleStrategy
suffix (e.g. NewCustomerStrategy
)strategy=
attribute writer method or instantiate with new_with_strategy
class method, which takes a strategy name string (any case), strategy class, or mirror object (having a class matching strategy name minus the word Strategy
) (note: you can call ::strategy_names
class method to obtain available strategy names or ::stratgies
to obtain available strategy classes)rails g migration add_strategy_name_to_resources strategy_name:string
and set strategy via strategy_name
column, storing in database. On load of the model, the right strategy is automatically loaded based on strategy_name
column.These methods can be delcared in a strategic model class body.
::default_strategy(strategy_name)
: sets default strategy as a strategy name string (e.g. 'us' selects UsStrategy) or alternatively a class/object if you have a mirror hierarchy for the strategy hierarchy::default_strategy
: returns default strategy (default: 'default'
as in DefaultStrategy
)::strategy_matcher
: custom matcher for all strategies (e.g. strategy_matcher {|string| string.start_with?('C') && string.end_with?('o')}
)::strategy_names
: returns list of strategy names (strings) discovered by convention (nested under a namespace matching the superclass name)::strategies
: returns list of strategies discovered by convention (nested under a namespace matching the superclass name)::new_with_strategy(string_or_class_or_object, *args, &block)
: instantiates a strategy based on a string/class/object and strategy constructor args::new_with_default_strategy(*args, &block)
: instantiates with default strategy::strategy_class_for(string_or_class_or_object)
: selects a strategy class based on a string (e.g. 'us' selects UsStrategy) or alternatively a class/object if you have a mirror hierarchy for the strategy hierarchy#strategy=
: sets strategy#strategy
: returns current strategy::strategy_matcher
: custom matcher for a specific strategy (e.g. strategy_matcher {|string| string.start_with?('C') && string.end_with?('o')}
)::strategy_exclusion
: exclusion from custom matcher (e.g. strategy_exclusion 'Cio'
)::strategy_alias
: alias for strategy in addition to strategy's name derived from class name by convention (e.g. strategy_alias 'USA'
for UsStrategy
)::strategy_name
: returns parsed strategy name of current strategy class#context
: returns strategy context (the strategic model instance)class TaxCalculator
default_strategy 'us'
# fuzz matcher
strategy_matcher do |string_or_class_or_object|
class_name = self.name # current strategy class name being tested for matching
strategy_name = class_name.split('::').last.sub(/Strategy$/, '').gsub(/([A-Z])/) {|letter| "_#{letter.downcase}"}[1..-1]
strategy_name_length = strategy_name.length
possible_keywords = strategy_name_length.times.map {|n| strategy_name.chars.combination(strategy_name_length - n).to_a}.reduce(:+).map(&:join)
possible_keywords.include?(string_or_class_or_object)
end
# ... more code follows
end
class TaxCalculator::UsStrategy
include Strategic::Strategy
strategy_alias 'USA'
strategy_exclusion 'U'
# ... strategy methods follow
end
class TaxCalculator::CanadaStrategy
include Strategic::Strategy
# ... strategy methods follow
end
gem install bundler && bundle && rake
and make sure RSpec tests are passingCopyright (c) 2020-2021 Andy Maleh.