Service Objects, Strategies and Rails
A common pattern you will discover in Ruby on Rails codebases is the use of so-called "service objects". The goal of service objects varies from codebase to codebase, but, collectively they aim to DRY up controller and model classes through encapsulation of business logic that creates testable interfaces to perform modifications on data in the application.
Service objects borrow heavily from the concept of the strategy pattern. While not "pure" to the point of fitting into an exact UML diagram definition (this is Ruby after all), the pattern communicates their purpose "well enough". Service objects tend to vary in implementation and there is often a great deal of "roll your own" interface definitions when comparing Rails applications. Rather than re-invent the wheel, we can use a helpful framework for Ruby called
Enter the Mutation
mutations library provides a simple yet powerful toolkit to construct Service Objects (or "mutations") . The setup is simple enough, add the
mutations gem (
'~> 0.9.1') to your
Gemfile and run
bundle install to grab it from RubyGems.
Every mutation object inherits from the
Mutations::Command parent class. This class provides a small, yet, elegant DSL for child classes to declare required and optional input arguments for the mutation.
class CreateInventoryOrder < Mutations::Command required do ❶ integer :routing_number ❷ model :product, class: Product ❸ end # Example of optional input: # optional do # integer :input_name # end end
required method accepts a block ❶ with arguments that are described using data type "filters". The library's filter methods include filters like
model, etc that assert certain properties about the argument via duck typing in order to roughly validate type.
In our above mutation example, the
required block specifies that a keyword argument
routing_number ❷ must be provided and should be an
integer, while a second keyword argument
product must be provided and should be a model. If any required input is left out from the list of arguments or the wrong type of argument is given, the mutation will raise an error. To run a mutation the class method
run!) is invoked on the mutation class:
outcome = CreateInventoryOrder.run outcome.errors.message_list # => [Routing Number is required, Product is required] outcome = CreateInventoryOrder.run(routing_number: 'not_an_int') outcome.errors.message_list # => [Routing Number isn't an integer]
This validation step is handy for ensuring the correct argument is provided to the mutation. Once arguments have been defined for the mutation, child classes can override the
execute methods to include desired validation and business logic, respectively . Let's take a look at an example below:
# Mutation class CreateInventoryOrder < Mutations::Command required do integer :routing_number model :product, class: Product end # Only if the inputs validate will the execute method run def validate ❹ add_error(:product, :invalid, "Inventory is remaining for product") unless available_product.out_of_stock? end private def available_product @available_product ||= Product.find(product.id) end end
When a mutation is executed using the class method
run it begins execution by calling the
validate instance method ❹. The
validate method is a hook for the implementation to evaluate the state of provided inputs to determine whether or not to proceed with execution. The hook exposes an
add_error method, similar to ActiveRecord, that is used to append error messages to the mutation "outcome". If the validation hook fails the mutation execution will halt before proceeding further.
# running the mutation outcome = CreateInventoryOrder.run(routing_number: 1, product: Product.first) outcome.errors.message_list ❺ # => ['Inventory is remaining for product']
The error messages created in the
validate hook are accessible through the
errors method of the return outcome value ❺. If no errors are added in the validate method, the mutation proceeds to call the
class CreateInventoryOrder < Mutations::Command required do integer :routing_number model :product, class: Product end # Only if the inputs validate will the execute method run def validate add_error(:product, :invalid, "Inventory is remaining for product") unless available_product.out_of_stock? end def execute ❻ pdf_order = PDF.new(routing_number, product) payable = AccountsPayable.new(pdf_order) SubscribersWebhookNotifier.perform_async InventorOrder.new(pdf_order, payable) end private def available_product @available_product ||= Product.find(product.id) end end
execute ❻ method contains the major business logic and data manipulation for a mutation. If you are still riding aboard the GoF pattern train, you can view this as the concrete implementation of the "strategy".
# running the mutation outcome = CreateInventoryOrder.run(routing_number: 1, product: Product.second) outcome.success? # => true outcome.result ❼ # => <InventoryOrder ..>
When a mutation completes the returned value of
execute is available in the outcome's
result ❼ . The outcome has a
success? method to determine if the mutation ran as expected or not which is handy.
As an application grows these mutation objects can create a clean separation of the parts of an application that know how to "take requests" (i.e. controllers) versus the parts that know how to validate and execute those requests (i.e. mutations and service objects).
# app/controllers/inventory_orders_controller.rb class InventoryOrdersController < ApplicationController def create outcome = CreateInventoryOrder.run(create_params) @inventory_order = outcome.result if outcome.success? if @inventory_order.save redirect_to inventory_order_path(@inventory_order) else render :new end else error_messages = outcome.errors.message_list.join(", ") render :new, alert: error_messages end end private def create_params params.require(:inventory_order).permit(:product_id, :routing_number) end end
Now that's a slim controller!