Following the previous article about the beginning of
the process of refactoring tangled code to a more organised codebase using services, we’ll dig in this article into some
details on how to organise your services in a mode detailed way.
In Ruby on Rails applications, service classes provide a way to encapsulate business logic and keep controllers and
models clean and maintainable. Consistently structuring your service classes can greatly enhance code readability and
reduce technical debt. This post covers how to write boilerplate code for services, including handling parameters,
managing responses, and implementing error handling.
Service classes are plain Ruby objects (POROs) that encapsulate business logic. By moving complex logic out of controllers and models, services help maintain a clear separation of concerns.
Start by creating a new service class in the app/services directory. Follow naming conventions that clearly describe
the service’s purpose.
Create a concern named ParamsValidatable:
# app/services/concerns/params_validatable.rb
module ParamsValidatable
extend ActiveSupport::Concern
included do
class << self
def param(name, type:, required: false, default: nil)
@params_def ||= {}
@params_def[name] = { type: type, required: required, default: default }
end
def params_def
@params_def || {}
end
def validate!(params)
params_def.each do |param, details|
if details[:required] && params[param].nil?
raise ArgumentError, "Missing required parameter: #{param}"
end
next unless params[param] && details[:type]
valid_types = Array(details[:type])
unless valid_types.any? { |type| params[param].is_a?(type) }
raise ArgumentError, "Invalid type for #{param}: expected #{details[:type]}"
end
end
end
end
end
end
Create a concern named ResponseHandler:
# app/services/concerns/response_handler.rb
module ResponseHandler
extend ActiveSupport::Concern
private
def success(data:)
{ status: :success, data: data }
end
def error(message:)
{ status: :error, message: message }
end
end
Include the concerns in the ServiceBase class:
# app/services/service_base.rb
class ServiceBase
include ParamsValidatable
include ResponseHandler
def self.call(params = {})
new(params).call
end
def initialize(params = {})
@params = params
apply_defaults
self.class.validate!(@params)
end
def call
raise NotImplementedError, 'Subclasses must implement the call method'
end
private
def apply_defaults
self.class.params_def.each do |param, details|
@params[param] = details[:default] if @params[param].nil? && details.key?(:default)
end
end
end
Here’s how to create a service that inherits from ServiceBase and utilizes the concise param method for defining
parameters.
# app/services/user_creation_service.rb
class UserCreationService < ServiceBase
param :name, type: String, required: true
param :email, type: String, required: true
param :role, type: String, default: 'user'
def call
user = create_user
success(user: user)
rescue StandardError => e
error(message: e.message)
end
private
def create_user
User.create!(@params)
end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
result = UserCreationService.call(user_params)
if result[:status] == :success
redirect_to result[:data][:user]
else
flash[:error] = result[:message]
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password)
end
end
Here’s another example of a service that processes orders and uses the ServiceBase for validation, type checking, and
default values.
# app/services/order_processing_service.rb
class OrderProcessingService < ServiceBase
param :order_id, type: Integer, required: true
param :payment_details, type: Hash, required: true
param :expedited, type: [TrueClass, FalseClass], default: false
def call
process_order
success(message: 'Order processed successfully')
rescue StandardError => e
error(message: e.message)
end
private
def process_order
order = Order.find(@params[:order_id])
PaymentGateway.charge(@params[:payment_details])
NotificationMailer.order_confirmation(order).deliver_now
end
end
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
result = OrderProcessingService.call(order_params)
if result[:status] == :success
redirect_to order_path(result[:data][:order])
else
flash[:error] = result[:message]
render :new
end
end
private
def order_params
params.require(:order).permit(:order_id, :payment_details)
end
end
Using a superclass like ServiceBase to handle common functionalities such as input validation (including type
checking), default values, and standardized responses can significantly streamline the development of service classes.
By separating the logic for defining parameters, validation, and response handling into concerns, we can make our code
more modular and easier to maintain.
Why use a superclass for services? A superclass provides a common structure and reusable methods, promoting consistency and reducing code duplication.
How does the ServiceBase handle parameter validation?
The ServiceBase includes the ParamsValidatable concern, which defines a class method validate! that checks for
the presence of required parameters and their types, raising an error if any are missing or of the wrong type.
What are the benefits of standardizing responses in services? Standardized responses ensure consistent communication of success and error states, making it easier to handle these states in controllers and other parts of the application.
Can I customize the validation logic in my services?
Yes, you can use the param method provided by the ParamsValidatable concern to define required parameters, their
types, and default values.
Is it possible to handle more complex validation scenarios?
For more complex validation, you can use additional validation libraries like ActiveModel::Validations within your
service classes.