Chain of Responsibility Pattern - Ruby
- 20 November 2017
- Patterns Ruby
- Chain of Responsibility Pattern - Ruby
In this article, I'll cover Chain of Responsibility pattern. We will learn how to implement it using Ruby and discover when this pattern is applicable in Ruby apps.
The intent of Chain of Responsibility pattern is to decouple the sender of a request from its receiver by giving more than one object a chance to handle the request.
Don't worry If this definition is too formal, we will consider couple examples that will help you to understand this idea.
Example #1
Let's say we're working on the application that accepts money from customers. Depending on amount and currency we want to use different payment providers to process that money.
To define which payment provider to use for each specific transaction we should run some conditional logic. The code might look like this:
if ...some logic for transaction
use payment provider 1
elsif ...logic
use payment provider 2
elsif ...logic
use payment provider 3
end
If logic is complex, such code can be very cumbersome and hard to refactor.
Chain of Responsibility allows us to build a chain of handlers. Each handler will contain logic to define if the handler can process a transaction.
A transaction will go through that chain until one of the handlers will process it. We can visualize it like this:
Each handler should contain logic that can decide if this handler is applicable to the transaction, if not it will run the next handler in the chain.
Having this chain, Handler #1 will try to process transaction first. If it can not process that transaction, it will run Handler #2. If Handler #2 can not process a transaction, it will run Handler #3.
Benefits of this approach:
- we can define an order of handlers
- each handler contains own conditional logic
- it's easy to add new handlers
- we can go from specific handlers to general ones
Let's implement Chain of Responsibility for this example.
First of all, we should create a simple class for a transaction:
class Transaction
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
end
Next step would be identifying the interface of our handlers. It would be good if our handlers respond to can_handle?
and handle
methods.
If it can not handle a transaction, it should call the next handler. We will call next handler in a chain successor
. I decided to extract this logic into base handler class. Each handler will be inherited from it:
class BaseHandler
attr_reader :successor
def initialize(successor = nil)
@successor = successor
end
def call(transaction)
return successor.call(transaction) unless can_handle?(transaction)
handle(transaction)
end
def handle(_transaction)
raise NotImplementedError, 'Each handler should respond to handle and can_handle? methods'
end
end
I know it's a quite big chunk of code. But let's go line by line to understand how it works.
def initialize(successor = nil)
@successor = successor
end
We accept successor
during initialization, so we can create a chain. For example, it could be used like this:
chain = StripeHandler.new(BraintreeHandler.new)
chain.call(transaction)
Since we call call(transaction)
method, it's implemented this way:
def call(transaction)
return successor.call(transaction) unless can_handle?(transaction)
handle(transaction)
end
When we call call(transaction)
on the first handler, we check if it can handle this transaction, if it can not, we call successor.call(transaction)
and pass flow to the next handler in the chain.
Now we know that each handler should be inherited from BaseHandler
and respond to can_handle?
and handle
messages. Let's implement couple handlers.
class StripeHandler < BaseHandler
private
def handle(transaction)
puts "handling the transaction with Stripe payment provider"
end
def can_handle?(transaction)
transaction.amount < 100 && transaction.currency == 'USD'
end
end
class BraintreeHandler < BaseHandler
private
def handle(transaction)
puts "handling the transaction with Braintree payment provider"
end
def can_handle?(transaction)
transaction.amount >= 100
end
end
transaction = Transaction.new(100, 'USD')
chain = StripeHandler.new(BraintreeHandler.new)
chain.call(transaction)
# => handling transaction with Braintree payment provider
We created two handlers. The logic that decides if handler should process this transaction lives in can_handle?
method. Payment processing lives in handle
method.
In the example above, we created an object of StripeHandler
with an object of class BraintreeHandler
as a successor (the next handler in the list).
Then we called call
. There is no implementation of call
in StripeHandler
, so it went to BaseHandler
and this code was executed:
def call(transaction)
return successor.call(transaction) unless can_handle?(transaction)
handle(transaction)
end
When we run can_handle?(transaction)
on the object of class StripeHandler
it responded with false
because the amount of transaction is more than 99.
So successor.call(transaction)
was executed and in our case successor is an object of BraintreeHandler
class. It can handle this transaction, so handle(transaction)
method was executed.
Example #2
Let's consider another example that should help us to understand the idea of Chain of Responsibility pattern.
We have an online store and we need to calculate a personal discount for a customer, depending on many many factors. For example: Holidays, customer's loyalty, number of previous orders, etc.
It's a great opportunity to create a chain of handlers that would calculate the final discount for the customer. Not all discounts are applicable, for example, Black Friday discount will be available only one day a year, discount for loyal customers will be available after five purchases, etc.
Let's implement it. For a customer, I'll create a really simple class just to show the idea:
class Customer
attr_reader :number_of_orders
def initialize(number_of_orders)
@number_of_orders = number_of_orders
end
end
To keep things simple, we will track just a number of orders for a customer.
Also, as in the previous example we can create BaseDiscount
class and inherit other discounts from it:
class BaseDiscount
attr_reader :successor
def initialize(successor = nil)
@successor = successor
end
def call(customer)
return successor.call(customer) unless applicable?(customer)
discount
end
end
Then we can add as many discounts as we need:
class BlackFridayDiscount < BaseDiscount
private
def discount
0.3
end
def applicable?(customer)
# ... calculate if it's a black Friday today
end
end
class LoyalCustomerDiscount < BaseDiscount
private
def discount
0.1
end
def applicable?(customer)
customer.number_of_orders > 5
end
end
class DefaultDiscount < BaseDiscount
private
def discount
0.05
end
def applicable?(customer)
true
end
end
Now we can use them from really specific one to the more general:
chain = BlackFridayDiscount.new(LoyalCustomerDiscount.new(DefaultDiscount.new))
Because Black Friday is the biggest discount customer can get, we will start from it. Then we will try to apply a discount for a loyal customer, and if previous two weren't applied, we will use default discount. Chain of Responsibility should be implemented this way: from specific cases to general ones.
Let's say that business wants to remove discounts for Black Fridays. No problem, all we need to do just remove it from the chain:
chain = LoyalCustomerDiscount.new(DefaultDiscount.new)
Now we have a chain of two. That was easy, right?
This pattern is a good fit for a system where an application should provide an answer to customer's question. You can create a chain of answers from specific to general ones. When question goes on that chain, the system will find the most applicable answer. It can be a specific answer to a specific question, or just general answer if there is no better answer.
Thanks for reading. I hope you'll find how to apply this pattern to your app and it will help to improve your code.
PS: I visited RubyConf in New Orleans and had a chance to say thanks talk to many great people there. Just wanted to admit here that Ruby community is so nice and I'm really thankful to all people who keep working on Ruby language, gems, tools, etc. As Matz said to Ruby developers: "be nice" :)