Skip to content

Introduction

This library provides a set of conventions for writing business logic in Rails (or other Ruby) applications with:

  • Clear calling semantics: Foo.call
  • A declarative interface
  • A consistent return interface
    • Exception swallowing + clear distinction between internal and user-facing errors

Minimal example

Your logic goes in a PORO. The only requirements are to include Action and a call method, meaning the basic skeleton looks something like this:

ruby
class Foo
  include Action

  def call
    log "Doesn't do much, but this technically works..."
  end
end

Inputs and Outflows

Most actions require inputs, and many return values to the caller; no need for any def initialize boilerplate, just add:

  • expects :foo to declare inputs the class expects to receive.

    You pass the expected keyword arguments to call, then reference their values as local attr_readers.

  • exposes :bar to declare any outputs the class will expose.

    Within your action, use expose :bar, <value> to set a value that will be available on the return interface.

INFO

By design you cannot access anything you do not explicitly expose from outside the action itself. Making the external interface explicit helps maintainability by ensuring you can refactor internals without breaking existing callsites.

WARNING

The declarative interface (expects and exposes) constitutes a contract you are making with yourself (and your fellow developers). This is not for validating user input -- there's a Form Object pattern for that.

If any declared expectations or exposures are not met the action will fail, setting error to a generic error message (because a failed validation means you called your own service wrong; there's nothing the end user can do about that).

Example

ruby
class Actions::Slack::Post
  include Action
  VALID_CHANNELS = [ ... ]

  expects :channel, default: VALID_CHANNELS.first, inclusion: { in: VALID_CHANNELS } 
  expects :message, type: String

  exposes :thread_id, type: String

  def call
    response = client.chat_postMessage(channel:, text: message)
    the_thread_id = response["ts"]

    expose :thread_id, the_thread_id 
  end

  private

  def client = Slack::Web::Client.new
end

Return interface

The return value of an Action call is always an Action::Result, which provides a consistent interface:

  • ok? will return a boolean (false if any errors or exceptions occurred, otherwise true)
    • if OK, success will return a string that is safe to show end users
    • if not OK, error will return an error string that is safe to show end users
  • message is a helper to return the relevant message in either case (defined as ok? ? success : error)

Example

This interface yields a common usage pattern:

ruby
class MessagesController < ApplicationController
  def create
    result = Actions::Slack::Post.call( 
      channel: "#engineering",
      message: params[:message],
    )

    if result.ok?
      @thread_id = result.thread_id # Because `thread_id` was explicitly exposed
      flash.now[:success] = "Sent the Slack message"
    else
      flash[:alert] = result.error
      redirect_to action: :new
    end
  end
end

Note this simple pattern handles multiple levels of "failure" (details below):

  • Showing specific user-facing flash messages for any arbitrary logic you want in your action (from fail!)
  • Showing generic error message if anything went wrong internally (e.g. the Slack client raised an exception -- it's been logged for the team to investigate, but the user doesn't need to care what went wrong)
  • Showing generic error message if any of your declared interface expectations fail (e.g. if the exposed thread_id, which we pulled from Slack's API response, somehow isn't a String)

Error handling

BIG IDEA

By design, result.error is always safe to show to the user.

Calling code usually only cares about ok? and error -- no complex error handling needed. 🤩

We make a clear distinction between user-facing and internal errors.

User-facing errors (fail!)

For known failure modes, you can call fail!("Some user-facing explanation") at any time to abort execution and set result.error to your custom message.

Internal errors (uncaught raise)

Any exceptions will be swallowed and the action failed (i.e. not ok?). result.error will be set to a generic error message ("Something went wrong" by default, but highly configurable).

The swallowed exception will be available on result.exception for your introspection, but it'll also be passed to your on_exception handler so, with a bit of configuration, you can trust that any exceptions have been logged to your error tracking service automatically (one more thing the dev doesn't need to think about).