Fully-articulated controller actions.
Installation
How It Works
Features
Design Philosophy
Examples
Requirements
License
ActionFigure makes your controller actions more usable and understandable. It turns this:
class ProjectsController < ApplicationController
def create
permitted = params.require(:project).permit(
:name, :description, settings: [:visibility, :notify_on_mention]
)
if permitted[:name].blank?
render json: { status: "fail", data: { name: ["is required"] } },
status: :unprocessable_entity
return
end
if permitted.dig(:settings, :visibility).present? &&
!%w[public private].include?(permitted[:settings][:visibility])
render json: { status: "fail", data: { settings: { visibility: ["must be public or private"] } } },
status: :unprocessable_entity
return
end
unless current_user.member_of?(current_workspace)
render json: { status: "fail", data: { base: ["must be a workspace member"] } },
status: :forbidden
return
end
if current_workspace.projects.exists?(name: permitted[:name])
render json: { status: "fail", data: { name: ["already exists in this workspace"] } },
status: :conflict
return
end
project = CreateProject.run(permitted, workspace: current_workspace, creator: current_user)
if project.errors.any?
render json: { status: "fail", data: project.errors.messages },
status: :unprocessable_entity
return
end
render json: { status: "success", data: ProjectBlueprint.render_as_hash(project) }
end
endinto this:
class ProjectsController < ApplicationController
def create
render Projects::CreateAction.create(params:, current_user:, current_workspace:)
end
endclass Projects::CreateAction
include ActionFigure[:jsend]
params_schema do
required(:project).hash do
required(:name).filled(:string)
optional(:description).filled(:string)
optional(:settings).hash do
optional(:visibility).filled(:string, included_in?: %w[public private])
optional(:notify_on_mention).filled(:bool)
end
end
end
def create(params:, current_user:, current_workspace:)
unless current_user.member_of?(current_workspace)
return Forbidden(errors: { base: ["must be a workspace member"] })
end
if current_workspace.projects.exists?(name: params[:project][:name])
return Conflict(errors: { name: ["already exists in this workspace"] })
end
project = CreateProject.run(params[:project], workspace: current_workspace, creator: current_user)
return UnprocessableContent(errors: project.errors.messages) if project.errors.any?
Created(resource: ProjectBlueprint.render_as_hash(project))
end
end- The shape and types of your params are obvious
- The structure is clear
- The tests are easy (and 10x faster)
- The responses are uniform and render-ready
(Did you notice which of the render responses was incorrect before?)
Add to your Gemfile and bundle install:
gem "action_figure"Every action class has three responsibilities:
- Check params (optional) — when a
params_schemais defined, it validates structure and types;rulesenforces validation rules. If either fails, the formatter returns an error response and your action method is never invoked. Actions without a schema receiveparams:as-is. - Orchestrate — your action method coordinates the work: creating records, coordinating collaborators, enqueuing jobs, or anything else the action requires. The action is the entry point, not necessarily where all the logic lives.
- Return a formatted response — response helpers like
Created(resource:)andNotFound(errors:)return render-ready hashes that go straight torenderin your controller.
| Feature | Description |
|---|---|
| Validation | Two-layer validation powered by dry-validation: structural schemas with type coercion, plus validation rules. Includes cross-parameter helpers like exclusive_rule, any_rule, one_rule, and all_rule. |
| Response Formatters | Four built-in formats: Default, JSend, JSON:API, and Wrapped. Each provides response helpers (Ok, Created, NotFound, etc.) that return render-ready hashes. |
| Status Codes | Which 4xx codes are domain concerns (handled by action classes) vs perimeter concerns (handled by middleware, router, or infrastructure). |
| Custom Formatters | Define your own response envelope by implementing the formatter interface. Registration validates your module at load time. |
| Actions | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and entry_point for disambiguation. |
| Configuration | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
| Notifications | Opt-in ActiveSupport::Notifications events for every action call. Emits action class, outcome status, and duration on the process.action_figure event. |
| Testing | Minitest assertions (assert_Ok, assert_Created, ...) and RSpec matchers (be_Ok, be_Created, ...) for expressive status checks. |
| Integration Patterns | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |
ActionFigure is scoped to controller actions — it validates params, runs your logic, and returns a hash you pass directly to render.
- Purpose over convention — each class does one thing and names it clearly
- Explicit over implicit — no magic method resolution, no inherited callbacks
- Actions own their lifecycle — validation, execution, and response formatting live together
- Controllers become boring — one-line
rendercalls that delegate to action classes - Separate domain tests from perimeter tests — keep your controller tests for perimeter checks, but now your domain logic lives in plain method calls. Faster tests, clearer failures.
Cross-parameter helpers make multi-field constraints declarative:
class Search::LookupAction
include ActionFigure[:jsend]
params_schema do
optional(:user_id).filled(:integer)
optional(:email).filled(:string)
end
rules do
exclusive_rule(:user_id, :email, "provide one, not both")
end
def lookup(params:)
user = params[:user_id] ? User.find_by(id: params[:user_id]) : User.find_by(email: params[:email])
Ok(resource: user.as_json)
end
endChoose a response envelope by name. The same helpers return different shapes:
# Default
Created(resource: user)
# => { json: { data: user }, status: :created }
# JSend
Created(resource: user)
# => { json: { status: "success", data: user }, status: :created }
# JSON:API
Created(resource: user)
# => { json: { data: { type: "users", id: "1", attributes: user } }, status: :created }
# Wrapped
Created(resource: user)
# => { json: { data: user, errors: nil, status: "success" }, status: :created }Action classes are plain method calls — no request setup needed:
class Search::LookupActionTest < Minitest::Test
include ActionFigure::Testing::Minitest
def test_finds_by_email
result = Search::LookupAction.lookup(
params: { email: "tad@example.com" }
)
assert_Ok(result)
end
def test_rejects_both_user_id_and_email
result = Search::LookupAction.lookup(
params: { user_id: 1, email: "tad@example.com" }
)
assert_UnprocessableContent(result)
assert_includes result[:json][:data][:user_id], "provide one, not both"
end
endNot every action needs parameter validation:
class HealthCheckAction
# Uses the globally-configured format
include ActionFigure
def check(current_user:)
Ok(resource: { status: "healthy", user: current_user.name })
end
end- Ruby >= 3.2
- dry-validation ~> 1.10 — ActionFigure uses dry-validation for schema validation. However, there's no dependency injection container, monads, or functional pipeline. Just a focused layer for controller actions.
- Rails is not required, but ActionFigure is designed for Rails controller patterns
The gem is available as open source under the terms of the MIT License.