Extract a service object using SimpleDelegator

… and check why 5600+ Rails engineers read also this

Extract a service object using SimpleDelegator

It’s now more than 1 year since I released the first beta release (the final release was ready in December 2014) of the “Fearless Refactoring: Rails controllers” book. Many of the readers were pointing to the one technique which was especially useful to them - extracting a service object using SimpleDelegator.

This technique has also been very popular in our Arkency team. It gives you a nice way of extracting a service object immediately, within minutes. It is based on a bit of a hack, though. The idea is to treat this hack as a temporary solution to make the transition to the service object more easy.

Before going into the chapter, let me quickly explain the structure of such a Rails Refactoring recipe. We start with a short introduction to the problem. Then we list the prerequisites - things that are needed to be done before this refactoring. After that we present a short algorithm and we jump into the examples. At the end we explain the benefits and we list the resources.

OK, now it’s time for the killer technique:

Extract a service object using the SimpleDelegator

New projects have a tendency to keep adding things into controllers. There are things which don’t quite fit any model and developers still haven’t figured out the domain exactly. So these features land in controllers. In later phases of the project we usually have better insight into the domain. We would like to restructure domain logic and business objects. But the unclean state of controllers, burdened with too many responsibilities is stopping us from doing it.

To start working on our models we need to first untangle them from the surrounding mess. This technique helps you extract objects decoupled from HTTP aspect of your application. Let controllers handle that part. And let service objects do the rest. This will move us one step closer to better separation of responsibilities and will make other refactorings easier later.

Prerequisites

Public methods

As of Ruby 2.0, Delegator does not delegate protected methods any more. You might need to temporarly change access levels of some your controller methods for this technique to work. Once you finish all steps, you should be able to bring the acess level back to old value. Such change can be done in two ways.

  • by moving the method definition into public scope.

Change


class A
  def method_is_public
  end

  protected

  def method_is_protected
  end
end

into

class A
  def method_is_public
  end

  def method_is_protected
  end

  protected

end
  • by overwriting method access level after its definition

Change

class A
  def method_is_public
  end

  protected

  def method_is_protected
  end
end

into

class A
  def method_is_public
  end

  protected

  def method_is_protected
  end

  public :method_is_protected
end

I would recommend using the second way. It is simpler to add and simpler to remove later. The second way is possible because #public is not a language syntax feature but just a normal method call executed on current class.

Inlined filters

Although not strictly necessary for this technique to work, it is however recommended to inline filters. It might be that those filters contain logic that should be actually moved into the service objects. It will be easier for you to spot it after doing so.

Algorithm

  1. Move the action definition into new class and inherit from SimpleDelegator.
  2. Step by step bring back controller responsibilities into the controller.
  3. Remove inheriting from SimpleDelegator.
  4. (Optional) Use exceptions for control flow in unhappy paths.

Example

This example will be a much simplified version of a controller responsible for receiving payment gateway callbacks. Such HTTP callback request is received by our app from gateway’s backend and its result is presented to the user’s browser. I’ve seen many controllers out there responsible for doing something more or less similar. Because it is such an important action (from business point of view) it usually quickly starts to accumulate more and more responsibilities.

Let’s say our customer would like to see even more features added here, but before proceeding we decided to refactor first. I can see that Active Record models would deserve some touch here as well, let’s only focus on controller right now.

class PaymentGatewayController < ApplicationController
  ALLOWED_IPS = ["127.0.0.1"]
  before_filter :whitelist_ip

  def callback
    order = Order.find(params[:order_id])
    transaction = order.order_transactions.create(callback: params.slice(:status, :error_message, :merchant_error_message, :shop_orderid, :transaction_id, :type, :payment_status, :masked_credit_card, :nature, :require_capture, :amount, :currency))
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      redirect_to successful_order_path(order.id)
    else
      redirect_to retry_order_path(order.id)
    end
  rescue ActiveRecord::RecordNotFound => e
    redirect_to missing_order_path(params[:order_id])
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    redirect_to failed_order_path(order.id), alert: t("order.problems")
  end

  private

  def whitelist_ip
    raise UnauthorizedIpAccess unless ALLOWED_IPS.include?(request.remote_ip)
  end
