Ruby - Strategy Pattern
- 09 October 2016
- Patterns Ruby Design
- Ruby - Strategy Pattern
Hi, today we will continue learning new patterns (see also: decorator and template). This time we will go through very popular pattern - Strategy.
Wikipedia describes pattern this way:
The strategy pattern (also known as the policy pattern) is a software design pattern that enables an algorithm's behavior to be selected at runtime.
The strategy pattern:
- defines a family of algorithms,
- encapsulates each algorithm, and
- makes the algorithms interchangeable within that family.
That's too general description, so I suggest to go to examples straight away.
Example #1
Let's create method which calculates net salary (salary you have after all taxes paid). Different countries have different rules for taxes, so code could look like this:
def net_salary(amount, country)
taxes = case country
when "Ukraine"
(amount * 0.05) + 313
when "U.S."
(amount * 0.2) + 100
when "Poland"
amount * 0.3
else
0
end
amount - taxes
end
net_salary(1000, "Poland") # => 700.0
net_salary(1000, "Ukraine") # => 637.0
As we see from example - each country has own rules (strategies) of calculation. I've simplified logic of calculation, but actual implementation of each rule could contain a lot of code which would make net_salary
method huge.
The main idea of Strategy pattern - to define set of objects (strategies) which solve the same problem in different way, depending on conditions. In our case they calculate amount of taxes differently, depending on selected country. All strategies should have the same interface to be interchangeable.
Let's rewrite example implementing strategy pattern:
class UkraineTaxes
def self.taxes(amount)
(amount * 0.05) + 313
end
end
class PolandTaxes
def self.taxes(amount)
amount * 0.3
end
end
class UsTaxes
def self.taxes(amount)
(amount * 0.2) + 100
end
end
class Taxes
def initialize
@strategies = {}
@strategies['Ukraine'] = UkraineTaxes
@strategies['Poland'] = PolandTaxes
@strategies['U.S.'] = UsTaxes
end
def net_salary(amount, country)
strategy = @strategies[country]
strategy ? amount - strategy.taxes(amount) : amount
end
end
Taxes.new.net_salary(1000, "U.S.") # => 700.0
For each country we've created separate class which has method .taxes
.
Method .taxes
contains all logic which calculates amount of taxes.
In Taxes#initialize
we define a list of available strategies. Method net_salary
dynamically selects proper strategy basing on country
param. It will return amount
If there is no applicable strategy.
Implementation of each strategy takes only one line of code, so we can avoid creating separate classes for that. We can use lambdas. That will make our code shorter:
class Taxes
attr_reader :amount
def initialize(amount)
@amount = amount
@strategies = {}
@strategies['Ukraine'] = -> { (amount * 0.05) + 313 }
@strategies['Poland'] = -> { amount * 0.3 }
@strategies['U.S.'] = -> { (amount * 0.2) + 100 }
end
def net_salary(country)
strategy = @strategies[country]
strategy ? amount - strategy.call : amount
end
end
Taxes.new(1200).net_salary("U.S.") # => 700.0
If you didn't use lambdas yet - don't worry. To simplify: lambdas are bits of code which we can assign to variables and execute them later (strategy.call
). Also it's good to remember that lambdas execute width scope they've been defined.
This approach allows us to decouple implementation of each strategy from place where it might be used. Polymorphism helps us a lot here as well. Each strategy supports the same interface so we can call strategy.call
even if we don't know which exact strategy will be executed.
I believe that simple examples allow us to develop skill of using patterns properly. Let's move forward and check another example.
Example #2
In this example we receive data from external API and depending on response decide how to process it.
Response is an object which has methods status
and data
.
status
can be: success
, error
or fail
. If status is success
- we can use response.data
. If returned status
is error
- we should display response.error_message
. The fail
status means that service is down and not responding at all.
def show
response = external_api.get(params[:id])
return handle_error if response.status == "error"
return handle_fail if response.status == "fail"
if response.status == "success"
"Successful response: #{response.data}"
end
end
def handle_error
puts "Error: #{response.error_message}"
end
def handle_fail
puts "Request Failed"
end
I've simplified that code, but the problem is obvious. We have response
object and should decide how to process it. In this example we do that using a lot of if..else
statements which is not good. Imagine how much code we will get if each action will have all these if's
.
It's a good place to use strategy pattern. We know that logic of processing should be extracted into classes or lambdas that have the same interface.
It's easy to decide which strategy to run because we have response.status
.
Let's implement that:
class ResponseHandler
def self.handle(response, strategies)
strategies[response.status.to_sym].call
end
end
def show
response = external_service.get(params[:id])
on_success = -> { "Successful response: #{response.data}" }
on_error = -> { "Error: #{response.error_message}" }
on_fail = -> { "Request Failed" }
ResponseHandler.handle(response, success: on_success, error: on_error, fail: on_fail)
end
We did following steps:
- Created class
ResponseHandler
which will handle responses from external API - Also,
ResponseHandler
accepts strategies which we want to run depending on status of response. - All strategies have the same interface and respond to
.call
method. - Having strategies with the same interface,
response
object andResponseHandler
class - we can easily run proper strategy:strategies[response.status.to_sym].call
I think that's a good code. ResponseHandler
can be extended by default strategies, for example for on_fail
. So we don't need to pass the same strategy for failed request every time.
If we have more complex logic - it will be easy to extract strategies into separate classes and get rid from lambdas. If classes support .call
method - everything will work.
Also you see how easy to add new strategies. Just create new strategy class or lambda for corresponding response status and ResponseHandler
will use that automatically. Which is good because it's an Open/Closed principle from SOLID.
If you see that your code contains a lot of if..else
or case..when
, check that code twice, there might be a chance that you're missing Strategy pattern there.
Cheers!