DEV Community

Cover image for 3 Chain of Responsibility implementations that will make you fall in love with this pattern
Pimp My Ruby
Pimp My Ruby

Posted on

3 Chain of Responsibility implementations that will make you fall in love with this pattern

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
Enter fullscreen mode Exit fullscreen mode

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
[...]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Better maintainability: Each class is responsible for a specific task. This makes modifications and updates much simpler.
  2. 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.
  3. 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.
  4. 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
Enter fullscreen mode Exit fullscreen mode

Explanations:

  • Our parent class ReopenUserService has only one responsibility: to act as a bridge between the input and the command associated with the closed_reason of my User.
  • 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 and ReopenOnboardingNotFinishedService are responsible for initiating their chain of responsibility.
  • OutOfAreaCommand is a Command 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
  [...]
Enter fullscreen mode Exit fullscreen mode

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)