(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:
- Making domain models aware of newly added strategies without touching their code (Open/Closed Principle).
- Fetching the right strategy without the use of conditionals.
- Avoiding duplication of strategy dispatch code for multiple domain models
- Have strategies mirror an existing domain model inheritance hierarchy
strategic
solves these problems by offering:
- Strategy Pattern support through a Ruby mixin and strategy path/name convention
- Automatic discovery of strategies based on path/name convention
- Ability to fetch needed strategy without use of conditionals
- Ability to fetch a strategy by name or by object type to mirror
- Plain Ruby and Ruby on Rails support
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)
).
- Include
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 intax_calculator/us_strategy.rb
representingTaxCalculator::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)
- In client code, set the strategy by underscored string reference minus the word strategy (e.g. UsStrategy becomes simply 'us'):
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
- Invoke the strategy implemented method:
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:
- Have the original class you'd like to strategize include
Strategic
(e.g.def TaxCalculator; include Strategic; end
- Create a directory matching the class underscored file name minus the '.rb' extension (e.g.
tax_calculator/
) - Create a strategy class under that directory (e.g.
tax_calculator/us_strategy.rb
) (default is assumed astax_calculator/default_strategy.rb
unless customized withdefault_strategy
class method):
- Lives under the original class namespace
- Includes the
Strategic::Strategy
module - Has a class name that ends with
Strategy
suffix (e.g.NewCustomerStrategy
)
- Set strategy on strategic model using
strategy=
attribute writer method or instantiate withnew_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 wordStrategy
) (note: you can call::strategy_names
class method to obtain available strategy names or::stratgies
to obtain available strategy classes) - Alternatively in Rails, create migration
rails g migration add_strategy_name_to_resources strategy_name:string
and set strategy viastrategy_name
column, storing in database. On load of the model, the right strategy is automatically loaded based onstrategy_name
column. - Invoke strategy method needed
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 inDefaultStrategy
)::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'
forUsStrategy
)
::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
- Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
- Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
- Fork the project.
- Change directory into project
- Run
gem install bundler && bundle && rake
and make sure RSpec tests are passing - Start a feature/bugfix branch.
- Write RSpec tests, Code, Commit and push until you are happy with your contribution.
- Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
- Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
Copyright (c) 2020-2021 Andy Maleh.