end

About filters

In this example I decided not to move the verification done by the whitelist_ip before filter into the service object. This IP address check of issuer’s request actually fits into controller responsibilities quite well.

Move the action definition into new class and inherit from SimpleDelegator

For start you can even keep the class inside the controller.

class PaymentGatewayController < ApplicationController
  # New service inheriting from SimpleDelegator
  class ServiceObject < SimpleDelegator
    # copy-pasted method
    def callback
      order = Order.find(params[:order_id])
      transaction = order.order_transactions.create(callback: params.slice(:status, :error_message, :merchant_error_message, :shop_orderid, :transaction_id, :type, :payment_status, :masked_credit_card, :nature, :require_capture, :amount, :currency))
      if transaction.successful?
        order.paid!
        OrderMailer.order_paid(order.id).deliver
        redirect_to successful_order_path(order.id)
      else
        redirect_to retry_order_path(order.id)
      end
    rescue ActiveRecord::RecordNotFound => e
      redirect_to missing_order_path(params[:order_id])
    rescue => e
      Honeybadger.notify(e)
      AdminOrderMailer.order_problem(order.id).deliver
      redirect_to failed_order_path(order.id), alert: t("order.problems")
    end
  end

  ALLOWED_IPS = ["127.0.0.1"]
  before_filter :whitelist_ip

  def callback
    # Create the instance and call the method
    ServiceObject.new(self).callback
  end

  private

  def whitelist_ip
    raise UnauthorizedIpAccess unless ALLOWED_IPS.include?(request.remote_ip)
  end
end

We created new class ServiceObject which inherits from SimpleDelegator. That means that every method which is not defined will delegate to an object. When creating an instance of SimpleDelegator the first argument is the object that methods will be delegated to.

def callback
  ServiceObject.new(self).callback
end

We provide self as this first method argument, which is the controller instance that is currently processing the request. That way all the methods which are not defined in ServiceObject class such as redirect_to, respond, failed_order_path, params, etc are called on controller instance. Which is good because our controller has these methods defined.

Step by step bring back controller responsibilities into the controller

First, we are going to extract the redirect_to that is part of last rescue clause.

rescue => e
  Honeybadger.notify(e)
  AdminOrderMailer.order_problem(order.id).deliver
  redirect_to failed_order_path(order.id), alert: t("order.problems")
end

To do that we could re-raise the exception and catch it in controller. But in our case it is not that easy because we need access to order.id to do proper redirect. There are few ways we can workaround such obstacle:

  • use params[:order_id] instead of order.id in controller (simplest way)
  • expose order or order.id from service object to controller
  • expose order or order.id in new exception

Here, we are going to use the first, simplest way. The third way will be shown as well later in this chapter.

class ServiceObject < SimpleDelegator
  def callback
    order = Order.find(params[:order_id])
    transaction = order.order_transactions.create(callback: params.slice(:status, :error_message, :merchant_error_message, :shop_orderid, :transaction_id, :type, :payment_status, :masked_credit_card, :nature, :require_capture, :amount, :currency))
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      redirect_to successful_order_path(order.id)
    else
      redirect_to retry_order_path(order.id)
    end
  rescue ActiveRecord::RecordNotFound => e
    redirect_to missing_order_path(params[:order_id])
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise # re-raise instead of redirect
  end
end

def callback
  ServiceObject.new(self).callback
rescue # we added this clause here
  redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
end

Next, we are going to do very similar thing with the redirect_to from ActiveRecord::RecordNotFound exception.

