Writing Boilerplate for Services in Ruby on Rails: Handling Parameters, Responses, and More

serving boilerplate for services
serving the boilerplate for services

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.

Understanding the Basics of Services

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.

Benefits of Using Services

Setting Up the Service Boilerplate

Start by creating a new service class in the app/services directory. Follow naming conventions that clearly describe the service’s purpose.

Parameter Definition and Validation Concern

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

Response Handling Concern

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

ServiceBase Superclass

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

Example Service Using Simplified ServiceBase

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

Refactoring the Controller to Use UserCreationService

# 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

Additional Example Service with Type Checking and Defaults

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

Refactoring the Controller to Use OrderProcessingService

# 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

Conclusion

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.

FAQs

  1. Why use a superclass for services? A superclass provides a common structure and reusable methods, promoting consistency and reducing code duplication.

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

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

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

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