codecov GitHub tag (latest SemVer pre-release)

RungerActions

Organize and validate the business logic of your Rails application with this combined form object / command object.

Table of Contents

Installation

Add the gem to your application's Gemfile.

gem 'runger_actions'

And then execute:

$ bundle install

Usage in general

Setup

Create a new subdirectory within the app/ directory in your Rails app: app/actions/.

Create an app/actions/application_action.rb file with this content:

# app/actions/application_action.rb

class ApplicationAction < RungerActions::Base
end

Generate your actions

This gem provides a Rails generator. For example, running:

bin/rails g runger_actions:action Users::Create

will create an empty action in app/actions/users/create.rb.

Define your actions

Then, you can start defining actions. Here's an example:

# app/actions/send_text_message.rb

class SendTextMessage < ApplicationAction
  requires :message_body, String, length: { minimum: 3 } # don't send any super short messages
  requires :user, User do
    validates :phone, presence: true, format: { with: /[[:digit:]]{11}/ }
  end

  returns :cost, Float, numericality: { greater_than_or_equal_to: 0 }
  returns :nexmo_id, String, presence: true

  fails_with :nexmo_request_failed

  def execute
    nexmo_response = NexmoClient.send_text!(number: user.phone, message: message_body)
    if nexmo_response.success?
      nexmo_response_data = nexmo_response.parsed_response
      result.cost = nexmo_response_data['cost']
      result.nexmo_id = nexmo_response_data['message-id']
    else
      result.nexmo_request_failed!
    end
  end
end

Invoke your actions

Once you have defined one or more actions, you can invoke the action(s) anywhere in your code, such as in a controller, as illustrated below.

# app/controllers/api/text_messages_controller.rb

class Api::TextMessagesController < ApplicationController
  def create
    send_message_action =
      SendTextMessage.new(
        user: current_user,
        message_body: "Hello! This message was generated at #{Time.current}.",
      )

    if !send_message_action.valid?
      # We'll enter this block if one of the ActiveRecord inputs (`user`, in this case) for the
      # action doesn't meet the required validations, e.g. if the user's `phone` is blank.
      render json: { error: send_message_action.errors.full_messages.join(', ') }, status: 400
      return
    end

    result = send_message_action.run
    if result.success?
      Rails.logger.info("Sent message with Nexmo id #{result.nexmo_id} at a cost of #{result.cost}")
      head :created
    elsif result.nexmo_request_failed?
      render json: { error: 'An error occurred when sending the text message' }, status: 500
    end
  end
end

You aren't limited to invoking actions from a controller action, though; you can invoke an action from anywhere in your code.

One good place to invoke an action is from within another action. For a complex or multi-step process, you might want to break that process down into several "sub actions" that can be invoked from the #execute method of a coordinating "parent action".

Available methods

There are a few different methods that can be used to instantiate and/or run an action:

  1. ::run! class method
  2. ::new! class method
  3. ::new class method
  4. #run! instance method
  5. #run instance method

::run! class method