class ServiceObject < SimpleDelegator
  def callback
    order = Order.find(params[:order_id])
    transaction = order.order_transactions.create(callback: params.slice(:status, :error_message, :merchant_error_message, :shop_orderid, :transaction_id, :type, :payment_status, :masked_credit_card, :nature, :require_capture, :amount, :currency))
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      redirect_to successful_order_path(order.id)
    else
      redirect_to retry_order_path(order.id)
    end
  rescue ActiveRecord::RecordNotFound => e
    raise # Simply re-raise
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise
  end
end

def callback
  ServiceObject.new(self).callback
rescue ActiveRecord::RecordNotFound => e # One more rescue clause
  redirect_to missing_order_path(params[:order_id])
rescue
  redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
end

We are left with two redirect_to statements. To eliminte them we need to return the status of the operation to the controller. For now, we will just use Boolean for that. We will also need to again use params[:order_id] instead of order.id.

class ServiceObject < SimpleDelegator
  def callback
    order = Order.find(params[:order_id])
    transaction = order.order_transactions.create(callback: params.slice(:status, :error_message, :merchant_error_message, :shop_orderid, :transaction_id, :type, :payment_status, :masked_credit_card, :nature, :require_capture, :amount, :currency))
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      return true # returning status
    else
      return false # returning status
    end
  rescue ActiveRecord::RecordNotFound => e
    raise
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise
  end
end

def callback
  if ServiceObject.new(self).callback
    # redirect moved here
    redirect_to successful_order_path(params[:order_id])
  else
    # and here
    redirect_to retry_order_path(params[:order_id])
  end
rescue ActiveRecord::RecordNotFound => e
  redirect_to missing_order_path(params[:order_id])
rescue
  redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
end

Now we need to take care of params method. Starting with params[:order_id]. This change is really small.

class ServiceObject < SimpleDelegator
  # We introduce new order_id method argument
  def callback(order_id)
    order = Order.find(order_id)
    transaction = order.order_transactions.create(callback: params.slice(:status, :error_message, :merchant_error_message, :shop_orderid, :transaction_id, :type, :payment_status, :masked_credit_card, :nature, :require_capture, :amount, :currency))
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      return true
    else
      return false
    end
  rescue ActiveRecord::RecordNotFound => e
    raise
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise
  end
end

def callback
  # Provide the argument for method call
  if ServiceObject.new(self).callback(params[:order_id])
    redirect_to successful_order_path(params[:order_id])
  else
    redirect_to retry_order_path(params[:order_id])
  end
rescue ActiveRecord::RecordNotFound => e
  redirect_to missing_order_path(params[:order_id])
rescue
  redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
end

The rest of params is going to be be provided as second method argument.

class ServiceObject < SimpleDelegator
  # One more argument
  def callback(order_id, gateway_transaction_attributes)
    order = Order.find(order_id)
    transaction = order.order_transactions.create(
      # that we use here
      callback: gateway_transaction_attributes
    )
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      return true
    else
      return false
    end
  rescue ActiveRecord::RecordNotFound => e
    raise
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise
  end
end

def callback
  # Providing second argument
  if ServiceObject.new(self).callback(
      params[:order_id],
      gateway_transaction_attributes
    )
    redirect_to successful_order_path(params[:order_id])
  else
    redirect_to retry_order_path(params[:order_id])
  end
rescue ActiveRecord::RecordNotFound => e
  redirect_to missing_order_path(params[:order_id])
rescue
  redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
end

private

# Extracted to small helper method
def gateway_transaction_attributes
  params.slice(:status, :error_message, :merchant_error_message,
    :shop_orderid, :transaction_id, :type, :payment_status,
    :masked_credit_card, :nature, :require_capture, :amount, :currency
  )
end

Remove inheriting from SimpleDelegator

When you no longer use any of the controller methods in the Service you can remove the inheritance from SimpleDelegator. You just no longer need it. It is a temporary hack that makes the transition to service object easier.

# Removed inheritance
class ServiceObject
  def callback(order_id, gateway_transaction_attributes)
    order = Order.find(order_id)
    transaction = order.order_transactions.create(callback: gateway_transaction_attributes)
    if transaction.successful?
      order.paid!
      OrderMailer.order_paid(order.id).deliver
      return true
    else
      return false
    end
  rescue ActiveRecord::RecordNotFound => e
    raise
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise
  end
