ActionFigure: Fully-Articulated Controller Actions for Rails

I worked on a codebase that had taken the “skinny controllers” and “skinny models” idea to heart by putting everything into service objects. I think that is a great idea if you are purposeful with your service objects. The problem was we weren’t. The stack from controller receiving a request to models updating the database was a dozen or more service objects deep in some places. It was service objects calling service objects calling service objects.

The problem wasn’t that the individual classes were particularly bad. Most of them were fine in isolation. The problem was that it was hard to know which service objects were being used by other service objects and it was hard to discern the impact that changing a service object had. It was safer to add a new service object and you had two choices: near the controller (the beginning of the chain) or the model (the end).

So the chains got longer. The service objects in the middle became a tangled web that nobody wanted to touch. The roles blurred until “service object” just meant “a class that does something, somewhere.”

I started thinking about what better boundaries and purpose would look like.

The service object problem

Service objects are just plain old Ruby objects. They can take anything, return anything, and call anything. There’s no constraint that tells you “this is my particular, exclusive job.” So when you’re staring at a chain of them, you have to read every one to understand what’s actually happening.

The lack of a clear role means they could do a bit of everything. Should this do validation? Is it some key business logic? Can I trust the inputs? Do I dare modify the data? Which models will this touch? The answer is usually “who knows?” which is how you end up with logic accreting at the edges — controllers and models — because at least you know what those are for.

Looking at the tests didn’t help. We believed in strong unit testing and mocking the boundaries. The test suite would tell you each link worked in isolation, but nothing about what the pipeline actually did end-to-end. You could have a green test suite and a broken feature.

The problem isn’t service objects per se. It’s the lack of clear roles and boundaries. What if each class had exactly one job, with explicit inputs, validation, and output?

Design philosophy

The idea behind ActionFigure is fully-articulated controller actions. When you open an action class, the full story is right there — what it accepts, how it validates, what it does, what it returns. You should be able to quickly see what it does without tracing through a chain of other objects.

Functional style. Actions are essentially functions. Clear inputs go in via .call, a render-ready hash comes out. No side-effecting instance state, no mutable shared context being passed down a chain.

Explicit over implicit. The shape and types of inputs are declared, not inferred. The response format can be chosen, not assumed. There’s no magic method resolution and no convention-based behavior — what you see is what you get.

Self-contained units. Each action is a complete, readable unit. No hidden state inherited from a base class, no relying on middleware you can’t see. Class-level state like params_schema and rules is intentionally not inherited by subclasses. Share behavior through plain Ruby — extract a method, call a service — not through deep class hierarchies.

Reusable across controllers. Because actions are decoupled from the controllers that call them, versioned controllers can share the same action class. V1::UsersController and V2::UsersController can both call Users::CreateAction.call. Versioning lives in the routing and controller layer where it belongs, not duplicated across business logic.

Introducing ActionFigure

Here’s what an action class looks like. This one creates a user and returns a JSON:API formatted response:

app/actions/users/create_action.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Users::CreateAction
  include ActionFigure[:jsonapi]

  params_schema do
    required(:user).hash do
      required(:name).filled(:string)
      required(:email).filled(:string)
    end
  end

  def call(params:, company:)
    user = company.users.create(params[:user])
    return UnprocessableContent(errors: user.errors.messages) if user.errors.any?

    Created(resource: user.as_json(only: %i[id name email]))
  end
end

The params_schema block declares the shape and types of the input. The #call method does the work. The response helpers — Created, UnprocessableContent — return render-ready hashes that go straight to render in the controller.

The controller becomes a one-liner:

app/controllers/users_controller.rb
1
2
3
4
5
6
class UsersController < ApplicationController
  def create
    render Users::CreateAction.call(params:, company: current_company)
  end
end

Extra keyword arguments like company: are passed through to #call as context. The action doesn’t know or care that it’s being called from a controller — you can test it with a plain method call.

The validation pipeline

In a typical Rails app the controller has params.require(:user).permit(:name, :email) — a whitelist that tells you which keys are allowed but nothing about their shape or types. You have to make those checks elsewhere.

ActionFigure replaces this with a two-layer pipeline that lives in one place.

Layer one: params_schema. This is where you declare the shape and types of the input. It makes both obvious at a glance:

params_schema — shape and types in one place
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
params_schema do
  required(:user).hash do
    optional(:title).filled(:string)
    required(:name).filled(string)
    optional(:email).filled(:string)
    optional(:guid).filled(:string)
    required(:address).hash do
      required(:street1).filled(:string)
      optional(:street2).filled(:string)
      required(:city).filled(:string)
      required(:state).filled(:string)
      required(:zipcode).filled(:string)
    end
  end
  optional(:referral_id).filled(:integar)
end

Compare that to params.require(:user).permit(:title, :name, :email, address: [:street1, :street2, :city, :state, :zipcode]). The permit call tells you the allowed keys, but not that title and street2 are optional while the rest are required, not that referral_id should be an integer, and there’s no clean way to express that referral_id is an optional sibling of the required user hash. With params_schema, the shape is obvious and the types are explicit.

Type coercion happens automatically — the string "25" that comes over HTTP becomes the integer 25. ActionFigure also accepts ActionController::Parameters directly. The schema acts as the whitelist, so there are no manual permit calls.

If the schema fails, #call never runs. The action returns an UnprocessableContent response immediately.

Layer two: rules. This is where cross-field and contextual validation lives. Rules only run after the schema passes, so you can trust that the data is structurally valid:

cross-param rules
1
2
3
4
rules do
  one_rule(:email, :guid, "provide either an email or a guid, but not both")
end

ActionFigure provides four cross-param rule helpers:

  • one_rule — exactly one of the listed fields must be present
  • all_rule — all or none of the listed fields must be present
  • any_rule — at least one of the listed fields must be present
  • exclusive_rule — at most one of the listed fields may be present

These are convenience helpers for common cases, but the rules block supports any custom validation that works with dry-validation — so you’re not limited to these four:

custom rule
1
2
3
4
5
6
7
8
9
10
rules do
  rule(:email) do
    key.failure("is not a valid email") unless values[:email].include?("@")
  end

  rule(:email) do
    key.failure("is already taken") if User.exists?(email: values[:email])
  end
end

These run in the validation layer, before #call. Invalid combinations never reach your business logic. When #call runs, you can trust that the inputs are structurally valid and logically consistent.

You can also enable strict mode with whiny_extra_params in the configuration. By default, extra parameters are silently stripped. With strict mode, they trigger a validation failure:

strict parameter checking
1
2
3
4
5
6
7
ActionFigure.configure do |config|
  config.whiny_extra_params = Rails.env.development?
end

# Called with: { item_id: 1, quantity: 2, admin: true }
# Result: UnprocessableContent with { admin: ["is not allowed"] }

The throughline: instead of validation scattered across controller, model, and service objects, the entire input contract is in one place.

Pluggable response formatters

Action logic shouldn’t be coupled to a particular response envelope. ActionFigure separates the two: your #call method uses response helpers like Ok and Created, and the formatter decides what shape the JSON takes.

The JSON:API formatter, for example, wraps resources in the standard { data: { type, id, attributes } } structure and formats errors with JSON pointers:

JSON:API success response
1
2
3
4
5
6
7
8
Created(resource: user.as_json(only: %i[id name email]))
# => {
#   json: {
#     data: { id: "1", name: "Tad", email: "tad@example.com" }
#   },
#   status: :created
# }
JSON:API error response
1
2
3
4
5
6
7
8
9
10
11
12
UnprocessableContent(errors: { name: ["is missing"] })
# => {
#   json: {
#     errors: [{
#       status: "422",
#       detail: "is missing",
#       source: { pointer: "/data/attributes/name" }
#     }]
#   },
#   status: :unprocessable_content
# }

ActionFigure ships with four formatters. Here’s how the same Ok(resource: { name: "Tad" }) call looks in each:

Formatter comparison
Formatter Include with Ok response shape
Default ActionFigure[:default] { name: "Tad" }
JSend ActionFigure[:jsend] { status: "success", data: { name: "Tad" } }
JSON:API ActionFigure[:jsonapi] { data: { name: "Tad" } }
Wrapped ActionFigure[:wrapped] { data: { name: "Tad" }, errors: nil, status: "success" }

You select a formatter per-class with include ActionFigure[:jsonapi], or set a global default:

global formatter configuration
1
2
3
4
ActionFigure.configure do |config|
  config.format = :jsonapi
end

Every formatter provides seven response helpers that map to HTTP status codes:

Response helpers
Helper HTTP Status Notes
Ok 200  
Created 201  
Accepted 202  
NoContent 204 No JSON body
Forbidden 403  
NotFound 404  
UnprocessableContent 422  

You can also build your own. A custom formatter is a module that implements the six required methods (Ok, Created, Accepted, UnprocessableContent, NotFound, Forbidden) and registers itself:

custom formatter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module MyFormatter
  include ActionFigure::Formatter

  def Ok(resource:, meta: nil)
    { json: { result: resource, ok: true }, status: :ok }
  end

  def Created(resource:, meta: nil)
    { json: { result: resource, ok: true }, status: :created }
  end

  def Accepted(resource: nil, meta: nil)
    { json: { ok: true }, status: :accepted }
  end

  def UnprocessableContent(errors:)
    { json: { errors: errors, ok: false }, status: :unprocessable_content }
  end

  def NotFound(errors:)
    { json: { errors: errors, ok: false }, status: :not_found }
  end

  def Forbidden(errors:)
    { json: { errors: errors, ok: false }, status: :forbidden }
  end
end

ActionFigure.configure do |config|
  config.register(my_format: MyFormatter)
  config.format = :my_format
end

Registration validates that your module implements all required methods at load time, so you find out immediately if you’ve missed one.

Under the hood

If you’re curious about how the pieces fit together, here’s what’s happening inside.

Dynamic module building. When you write include ActionFigure[:jsonapi], the [] method dynamically builds a module that mixes in two things: the validation pipeline (Core) and the chosen formatter. Here’s the actual code:

lib/action_figure.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def self.[](format = configuration.format)
  format_modules.compute_if_absent(format) { build_format_module(format, fetch(format)) }
end

def self.new_format_module(formatter)
  Module.new do
    def self.included(base)
      base.extend(ActionFigure::Core::ClassMethods)
      return unless defined?(ActiveSupport::Notifications) &&
                    ActionFigure.configuration.activesupport_notifications

      base.extend(ActionFigure::Core::Notifications)
    end

    include ActionFigure::Core
    include formatter
  end
end

This is why each action class gets a clean include — the dynamically built module brings in everything the action needs in one shot. Formatter selection happens at include time, so different actions in the same app can use different formatters.

Thread-safe caching. Format modules are cached in a Concurrent::Map, so each one is built exactly once and reused across threads. The compute_if_absent call guarantees thread safety without explicit locks — important for high-throughput Rails apps running on Puma.

thread-safe module cache
1
2
3
4
def self.format_modules
  @format_modules ||= Concurrent::Map.new
end

No inheritance by design. Class-level state — params_schema, rules, entry_point — is stored in class instance variables that are intentionally not inherited by subclasses. This is a deliberate choice. Each action is a flat, self-contained unit that you can read without wondering what a parent class set up. It prevents the kind of deep inheritance chains that made the original service object problem so hard to reason about.

Contract exposure. The underlying Dry::Validation::Contract is accessible via .contract:

using the contract directly
1
2
3
4
5
contract = Users::CreateAction.contract
result = contract.call(user: { name: "Tad", email: "tad@example.com" })
result.success?  # => true
result.to_h      # => { user: { name: "Tad", email: "tad@example.com" } }

This is useful for form validation endpoints where you want to check params without running the full action, or for testing validation rules in isolation.

Testing

With ActionFigure, testing is straightforward. Actions are Ruby objects that receive named parameters and return hashes. You call .call with the same arguments the controller would pass, and you assert on the result.

Minitest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
require "action_figure/testing/minitest"

class Users::CreateActionTest < Minitest::Test
  include ActionFigure::Testing::Minitest

  def test_creates_a_user
    company = Company.create!(name: "Acme")

    result = Users::CreateAction.call(
      params: { user: { name: "Tad", email: "tad@example.com" } },
      company: company
    )

    assert_Created(result)
    assert_includes result[:json][:data].to_s, "Tad"
  end

  def test_rejects_missing_name
    company = Company.create!(name: "Acme")

    result = Users::CreateAction.call(
      params: { user: { email: "tad@example.com" } },
      company: company
    )

    assert_UnprocessableContent(result)
  end
end
RSpec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
require "action_figure/testing/rspec"

