Make your Rails interactors clean, powerful, and less error-prone!
InteractorSupport
extends the Interactor pattern to make your business logic more concise, expressive, and robust.
- Automatic Validations β Validate inputs before execution
- Data Transformations β Trim, downcase, and sanitize with ease
- Transactional Execution β Keep data safe with rollback support
- Conditional Skipping β Skip execution based on logic
- Auto Record Lookup & Updates β Reduce boilerplate code
- Request Objects β Lean on ActiveModel for structured, validated inputs
Add to your Gemfile:
gem 'interactor_support', '~> 1.0', '>= 1.0.1'
Or install manually:
gem install interactor_support
It is reccommended that you use Rails 7.1.x and above with this gem. However, it will work with older versions of rails. You may encounter the issue below when using Rails versions prior to 7.1.
<module:LoggerThreadSafeLevel>: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)
In that case, you need to add require "logger"
as the first line to your boot.rb file
or pin the concurrent-ruby version to gem 'concurrent-ruby', '1.3.4'
. If feasible, consider upgrading your Rails application to version 7.1 or newer, where this compatibility issue has been addressed.
Without InteractorSupport
class UpdateTodoTitle
include Interactor
def call
todo = Todo.find_by(id: context.todo_id)
return context.fail!(error: "Todo not found") if todo.nil?
todo.update!(title: context.title.strip, completed: context.completed)
context.todo = todo
end
end
Problems:
β Repetitive boilerplate
β Manual failure handling
β No automatic transformations
With InteractorSupport, your interactor is now elegant and expressive:
class UpdateTodoTitle
include Interactor
include InteractorSupport
required :todo_id, :title
transform :title, with: :strip
find_by :todo, query: { id: :todo_id }, required: true
update :todo, attributes: { title: :title }
def call
context.message = "Todo updated!"
end
end
π What changed?
β
Self documenting validation using requires
β
Trimmed the title with transform
β
Automatic record lookup with find_by
β
Automatic update with update
class CompleteTodo
include Interactor
include InteractorSupport
transaction
required :todo_id
find_by :todo, query: { id: :todo_id }, required: true
update :todo, attributes: {
completed: true,
completed_at: -> { Time.current }
}
end
β
Wraps the interactor in an active record transaction
β
Self documenting validation using requires
β
Automatic record lookup with find_by
β
Automatic update with update
using static values, and a context aware lambda.
class CompleteTodo
include Interactor
include InteractorSupport
transaction
required :todo
skip if: -> { todo.completed? }
update :todo, attributes: { completed: true, completed_at: -> { Time.current } }
end
Instead of raw hashes, Request Objects provide validation, transformation, and structure.
- Works just like an ActiveRecord model
- Supports validations out of the box
- Automatically transforms & sanitizes data
class TodoRequest
include InteractorSupport::RequestObject
attribute :title, transform: :strip
attribute :email, transform: [:strip, :downcase]
attribute :completed, type: Boolean, default: false
validates :title, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
Provides the update
method to automatically update records based on context.
# example incantations
update :todo, attributes: { title: :title } # -> context.todo.update!(title: context.title)
update :todo, attributes: { title: :title }, context_key: :updated_todo # -> context.updated_todo = context.todo.update!(title: context.title)
update :todo, attributes: { request: { title: :title } } # -> context.todo.update!(title: context.request.title)
update :todo, attributes: { request: [:title, :completed] } # -> context.todo.update!(title: context.request.title, completed: context.request.completed)
update :todo, attributes: :request # -> context.todo.update!(context.request)
update :todo, attributes: [:title, :completed] # -> context.todo.update!(title: context.title, completed: context.completed)
update :todo, attributes: { title: :title, completed: true, completed_at: -> { Time.zone.now } } # -> context.todo.update!(title: context.title, completed: true, completed_at: Time.zone.now)
Provides find_by
and find_where
to automatically locate records.
# example incantations
# Genre.find_by(id: context.genre_id)
find_by :genre, context_key: :current_genre
# lambdas are executed within the interactor's context, can be anything needed to compute at runtime
# Genre.find_by(name: context.name, created_at: context.some_context_value )
find_by :genre, query: { name: :name, created_at: -> { some_context_value } }
find_by :genre, query: { name: :name, created_at: -> { 7.days.ago...1.day.ago } }
# be careful here, this is not advisable.
find_by :genre, query: { name: :name, created_at: 7.days.ago...1.day.ago }
# Genre.find_by(name: context.name)
find_by :genre, query: { name: :name }
# context.current_genre = Genre.find_by(id: context.genre_id)
find_by :genre, context_key: :current_genre
# Genre.find_by(id: context.genre_id), fails the context if the result is nil
find_by :genre, required: true
# find_where
# Post.where(user_id: context.user_id)
find_where :post, where: { user_id: :user_id }
# Same as above, but will fail the context if the results are empty
find_where :post, where: { user_id: :user_id }, required: true
# lambdas are executed within the interactor's context
# Post.where(user_id: context.user_id, created_at: context.some_context_value )
find_where :post, where: { user_id: :user_id, created_at: -> { some_context_value } }
# Post.where(user_id: context.user_id).where.not(active: false)
find_where :post, where: { user_id: :user_id }, where_not: { active: false }
# Post.active.where(user_id: context.user_id)
find_where :post, where: { user_id: :user_id }, scope: :active
# context.user_posts = Post.where(user_id: context.user_id)
find_where :post, where: { user_id: :user_id }, context_key: :user_posts
Provides transform
to sanitize and normalize inputs.
# any method that the attribute responds to will work
transform :title, with: :strip
# You can chain transformers on an attribute
# show_the_thing == "1" => "1".to_i.positive? => true
transform :show_the_thing, with: [:to_i, :positive?]
# transforming a string to a boolean using a lambda, eg: "true" => true
transform :my_param, with: -> (val) { ActiveModel::Type::Boolean.new.cast(val) }
# added in 1.0.2
# mixing symbols and keys. eg: " True " => true
transform :my_param, with: [
:strip,
:downcase,
-> (val) { ActiveModel::Type::Boolean.new.cast(val) }
]
Allows an interactor to skip execution if a condition is met.
# skips execution
skip if: true
# skips execution when a lambda is passed
skip if: -> { true }
# using a method
skip if: :some_method?
# using a context variable
skip if: :condition
# Using `unless`
# skips execution
skip unless: false
# skips execution when a lambda is passed
skip unless: -> { false }
# using a method
skip unless: :some_method?
# using a context variable
skip unless: :condition
Provides automatic input validation before execution. This includes ActiveModel::Validations
and
ActiveModel::Validations::Callbacks
, and adds a few extra methods.
method | description |
---|---|
required | a self documenting helper method that registers the attribute as an accessor, applies any active model validations passed to it. If the attribute is missing, it will fail the context |
optional | a self documenting helper method that registers the attribute as an accessor, applies any active model validations passed to it using :if_assigned |
validates_after | An after validator to ensure context consistancy. |
validates_before | will likely be deprecated in the future. |
class CreateUser
include Interactor
include InteractorSupport
required email: { format: { with: URI::MailTo::EMAIL_REGEXP } },
password: { length: { minimum: 6 } }
optional age: { numericality: { greater_than: 18 } }
validates_after :user, persisted: true
end
β email must be present and match a valid format β password must be present and at least 6 characters long β age is optional but must be greater than 18 if provided
If any validation fails, context.fail!(errors: errors.full_messages) will automatically halt execution.
A flexible, form-like abstraction for service object inputs, built on top of ActiveModel. InteractorSupport::RequestObject extends ActiveModel::Model and ActiveModel::Validations to provide structured, validated, and transformed input objects. It adds first-class support for nested objects, type coercion, attribute transformation, and array handling. It's ideal for use with any architecture that benefits from strong input modeling.
RequestObject Enforces Input Integrity, and π allow-lists attributes by default
Features
- Define attributes with types and transformation pipelines
- Supports primitive and custom object types
- Deeply nested input coercion and validation
- Array support for any type
- Auto-generated context hashes or structs
- Key rewriting for internal/external mapping
- Full ActiveModel validation support
Rather than manually massaging and validating hashes or params in your services, define intent-driven objects that:
- clean incoming values
- validate data structure and content
- expose clean interfaces for business logic
π Getting Started
- Define a Request Object
class GenreRequest
include InteractorSupport::RequestObject
attribute :title, transform: :strip
attribute :description, transform: :strip
validates :title, :description, presence: true
end
- Use it in your Interactor, Service, or Controller
class GenresController < ApplicationController
def create
context = SomeOrganizerForCreatingGenres.call(
GenreRequest.new(params.permit!) # π request objects are a safe and powerful replacement for strong params
)
# render context.genre & handle success? vs failure?
end
end
Apply one or more transformations when values are assigned.
attribute :email, transform: [:strip, :downcase]
- You can use any transform that the value can
respond_to?
- Define custom transforms as instance methods.
Type Casting: Cast inputs to expected types automatically:
attribute :age, type: :integer
attribute :tags, type: :string, array: true
attribute :config, type: Hash
attribute :published_at, type: :datetime
attribute :user, type: User
If the value is already of the expected type, it will just pass through. Otherwise, it will try to cast it.
If casting fails, or you specify an unsupported type, it will raise an InteractorSupport::RequestObject::TypeError
Supported types are
- Any ActiveModel::Type, provided as a symbol.
- The following primitives, Array, Hash, Symbol
- RequestObject subclasses (for nesting request objects)
class AuthorRequest
include InteractorSupport::RequestObject
attribute :name
attribute :location, type: LocationRequest
end
class PostRequest
include InteractorSupport::RequestObject
attribute :authors, type: AuthorRequest, array: true
end
Nested objects are instantiated recursively and validated automatically.
Rename external keys for internal use.
attribute :image, rewrite: :image_url, transform: :strip
request = ImageUploadRequest.new(image: ' https://url.com ')
request.image_url # => "https://url.com"
request.respond_to?(:image) # => false
Return a nested Hash, Struct, or self:
# Default
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
# returns a hash with symbol keys => {:authors=>[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]}
# Configure globally
InteractorSupport.configure do |config|
config.request_object_behavior = :returns_context # or :returns_self
config.request_object_key_type = :symbol # or :string, :struct
end
# request_object_behavior = :returns_context, request_object_key_type = :string
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
# returns a hash with string keys => {"authors"=>[{"name"=>"Ruby", "location"=>{"city"=>"Seattle"}}]}
# request_object_behavior = :returns_context, request_object_key_type = :struct
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
# returns a Struct => #<struct authors=[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]>
# request_object_behavior = :returns_self, request_object_key_type = :symbol
request = PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
# returns the request object => #<PostRequest authors=[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]>
# request.authors.first.location.city => "Seattle"
# request.to_context => {:authors=>[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]}
π‘ Replacing Strong Parameters Safely
InteractorSupport::RequestObject is a safe, testable, and expressive alternative to Railsβ strong_parameters. While strong_params are great for sanitizing controller input, they tend to:
- Leak into your business logic
- Lack structure and type safety
- Require repetitive permit/require declarations
- Get clumsy with nesting and arrays
Instead, RequestObject defines the expected shape and behavior of input once, and gives you:
- Input sanitization via transform:
- Validation via ActiveModel
- Type coercion (including arrays and nesting)
- Reusable, composable input classes
StrongParams Example
def user_params
params.require(:user).permit(:name, :email, :age)
end
def create
user = User.new(user_params)
...
end
Even with this, you still have to: β’ Validate formats (like email) β’ Coerce types (:age is still a string!) β’ Repeat this logic elsewhere
Request Object Equivelent
class UserRequest
include InteractorSupport::RequestObject
attribute :name, transform: :strip
attribute :email, transform: [:strip, :downcase]
attribute :age, type: :integer # or transform: [:to_i]
validates :name, presence: true
validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
end
Why replace Strong Params?
Feature | Strong Params | Request Object |
---|---|---|
Requires manual permit/require | β Yes | β Not needed |
Validates types/formats | β No | β Yes |
Handles nested objects | π¬ With effort | β First-class support |
Works outside controllers | β Not cleanly | β Perfect for services/interactors |
Self-documenting input shape | β No | β Defined via attribute DSL |
Testable as a unit | β Not directly | β Easily tested like a form object |
π‘ Tip
You can still use params.require(...).permit(...) in the controller if you want to restrict top-level keys, then pass that sanitized hash to your RequestObject:
UserRequest.new(params.require(:user).permit(:name, :email, :age))
But with RequestObject, thatβs often unnecessary because youβre already defining a schema.
The Organizable concern provides utility methods to simplify working with interactors and request objects. It gives you a clean and consistent pattern for extracting, transforming, and preparing parameters for use in service objects or interactors.
Features
- organize: Call interactors with request objects, optionally namespaced under a context_key.
- request_params: Extract, shape, filter, rename, flatten, and merge incoming params in a clear and declarative way.
- Built for controllers or service entry points.
- Rails-native feel β works seamlessly with strong params.
#organize(interactor, params:, request_object:, context_key: nil) Calls the given interactor with a request object built from the provided params.
Argument | Type | Description |
---|---|---|
interactor | Class | The interactor to call (.call must be defined). |
params | Hash | Parameters passed to the request object. |
request_object | Class | A request object class that accepts params in its initializer. |
context_key | Symbol or nil | Optional key to namespace the request object inside the interactor context. |
Examples
organize(MyInteractor, params: request_params, request_object: MyRequest)
# => MyInteractor.call(MyRequest.new(params))
organize(MyInteractor, params: request_params, request_object: MyRequest, context_key: :request)
# => MyInteractor.call({ request: MyRequest.new(params) })
Returns a shaped parameter hash derived from params.permit!. You can extract specific top-level keys, rename them, flatten values, apply defaults, and remove unwanted fields.
Argument | Type | Description |
---|---|---|
*top_level_keys |
Symbol... |
Optional list of top-level keys to include. If omitted, includes all. |
merge: |
Hash |
Extra values to merge into the result. |
except: |
Array<Symbol or Array<Symbol>> |
Keys or nested key paths to exclude. |
rewrite: |
Array<Hash> |
Rules for renaming, flattening, filtering, merging, or defaulting values. |
Rewrite Options
Each rewrite entry is a hash in the form { key => options }, where options may include:
Option | Type | Description |
---|---|---|
as |
Symbol |
Rename the key to a new top-level key. |
only |
Array<Symbol> |
Include only these subkeys in the result. |
except |
Array<Symbol> |
Remove these subkeys from the result. |
flatten |
true or Array<Symbol> |
Flatten all subkeys into top-level (or just the specified ones). |
default |
Hash |
Use this value if the original key is missing or nil. |
merge |
Hash |
Merge this hash into the result (after filtering and flattening). |
Example: full usage
# Incoming params:
params = {
order: {
product_id: 1,
quantity: 2,
internal: "should be removed"
},
metadata: {
source: "mobile",
internal: "hidden",
location: { ip: "1.2.3.4" }
},
flags: {
foo: true
},
internal: "global_internal",
session: nil
}
# Incantation:
request_params(:order, :metadata, :flags, :session,
merge: { user: current_user }, # <- Add the user
except: [[:order, :internal], :internal], # <- remove `order.internal`, and the top level key `internal`
rewrite: [
{ order: { flatten: true } }, # <- moves all the values from order to top level keys
{ metadata: { as: :meta, only: [:source, :location], flatten: [:location] } }, # <- Rename metadata to meta, pluck source and location, move location's values to meta
{ flags: { merge: { debug: true } } }, # <- add flags.debug = true
{ session: { default: { id: nil } } } # <- create a default value for session
]
)
# Result
{
product_id: 1,
quantity: 2,
meta: {
source: "mobile",
ip: "1.2.3.4"
},
flags: {
foo: true,
debug: true
},
session: {
id: nil
},
user: current_user
}
Flattening arrays of hashes (e.g., { events: [{ id: 1 }] }) is intentionally not supported to avoid accidental key collisions. If needed, transform such structures manually before passing to request_params.
Usage
Include in a controller or service base class
class ApplicationController < ActionController::Base
include InteractorSupport::Concerns::Organizable
end
Pull requests are welcome on GitHub.
Released under the MIT License.