end

def callback
  # ServiceObject constructor doesn't need
  # controller instance as argument anymore
  if ServiceObject.new.callback(
       params[:order_id],
       gateway_transaction_attributes
    )
    redirect_to successful_order_path(params[:order_id])
  else
    redirect_to retry_order_path(params[:order_id])
  end
rescue ActiveRecord::RecordNotFound => e
  redirect_to missing_order_path(params[:order_id])
rescue
  redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
end

This would be a good time to also give a meaningful name (such as PaymentGatewayCallbackService) to the service object and extract it to a separate file (such as app/services/payment_gateway_callback_service.rb). Remember, you don’t need to add app/services/ to Rails autoloading configuration for it to work (explanation).

(Optional) Use exceptions for control flow in unhappy paths

You can see that code must deal with exceptions in a nice way (as this is critical path in the system). But for communicating the state of transaction it is using Boolean values. We can simplify it by always using exceptions for any unhappy path.

class PaymentGatewayCallbackService
  # New custom exception
  TransactionFailed = Class.new(StandardError)

  def callback(order_id, gateway_transaction_attributes)
    order = Order.find(order_id)
    transaction = order.order_transactions.create(callback: gateway_transaction_attributes)
    # raise the exception when things went wrong
    transaction.successful? or raise TransactionFailed
    order.paid!
    OrderMailer.order_paid(order.id).deliver
  rescue ActiveRecord::RecordNotFound, TransactionFailed => e
    raise
  rescue => e
    Honeybadger.notify(e)
    AdminOrderMailer.order_problem(order.id).deliver
    raise
  end
end

class PaymentGatewayController < ApplicationController
  ALLOWED_IPS = ["127.0.0.1"]
  before_filter :whitelist_ip

  def callback
    PaymentGatewayCallbackService.new.callback(params[:order_id], gateway_transaction_attributes)
    redirect_to successful_order_path(params[:order_id])
  # Rescue and redirect
  rescue PaymentGatewayCallbackService::TransactionFailed => f
    redirect_to retry_order_path(params[:order_id])
  rescue ActiveRecord::RecordNotFound => e
    redirect_to missing_order_path(params[:order_id])
  rescue
    redirect_to failed_order_path(params[:order_id]), alert: t("order.problems")
  end

  # ...
end

“What about performance?” you might ask. After all, whenever someone mentions exceptions on the Internet, people seem to start raising the performance argument for not using them. Let me answer that way:

  • Cost of using exceptions is negligable when the exception doesn’t occur.
  • When the exception occurs its performance cost is 3-4x times lower compared to one simple SQL statement.

Hard data for those statements. Feel free to reproduce on your Ruby implementation and Rails version.

In other words, exceptions may hurt performance when used inside a “hot loop” in your program and in such case should be avoided. Service Objects usually don’t have such performance implications. If using exceptions helps you clean the code of services and controller, performance shouldn’t stop you. There are probably plenty of other opportunities to speed up your app compared to removing exceptions. So please, let’s not use such argument in situations like that.

Benefits

This is a great way to decouple flow and business logic from HTTP concerns. It makes the code cleaner and easier to reason about. If you want to keep refactoring the code you can easily focus on controller-service communication or service-model. You just introduced a nice boundary.

From now on you can also use Service Objects for setting a proper state in your tests.

Resources

About the book

What I showed you here is one chapter of the “Fearless Refactoring: Rails Controllers” book. The whole book is divided into 3 parts:

  • Recipes
  • Big code examples
  • Theory with examples

The chapter above was one of the recipes. The book is much more than that. The third part of the book is explaining in a very detailed way what are:

  • service objects
  • repositories
  • form objects
  • adapters

If you think that your Rails application can benefit from those patterns, consider buying the book.

You might also like