RSpec.describe Users::CreateAction do
  it "creates a user with valid parameters" do
    company = Company.create!(name: "Acme")

    result = Users::CreateAction.call(
      params: { user: { name: "Tad", email: "tad@example.com" } },
      company: company
    )

    expect(result).to be_Created
    expect(result[:json][:data]).to include("name" => "Tad")
  end

  it "rejects missing name" do
    company = Company.create!(name: "Acme")

    result = Users::CreateAction.call(
      params: { user: { email: "tad@example.com" } },
      company: company
    )

    expect(result).to be_UnprocessableContent
  end
end

The matchers — assert_Created, be_Created, assert_UnprocessableContent, be_UnprocessableContent, and so on — are provided for all seven response statuses. They check the :status key in the returned hash.

The test calls the action the same way the controller does. There’s nothing to mock.

ActiveSupport Notifications

ActionFigure has optional ActiveSupport::Notifications integration. When enabled, every action call emits a process.action_figure event with the action class name and response status:

enabling notifications
1
2
3
4
ActionFigure.configure do |config|
  config.activesupport_notifications = true
end
subscribing to events
1
2
3
4
5
6
ActiveSupport::Notifications.subscribe("process.action_figure") do |event|
  Rails.logger.info "#{event.payload[:action]}#{event.payload[:status]} (#{event.duration}ms)"
end

# => "Users::CreateAction → created (12.3ms)"

You get logging, monitoring, and alerting hooks without coupling any of that into the action logic itself. The action doesn’t know it’s being observed.

File conventions

Actions aren’t generic service objects floating in app/services/ with vague names like UserManager or ProcessOrder. They follow a consistent naming convention: Resource::VerbAction. The -Action suffix tells you exactly what you’re looking at — a class that handles a single controller action with declared inputs and a structured response.

I have two recommended directory structures (that work with Rails autoloading):

Option 1: standalone directory
1
2
3
4
5
6
7
8
9
10
11
app/actions/
  users/
    create_action.rb
    destroy_action.rb
    index_action.rb
    show_action.rb
    update_action.rb
  orders/
    search_action.rb
    cancel_action.rb
Option 2: alongside controllers
1
2
3
4
5
6
7
8
9
10
11
12
13
app/controllers/
  users_controller.rb
  users/
    create_action.rb
    destroy_action.rb
    index_action.rb
    show_action.rb
    update_action.rb
  orders_controller.rb
  orders/
    search_action.rb
    cancel_action.rb

The second option keeps actions right next to the controllers that call them. Either way, when you see Users::CreateAction you know it’s an action class that handles user creation for a controller — not a service object that might be called from anywhere.

Playing well with others

Because actions are plain Ruby objects that take keyword arguments and return hashes, they compose naturally with whatever gems you’re already using. No adapters, no special imports — just call the library and pass the result to a response helper.

Serialization. Use Blueprinter, Alba, Oj Serializers, or anything else that gives you a hash:

with Blueprinter
1
2
3
4
5
6
7
def call(params:, company:)
  user = company.users.create(params[:user])
  return UnprocessableContent(errors: user.errors.messages) if user.errors.any?

  Created(resource: UserBlueprint.render_as_hash(user))
end

Authorization. Pass current_user as context from the controller and check policies inside the action:

with Pundit
1
2
3
4
5
6
7
8
9
10
def call(params:, current_user:)
  user = User.find(params[:id])
  unless UserPolicy.new(current_user, user).destroy?
    return Forbidden(errors: { base: ["not authorized to delete this user"] })
  end

  user.destroy!
  NoContent()
end

Pagination. Paginate with Pagy, cursor pagination, or whatever you prefer — pass metadata through the meta: keyword:

with Pagy
1
2
3
4
5
6
7
8
9
10
11
class Users::IndexAction
  include ActionFigure[:jsend]
  include Pagy::Backend

  def call(request:, company:, **)
    pagy, users = pagy(:keyset, company.users.order(:name), request:)
    resource = UserSerializer.many(users)
    Ok(resource:, meta: { next: pagy.next })
  end
end

The pattern is always the same: context comes in as keyword arguments, the gem does its job, and the result goes out through a response helper.

Fully articulated

The 12-deep service object chains happened because there were no clear roles or boundaries. Nobody knew where they were in the chain, so the chains got longer.

ActionFigure is my answer to that. Each action owns its validation, its logic, and its response shape. One class, one job, fully self-contained. You open the file and you can see what it does and know that it is being used by a controller.

ActionFigure on GitHub →