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 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 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.
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