This will attempt to instantiate an action (via ::new!) and then attempt to run the action (via #run!). If there are any validation errors and/or if any fails_with conditions are invoked during execution, then an error will be raised.

Example:

SendTextMessage.run!(user: current_user, message_body: 'Hello!')

::new! class method

This will attempt to instantiate an action. If there are any validation errors, then an error will be raised.

Example:

action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')

::new class method

This will instantiate an action. Even if there are ActiveModel validation errors, an error will not be raised.

Example:

action = SendTextMessage.new(user: current_user, message_body: 'Hi!')

#run! instance method

This will attempt to run an action. If any fails_with conditions are invoked during execution, then an error will be raised.

Example:

action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
action.run!

#run instance method

This will run an action. If any fails_with conditions are invoked during execution, then an error will not be raised. The errors will be registered on the result object.

Example:

action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
result = action.run
result.nexmo_request_failed? # check if a `fails_with` condition was invoked

Usage in specific

An #execute instance method is required!

The only real requirement for an action is that it implements an #execute instance method.

class DoSomething < ApplicationAction
  def execute
    # you MUST write an #execute instance method for your action
  end
end

Although all actions must implement an #execute instance method, you should generally not invoke that method directly in your application code. Instead, call #run on an instance of the class:

# this will run the DoSomething#execute instance method
DoSomething.new.run

Action class methods

When defining an action class, these three class methods are available:

  1. requires
  2. returns
  3. fails_with

Those class methods are all optional, though. We'll detail/illustrate their usage below.

::requires

The ::requires class method declares the necessary, expected inputs that are needed in order to execute an action.

An action can have zero, one, or more requires statements.

An action that requires no input values will have no requires statements:

class PrintCurrentTime < ApplicationAction
  def execute
    puts("The current time is #{Time.now}.")
  end
end

PrintCurrentTime.new.run
# => prints "The current time is 2020-06-20 03:25:14 -0700."

Most actions probably will take one or more inputs, though. Here's an example of an action with one requires statement:

class PrintDoubledNumber < ApplicationAction
  requires :number, Numeric

  def execute
    puts("#{number} doubled is #{number * 2}")
  end
end

PrintDoubledNumber.new(number: 8).run
# => prints "8 doubled is 16"

In the example above, because the PrintDoubledNumber action class declares requires :number, a #number instance method is available for all instances of that action class. This #number instance method is used within the PrintDoubledNumber#execute action.

All subsequent arguments given to requires are used to define a "shape" via the shaped gem.

The simplest way to define the expected "shape" of a required action parameter is probably to declare its expected class, as illustrated above (where we specified that the number input parameter must be an instance of Numeric). However, the shaped gem supports a wide variety of ways to specify the expected "shape" of an input. A few additional examples are shown below; see the shaped documentation for more possibilities.

Specifying the expected shape of a Hash input

class PrintNameAndEmail < ApplicationAction
  # The `{ email: String, phone: String }` argument specifies the expected shape of `user_data`.
  requires :user_data, { name: String, email: String }

  def execute
    puts("The email of #{user_data[:name]} is #{user_data[:email]}.")
  end
end

PrintNameAndEmail.new(user_data: { name: 'Tom', email: '[email protected]' }).run
# => prints "The email of Tom is [email protected]."

# The name and email keys are strings; they are supposed to be symbols.
PrintNameAndEmail.new(user_data: { 'name' => 'Thomas', 'email' => '[email protected]' })
# => raises RungerActions::TypeMismatch

# The `:name` key is missing in the `user_data` hash.
PrintNameAndEmail.new(user_data: { email: '[email protected]' })
# => raises RungerActions::TypeMismatch

Specifying ActiveModel-style validations

class PrintEmail < ApplicationAction
  requires :email, String, format: { with: /.+@.+\..+/ }, length: { minimum: 6 }

  def execute
    puts("The email is '#{email}'.")
  end
end

PrintEmail.new(email: '[email protected]').run
# => prints "The email is '[email protected]'."

# This email doesn't match the specified regex
PrintEmail.new(email: 'Thomas Jefferson')
# => raises RungerActions::TypeMismatch

# This email is too short
PrintEmail.new(email: '[email protected]')
# => raises RungerActions::TypeMismatch

Specifying arbitrary input "shapes" by providing a callable object

You can leverage shaped's Callable shape type by providing any object that responds to #call (such as a lambda). This allows you unlimited flexibility to define requirements for the action's input(s).

class PrintSmallEvenNumber < ApplicationAction
  requires :small_even_number, ->(number) { (0..6).cover?(number) && number.even? }

  def execute
    puts("#{small_even_number} is a small, even number.")
  end
end

PrintSmallEvenNumber.new(small_even_number: 2).run
# => prints "2 is a small, even number."

# This number is not even
PrintSmallEvenNumber.new(small_even_number: 3).run
# => raises RungerActions::TypeMismatch

# This number is not small
PrintSmallEvenNumber.new(small_even_number: 200).run
# => raises RungerActions::TypeMismatch

Specifying validations for ActiveRecord inputs

When declaring a requires where the input is specified (via the second argument to requires) to be a class that inherits from ActiveRecord::Base, there are a few special things that happen:

  1. You can provide a validation block for the ActiveRecord object. Within this block, you can specify validations on attributes of that ActiveRecord model.
  2. You can check, by calling valid? on an instance of the action, whether the ActiveRecord object(s) that are inputs for the action meet the validation block validations.
  3. You can access any validation errors (from the validation block) via the #errors method of the action instance.
  4. You can execute the action instance via run! rather than run; this will raise an exception (and not run the #execute method) if any of the validations from a validation block are not met.
class PrintFirstAndLastName < ApplicationAction
  requires :user, User do
    validates :name, format: { with: /.+ .+/ }
  end

  def execute
    name_parts = user.name.split(' ')
    puts("First name: #{name_parts.first}. Last name: #{name_parts.last}")
  end
end

user = User.find(1)
user.is_a?(ActiveRecord::Base)
# => true
user.name
# => "David Runger"
action = PrintFirstAndLastName.new(user: user)
action.valid?
# => true
action.errors.to_hash
# => {}
action.run!
# => prints "First name: David. Last name: Runger"

user = User.find(2)
user.name
# => "Cher"
action = PrintFirstAndLastName.new(user: user)
action.valid?
# => false
action.errors.to_hash
# => {:name=>["is invalid"]}
action.run!
# => raises RungerActions::InvalidParam

::returns

The ::returns class method describes the value(s) that an action promises to return (if any).

As with requires, an action can have zero, one, or more returns statements.

An action that is used for its "side effects," such as most of the examples above that use puts to print output, will probably not have any returns statements.

However, if you want the action to return object(s)/data to other parts of your code, then you'll need to declare those return values using the returns class method.

Here's an example:

class MultiplyNumber < ApplicationAction
  requires :input_number, Numeric

  returns :doubled_number, Numeric
  returns :tripled_number, Numeric

  def execute
    result.doubled_number = input_number * 2
    result.tripled_number = input_number * 3
  end
end

multiply_result = MultiplyNumber.new(input_number: 1.5).run
multiply_result.class
# => MultiplyNumber::Result
puts("The number doubled is #{multiply_result.doubled_number}")
# => prints "The number doubled is 3.0"
puts("The number tripled is #{multiply_result.tripled_number}")
# => prints "The number tripled is 4.5"

The result object

We can see in the example above that MultiplyNumber#execute references result, which is an object provided automatically to action instances. Because the MultiplyNumber action declares returns :doubled_number and returns :tripled_number, the result object automatically has #doubled_number= and #tripled_number= writer methods, which can (and should) be invoked by the action instance in order to set those values on the result object.

When we call MultiplyNumber.new(input_number: 1.5).run, the return value of #run is the action's result object. Outside of the action, we can then access the return values that were set within the action's #execute method; we do this via the #doubled_number and #tripled_number reader methods that are defined on the result object (which we captured in a local variable called multiply_result).

All promised values must be returned

If an action fails to set any promised return values on the result object, then an error will be raised when #run is called:

class MultiplyNumber < ApplicationAction
  requires :input_number, Numeric

  returns :doubled_number, Numeric
  returns :tripled_number, Numeric

  def execute
    # PROBLEM BELOW! An error will be raised when this action is executed,
    # because we fail to set a `doubled_number` return value.

    # result.doubled_number = input_number * 2
    result.tripled_number = input_number * 3
  end
end

multiply_result = MultiplyNumber.new(input_number: 10).run
# => raises RungerActions::MissingResultValue

Validating the "shape" of returned values

As with the requires action class method, the "shape" of the promised return values declared via returns can be described via the arguments to returns, which are passed to the shaped gem. Leveraging this functionality allows you to ensure that your action is providing the expected type of return values.

class UppercaseEmail < ApplicationAction
  requires :email, String, format: { with: /.+@.+/ }

  returns :uppercased_email, String, format: { with: /[A-Z]+@[A-Z.]+/ }

  def execute
    result.uppercased_email = email.upcase
  end
end

UppercaseEmail.new(email: '[email protected]').run.uppercased_email
# => "[email protected]"

If an action attempts to set a return value that doesn't match the specified "shape" for that return value, then an RungerActions::TypeMismatch error will be raised:

class UppercaseEmail < ApplicationAction
  requires :email, String, format: { with: /.+@.+/ }

  returns :uppercased_email, String, format: { with: /[A-Z]+@[A-Z.]+/ }

  def execute
    # PROBLEM BELOW! This action is supposed to _upcase_ the email, not downcase it!
    result.uppercased_email = email.downcase
  end
end

UppercaseEmail.new(email: '[email protected]').run
# => raises RungerActions::TypeMismatch

::fails_with

The ::fails_with class method can be used to enumerate possible "failure modes" for the action.

As with requires and returns, an action can have zero, one, or more fails_with statements.

Generally, it's best to try to write actions in a way such that we don't expect any failures, but sometimes there are things outside of our control; in such cases, using fails_with to list these possible points of failure is a good idea. For example, a call to an external API might time out or receive a 500 error response.

Here's a (contrived) example with one fails_with declaration:

class PrintRandomNumberAboveFive < ApplicationAction
  fails_with :number_was_too_small

  def execute
    random_number = rand(10)
    if random_number > 5
      puts(random_number)
    else
      result.number_was_too_small!
    end
  end
end

result = PrintRandomNumberAboveFive.new.run
# => prints "9" (sometimes)
result.success?
# => true
result.number_was_too_small?
# => false

In the case above, we didn't encounter the error condition, which we can verify via the #success? and #number_was_too_small? methods on the result. #success? is available on all action results, and #number_was_too_small? is available for this particular action result because the action class declares fails_with :number_was_too_small.

And here's what a failure case would look like:

result = PrintRandomNumberAboveFive.new.run
# => [doesn't print anything, if the random number is <= 5]
result.success?
# => false
result.number_was_too_small?
# => true

In this case, we entered the else branch of the action's #execute method and called the result.number_was_too_small! method (made available automatically because of the class's fails_with :number_was_too_small declaration). Since we called the result.number_was_too_small! method, indicating that that failure mode occurred when executing the action, #success? returns false and #number_was_too_small? returns true.

Setting an error_message

When invoking a fails_with error case, the bang method can optionally take an error message as an argument, which will then be made available via a special error_message reader on the result object:

class SellAlcohol < ApplicationAction
  requires :age, Numeric

  fails_with :too_young

  def execute
    if age < 21
      result.too_young!("Age #{age} is too young to buy alcohol.")
    else
      puts('Enjoy your alcohol responsibly!')
    end
  end
end

result = SellAlcohol.new!(age: 17).run
result.success?
# => false
result.too_young?
# => true
result.error_message
# => "Age 17 is too young to buy alcohol."

Alternatives

This project is not the first of its kind!

Here are a few similar projects:

Status / Context

I wouldn't recommend using this gem in production. It's very new (i.e. probably rough around the edges, subject to significant changes at a relatively rapid rate, and arguably somewhat feature incomplete) and I am not committed to maintaing the gem.

I mostly built this gem because I wasn't quite satisfied with any of the above alternatives that I knew about at the time that I decided to start building it. I built this gem mostly to scratch my own itch and for the sake of exploring this problem space a little bit.

I am actively using this gem in the small Rails application that hosts my personal website and apps; you can check out its app/actions/ directory if you are interested in seeing some real-world use cases.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bin/rspec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

License

The gem is available as open source under the terms of the MIT License.