Using Mutations in Rails

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

Enter the Mutation

The 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

The required method accepts a block ❶ with arguments that are described using data type "filters". The library's filter methods include filters like integer, string, symbol, 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 (or 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 validate and 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 execute hook.

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

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

For example:

# 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!

Have fun!