Skip to content

InteractorSupport extends the Interactor pattern to make your business logic more concise, expressive, and robust.

License

Notifications You must be signed in to change notification settings

charliemitchell/interactor_support

Repository files navigation

InteractorSupport πŸš€

Make your Rails interactors clean, powerful, and less error-prone!

Coverage Status
Ruby
Codacy Badge


πŸš€ 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

  1. Define a Request Object
class GenreRequest
  include InteractorSupport::RequestObject

  attribute :title, transform: :strip
  attribute :description, transform: :strip

  validates :title, :description, presence: true
end
  1. 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.

InteractorSupport::Organizable

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.

API Reference

#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) })

#request_params(*top_level_keys, merge: {}, except: [], rewrite: [])

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
}

⚠️ Array flattening is not supported

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

🀝 Contributing

Pull requests are welcome on GitHub.


πŸ“œ License

Released under the MIT License.

About

InteractorSupport extends the Interactor pattern to make your business logic more concise, expressive, and robust.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published