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:
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:
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:
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:
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 presentall_rule— all or none of the listed fields must be presentany_rule— at least one of the listed fields must be presentexclusive_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:
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:
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:
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 # } |
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 | 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:
1 2 3 4 | ActionFigure.configure do |config| config.format = :jsonapi end |
Every formatter provides seven response helpers that map to HTTP status codes:
| 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:
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:
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.
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:
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.
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 |
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:
1 2 3 4 | ActionFigure.configure do |config| config.activesupport_notifications = true end |
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):
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
|
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:
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:
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:
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.