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:
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
expect
ed keyword arguments tocall
, then reference their values as localattr_reader
s.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
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
- if OK,
message
is a helper to return the relevant message in either case (defined asok? ? success : error
)
Example
This interface yields a common usage pattern:
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).