Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save israelb/a62ef1360022d791173a94d6e5274885 to your computer and use it in GitHub Desktop.
Save israelb/a62ef1360022d791173a94d6e5274885 to your computer and use it in GitHub Desktop.

Interactor Gem (Service object)

It is based on the Command pattern, where each Command class/object represents a task and has one public method. Basically, something like:

PressButton.new(button).execute # or
PurchaseOrder.new(params).call # or even
Song::Create.(params) # or a proc
SendEmail.perform(email) # it could be a class method, why not?

Improving Interactor Gem

In order to get more control, we encourage to follow the next guidelines

Definition of a BaseInteractor

class BaseInteractor
  include Interactor
  include ActiveModel::Validations
  
  # For any class that inherits this class, a `before` hook is registered, which raises ArgumentError if the reqired parameters are not passed in during invocation.
  def self.inherited(subclass)
    subclass.class_eval do
      def self.requires(*attributes)
        validates_each attributes do |record, attr_name, value| #from ActiveModel::Validations
            if value.nil?
              raise ArgumentError.new("Required attribute #{attr_name} is missing")
            end
          end
        delegate *attributes, to: :context
      end

      before do
        context.fail!(errors: errors) unless valid? # runs every validation
      end

      def read_attribute_for_validation(method_name)
        context.public_send(method_name)
      end
    end
  end
end

Examples:

Definition of the Interactor using the BaseInteractor

class MyInteractor < BaseInteractor
  requires :email, :name
  validate :email_is_internal
  
  def call
    # ...
  end
  
  private
  
  def email_is_internal
    return unless email.present?
    errors.add(:email, 'domain should be yotepresto.com') unless context.email.match(/^[a-z]+.\@yoteprseto\.com$/i)
  end
end

Invocation of the Interactor

result = MyInteractor.call(email: 'blah@example.com')
# => ArgumentError: Required parameter name is missing.
result = MyInteractor.call(email: 'blah@example.com', name: 'Bob')
result.success? # => false
puts result.errors # => ['email domain should be yotepresto.com']

Conventions:

  1. Context Object:

    Context is convenient when you have multiple Interactors called one after the other using the organize method.

    So, to make this more predictable, It is recommended only attaching values to the context at the end of the call method or within the if something.save... block, as you can see below:

class CreateResponse < BaseInteractor
  requires :responder, :answers, :survey
  
  def call
    survey_response = responder.survey_responses.build(
      response_text: answers[:text],
      rating: answers[:rating]
      survey: survey
    )
    
    if survey_response.save
      context.survey_response = survey_response
    else
      context.fail!(errors: survey_response.errors)
    end
  end
end
  1. Single Responsibility Principle (SRP)

    Only one responsability per Class

  2. Nesting modules to name space common services together

  3. Dependency injection to better isolate service logic in unit tests

  4. Contain logic that would otherwise end up in a controller or model

  5. Have one concern and generally represent a single chunk of business logic

  6. Are available throughout the project code and are not restricted to workflows within a single controller or model

  7. Are an alternative to potentially complex callbacks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment