Always with the aim of reducing the number of code execution paths, it is essential that I show you the implementations we use at Wecasa of the Chain of Responsibility (CoR) pattern.
In this article, I would like to share with you three implementations of the Chain of Responsibility pattern and explain why you should also use them.
What is a CoR?
The purpose of the Chain of Responsibility is to transform a series of method calls in your class into a series of calls to independent objects. We move from a god class with many lines and conditionals to a series of objects with unique responsibilities.
Easy, right?
Let's look at the simplest version of the three implementations we will explore today to clarify this explanation.
1. One-dimensional CoR
Imagine you have a magazine selling website. You need to go through a series of validations before effectively processing the order placed by your user.
For this, you have a class that contains all the business logic as well as the pre-validation logic.
class OrderProcessorService
def call(order:)
if order.product.stock == 0
cancel_order(reason: :out_of_stock)
elsif order.user.balance < order.total
cancel_order(:insufficient_balance)
elsif order.address.nil?
cancel_order(:missing_address)
elsif order.total_price <= 0
cancel_order(:invalid_price)
elsif order.product.is_a?(Subscription) && order.user.subscription.present?
cancel_order(:already_subscribed)
else
process_order
end
end
private
def cancel_order(reason); end
def process_order; end
end
The first reflex to hide the misery would be to put all the validation logic in an OrderProcessorValidator
object. This could work for a while. But as your project evolves, you will always add new conditions.
Moreover, with each request for evolution of this class, you will have to rewrite its body, update the tests of your classes, etc.
We are here to see chains of responsibility, so let's see how to use it:
class OrderProcessorService
VALIDATOR_CHAINS = [
ProductInStockValidator,
UserBalanceValidator,
AssertUserAddressValidator,
OrderPositivePriceValidator,
SubscriptionValidator
].freeze
def call(order:)
VALIDATOR_CHAINS.each do |validator|
result = validator.call(order: order)
return cancel_order(result.failure) if result.failure?
end
process_order
end
[...]
end
# If the result of the condition is false, it returns Failure(:out_of_stock)
# If the result of the condition is true, it returns Success(true)
class ProductInStockValidator
def call(order:)
Some(order.product.stock == 0).filter.to_result(:out_of_stock)
end
end
[...]
If the monadic syntax is unfamiliar to you, I invite you to read my article on the subject.
As you can see in the example above, we have transformed all our conditional calls into objects with a single responsibility.
Now that we have seen the first implementation of the chain of responsibility, let's explore the benefits of this pattern.
Why you should use CoR in your codebase
Here are the 4 reasons that lead me to use chains of responsibility:
- Better maintainability: Each class is responsible for a specific task. This makes modifications and updates much simpler.
- Easy extensibility: To add a new step, you simply create a new class and add it to the chain. We adhere to the open-closed principle.
- Better code readability: Instead of a long list of conditions in a method, each condition becomes a distinct class. Each class conveys a simple idea in its name.
- More efficient unit tests: You can test each class independently, focusing on its specific behavior. At the calling class level, you can stub the chain of responsibility to perform only one acceptance test.
With all these positive points in mind, let's see why you should not use the chain of responsibility in your code.
Why you should not use CoR in your codebase
- Few conditions in the class: If you have few conditions in your class, this is unnecessary. It is essential to find the right balance between readability, simplicity, and maintainability.
- Performance cost: Depending on how you architect your chain of responsibility, there may be performance issues. For example, if you make complex database calls on several links, when you could make a single large call.
Feel free to leave a comment if you see other reasons to use or, conversely, not to use it!
Now that you understand all the challenges of the chain of responsibility, let's explore the two variants I would like to present to you.
2. Two-dimensional CoR
The idea of the two-dimensional chain of responsibility is to associate an attribute of an object with a chain of responsibility.
Take an example:
In your User
table, you have an attribute closed_reason
that stores the reason for closing the account. closed_reason
is an enumeration; you already know in advance all possible values → %w[fraud outboarding_not_finished not_interested asked_by_user]
Today the product wants a feature to automatically reopen user accounts. For each closed_reason
, there is an associated reopening logic. We want to be able to reject the automatic reopening of the account if all the conditions are not met.
Let's see how to address this issue with the two-dimensional chain of responsibility!
class ReopenUserService
CLOSED_REASON_COMMANDS = {
"fraud" => WontReopenService,
"outboarding_not_finished" => ReopenOnboardingNotFinishedService,
"not_interested" => ReopenOnboardingNotFinishedService,
"asked_by_user" => ReopenOnboardedService
}
def call(user:)
CLOSED_REASON_COMMANDS[user.closed_reason].new.call(user: user)
end
end
class WontReopenService
def call(**)
Failure(:wont_reopen_user)
end
end
class ReopenOnboardingNotFinishedService
OTHER_COMMANDS = [
OutOfAreaCommand,
ResetOnboardingCommand,
]
def call(user:)
OTHER_COMMANDS.each do |command|
result = command.new.call(user: user)
return result if result.failure?
end
user.update!(closed_reason: nil, status: "onboarding")
Success()
end
end
class ReopenOnboardeService
OTHER_COMMANDS = [
OutOfAreaCommand,
]
def call(user:)
OTHER_COMMANDS.each do |command|
result = command.new.call(user: user)
return result if result.failure?
end
user.update!(closed_reason: nil, status: "active")
Success()
end
end
class OutOfAreaCommand
def call(user:)
Some(user.out_of_area?).filter.to_result(:out_of_area)
end
end
class ResetOnboardingCommand
def call(user:)
ResetUserOnboardingService.call(user: user)
Success()
end
end
Explanations:
- Our parent class
ReopenUserService
has only one responsibility: to act as a bridge between the input and the command associated with theclosed_reason
of myUser
. -
WontReopenService
is the class used when we know in advance that we do not want to reopen the user's account. -
ReopenOnboardingNotFinishedService
allows us to check if the user is still in an active area and to reset their onboarding before reopening. -
ReopenOnboardedService
allows us to check if the user is still in an active area before reopening. -
ReopenOnboardedService
andReopenOnboardingNotFinishedService
are responsible for initiating their chain of responsibility. -
OutOfAreaCommand
is aCommand
that serves as a link usable by all chains of responsibility and does only one thing → check if the user is in an active area.
The strength of this architecture is its reusability. We have a list of chains of responsibility. Each one is composed of links that can be used by other chains.
We can therefore isolate responsibilities even better, as in our case for the final update
once all the commands have passed.
I tried to take the smallest possible example but still highlight the benefits of this architecture. To realize the impact that this kind of architecture can have, ask yourself these questions:
- How do you add new
closed_reasons
? - How do you add a new check on a single
closed_reason
? - How do you add a new check on several
closed_reasons
?
This architecture addresses many of these issues.
3. Winning Strategy CoR
The last implementation we will see today addresses another scenario.
The goal is to find the right Service to execute. For this, we will go through a series of objects that will test if we are "eligible" to be processed by it. Once a Service returns a positive message, we stop the chain.
Our example:
We want to proceed with a payment. We do not yet know which source to debit. For this, we will test the eligibility of all our payment methods to find the right one to proceed with the payment.
Here is an implementation of a Winning Strategy CoR to solve this problem :
class ResolvePayment
RESOLVER_CHAIN = [
PaypalResolver,
StripeResolver,
CreditCardResolver,
BankTransferResolver
]
def call(payment:)
RESOLVER_CHAIN.each do |resolver|
resolver = resolver.new(payment: payment)
return Success(resolver.call) if resolver.actionable?
end
Failure()
end
end
class PaypalResolver
def initialize(payment:)
@payment = payment
end
def call
# process payment
end
def actionable?
@payment.paypal_link.present?
end
end
class StripeResolver
def initialize(payment:)
@payment = payment
end
def call
# process payment
end
def actionable?
@payment.stripe_link.present?
end
end
[...]
In this example, each resolver (such as PaypalResolver
or StripeResolver
) checks if the payment method is eligible for its specific treatment. If so, the payment is made successfully, and the chain of responsibility stops. If no payment method is eligible, we return a Failure.
This architecture is a variant of the one-dimensional chain of responsibility but allows a different approach in its design and use.
Conclusion
In this article, we delved into the world of chains of responsibility, exploring three distinct implementations to solve various problems.
By understanding these advantages and considerations, you will be better equipped to decide when to use or avoid chains of responsibility in your own code.
Feel free to share your experiences and perspectives in the comments!
Top comments (0)