Rails Services and Actions

Like the "modular monolith" approach advocated by Root and Shopify, services and actions feel like missing pieces of the Rails architecture that help you keep your code simple-to-understand as your organization grows.

Services

Services are stateless, procedural pieces of business logic. A UserCreatorService is the classic example, which would encapsulate things like checking validations (which are still defined in the model), inserting the user record into the database, sending a welcome email, adding the user to an external CRM service, etc. "At scale," services might be better modeled in something like Temporal.

# app/services/user_creator_service.rb

class UserCreatorService < BaseService
  def call(user_params: {})
    user = User.new(user_params)

    if user.save
      if user.email.present?
        UserMailer.with(user: user).welcome_email.deliver_later
      end

      CrmJob.perform_later(user)
      SeedNewAccountJob.perform_later(user.account)
    end

    user
  end
end

Without services, this logic might go into a controller or worse: an after-create hook in your User model.

Services can be called from anywhere in your code base, including other services.

Actions

Actions are 1:1 with controller actions. They're a place to extract logic from your controllers that isn't dealing directly with request / response handling. Action classes are only called from their corresponding controller action methods and nowhere else.

# app/actions/customers_index_action.rb

class CustomersIndexAction < BaseAction
  def call(crm_ids: [])
    return success([]) unless crm_ids.any?

    customer_data =
      Crm
        .call(customer_ids: customer_ids)
        .map do |hash|
          # some formatting that the client needs
          hash.transform_keys { |k| k.to_s.camelize(:lower).to_sym }
        end

    success(customer)
  rescue CrmError
    failure("Could not connect to the CRM API")
  end
end

Actions let you have skinny controllers without fattening up your models with a bunch of callbacks and non-data concerns. Not all controllers need action classes (for example, it would be overkill to extract a basic CRUD action into its own class).

Actions and services are similar, and something that starts as an action might get refactored into a service later if that logic becomes useful in other contexts. It's a wide, gray line, but generally speaking: actions are for logic that is coupled to a specific request, and their return values should be easy for controllers to consume or pass along without additional work.

Base Classes

It's important that your actions and services have consistent interfaces, so consider implementing base classes for each.

# app/services/base_service.rb

class BaseService
  def self.call(args)
    new.call(**args)
  end

  def call
    raise NotImplementedError
  end
end
# app/actions/base_action.rb

class BaseAction
  def self.call(args)
    new.call(**args)
  end

  def call
    raise NotImplementedError
  end

  def success(data = nil)
    OpenStruct.new(success: true, data: data)
  end

  def failure(error_message = nil)
    OpenStruct.new(success: false, error_message: error_message)
  end
end
Dec 19, 2021 • rails (3), programming (3)