InteractorSupport π
Make your Rails interactors clean, powerful, and less error-prone!
π What Is InteractorSupport?
InteractorSupport
extends the Interactor pattern to make your business logic more concise, expressive, and robust.
β Why Use It?
- 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
π¦ Installation
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.
π¦ Getting Started
The Problem: Messy Interactors
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
The Solution: InteractorSupport
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
Making it even more powerful
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.
Skipping execution when it's not needed
class CompleteTodo
include Interactor
include InteractorSupport
transaction
required :todo
skip if: -> { todo.completed? }
update :todo, attributes: { completed: true, completed_at: -> { Time.current } }
end
π Request Objects β Lean on Rails Conventions
Instead of raw hashes, Request Objects provide validation, transformation, and structure.
β Built on ActiveModel
- 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
π Documentation
Modules & Features
πΉ InteractorSupport::Concerns::Updatable
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)
πΉ InteractorSupport::Concerns::Findable
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
πΉ InteractorSupport::Concerns::Transformable
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) }
]
πΉ InteractorSupport::Concerns::Skippable
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
πΉ InteractorSupport::Validations
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.
πΉ InteractorSupport::RequestObject
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
Attribute Features
Transformations:
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)
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.
Rewrite Keys
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
to_context Output
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.
π€ Contributing
Pull requests are welcome on GitHub.
π License
Released under the MIT License.