Skip to content

Interaction ​

The entry point from a Plutonium page into an operation. An interaction declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash message and a redirect. Built on ActiveModel attributes + validations.

🚨 Critical ​

  • ActiveRecord::RecordInvalid is NOT rescued automatically. Always rescue when using create! / update! / save!, return failed(e.record.errors).
  • Return succeed(...) or failed(...) from execute β€” the controller can't tell what happened otherwise. Returning anything else raises.
  • Redirect is automatic on success β€” only use with_redirect_response for a different destination.
  • Bulk actions use attribute :resources (plural). Policy authorization is checked per record β€” if any fails, the whole request fails.
  • The shape of the action (record / bulk / resource) is inferred from the interaction's attributes. See Resource β€Ί Actions.
  • An interaction is a presentation object. Logic may start in execute; the second caller β€” a job, an API controller, a rake task, the console β€” is the signal to move it to the model. See below.

What an interaction is for ​

An interaction is a presentation object. It exists so Plutonium can render a button, check a policy, bind a form, and turn the result into a message and a redirect. That is the whole job:

An interaction ownsAn interaction does not own
The button β€” presents label: / icon:Who may click it. That's the policy.
The form β€” attribute + input declarationsβ€”
Input shape validation: present? parses? right type?Business invariants β€” they must hold for every caller, so they belong on the model
The user-facing outcome β€” succeed / failed, messages, redirectThe domain operation itself, once more than one caller needs it

Logic may start in execute ​

A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line update! β€” that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a refactoring trigger, not a prohibition.

The second caller is the trigger to extract ​

The moment a background job, an API controller, a rake task, the console, or another interaction needs the same behaviour, move it to the model.

The deadline is the second caller β€” and not "as soon as it looks like business logic" β€” because of one line in the base class:

ruby
def initialize(view_context:, **attributes)

view_context: is required. So a caller that isn't a Plutonium page has exactly two options: duplicate the logic, or manufacture a view_context it has no business owning. view_context is the tell. If reaching some behaviour would force a caller to conjure one, that behaviour is on the wrong side of the boundary.

The destination is the model ​

Rails convention: fat models. Give the operation a name in domain language and hang it off the record.

ruby
# app/models/post.rb
class Post < ApplicationRecord
  def publish!(on: Time.current)
    update!(published: true, published_at: on)
  end
end
ruby
# the interaction presents it
def execute
  resource.publish!(on: publish_date)
  succeed(resource).with_message("Post published!")
rescue ActiveRecord::RecordInvalid => e
  failed(e.record.errors)
end

Name it for the domain (publish!, archive!, register!), not for the persistence (update_published_at) β€” the point is that a scheduler job can now call post.publish! and read as if it meant it. And resist inventing a PublishPostService: the model is the destination, not a new layer.

Worked counter-example β€” chained interactions ​

ruby
# 🚫 Every link demands a view_context that has nothing to do with the work
CreateUserInteraction.call(view_context:, **user_params)
  .and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
  .and_then { |user| LogActivity.call(view_context:, user:) }

Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do β€” none of which has a view_context. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.

ruby
# βœ… The model owns registering a user; the interaction presents it
def execute
  user = User.register!(**attributes)   # welcome email + audit row live in here
  succeed(user).with_message("Welcome aboard!")
end

Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. and_then is real API and stays documented below β€” just don't reach for it to sequence business operations.

Structure ​

ruby
# app/interactions/resource_interaction.rb β€” installed once
class ResourceInteraction < Plutonium::Resource::Interaction
end

# A real interaction
class PublishPostInteraction < ResourceInteraction
  presents label: "Publish",
           icon: Phlex::TablerIcons::Send,
           description: "Make this post public"

  attribute :resource
  attribute :publish_date, :datetime, default: -> { Time.current }

  input :publish_date

  validates :publish_date, presence: true

  private

  def execute
    resource.publish!(on: publish_date)   # Post#publish! β€” see above
    succeed(resource).with_message("Post published!")
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end
end

Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. Post#publish! decides what publishing a post means β€” so the scheduled-publishing job can call it too.

Attributes ​

ActiveModel-style:

ruby
attribute :resource                                  # single record (record action)
attribute :resources                                 # array of records (bulk action)
attribute :email, :string
attribute :count, :integer, default: 1
attribute :active, :boolean, default: -> { true }    # callable default
attribute :tags, :array
attribute :metadata, :hash
attribute :date, :datetime

The presence of :resource / :resources / neither determines the action type β€” see Resource β€Ί Actions β€Ί Inferred visibility.

Inputs ​

Same DSL as definition input. Auto-detection from the attribute type applies β€” declare as: only when overriding.

ruby
input :email                          # auto: :email type from name match
input :role, as: :select, choices: %w[admin user]
input :content, as: :text

See Resource β€Ί Definition for all as: types, options, and dynamic blocks.

Presentation ​

ruby
presents label: "Archive Record",
         icon:  Phlex::TablerIcons::Archive,
         description: "Move to archive"

Access:

ruby
MyInteraction.label        # => "Archive Record"
MyInteraction.icon         # => Phlex::TablerIcons::Archive
MyInteraction.description  # => "Move to archive"

If action :foo, interaction: FooInteraction doesn't override label: / icon: etc., these presents values are used.

execute β€” outcomes ​

execute MUST return a succeed(...) or failed(...) outcome. Validations run automatically before execute; if they fail, the interaction short-circuits to failed().

Success ​

ruby
succeed(resource)                                       # auto-redirect to resource
succeed(resource).with_message("Done!")
succeed(resource).with_message("Heads up!", :alert)
succeed(resource).with_redirect_response(custom_path)   # different destination
succeed(resource).with_file_response(path, filename: "report.pdf")
succeed(resource).with_render_response(:custom_template)

Failure ​

ruby
failed("Something went wrong")
failed(resource.errors)
failed(email: "is invalid", name: "is required")  # hash form
failed("Invalid value", :email)                   # string + attribute

Manual error addition ​

ruby
def execute
  errors.add(:base, "Post must have content")
  return failure if errors.any?

  # …continue
end

Chaining ​

and_then composes Outcomes. On a Success it yields the value (not the outcome) and returns whatever the block returns; on a Failure it short-circuits, returning the failure untouched.

ruby
def execute
  unlocked_resource.and_then do |post|
    post.publish!(on: publish_date)
    succeed(post).with_message("Post published!")
  end
end

private

# a guard expressed as an Outcome, so the failure carries its own message
def unlocked_resource
  resource.locked? ? failed("This post is locked") : succeed(resource)
end

Don't use and_then to sequence business operations

A chain of three interactions is a chain of three things that each demand a view_context, none of which a job or an API controller can supply. That's one model method wearing three costumes β€” see Worked counter-example. and_then earns its keep composing outcomes within one interaction, or in a test.

Validations ​

Standard ActiveModel. Run automatically before execute; if they fail, execute never runs.

ruby
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, inclusion: {in: %w[admin user guest]}

validate :custom_check

private

def custom_check
  errors.add(:resource, "cannot be modified when archived") if resource.archived?
end

Which validation goes where ​

Interactions have validations and so do models, and they are not competing β€” they answer different questions:

Interaction validationModel validation
Asks"Can I read this input?" β€” present, parses, right type, plausible format"Is this record legal?" β€” invariants that hold no matter who is calling
Exists torender a form error next to the fieldprotect the data from every caller, including the ones with no form
Runsbefore execute, without ever touching the modelinside save! / update! β€” i.e. inside your model method

Both surface to the user, but not identically, and the difference should inform where you put a rule:

  • An interaction validation attaches to a declared attribute. The re-rendered modal shows it inline against that input, and again in the summary at the top of the form.
  • failed(record.errors) flattens ActiveModel::Errors into full messages on :base (Array(errors) calls errors.to_a, which is full_messages). Those land in the form's error summary only β€” never against a field β€” and they're phrased with the model's attribute names, which need not match your inputs.

So it is fine, and often right, to duplicate a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: the moment a job calls post.publish!, the interaction's validations are not in the picture at all.

Accessing context ​

current_user is provided by the base class (view_context.controller.helpers.current_user):

ruby
def execute
  resource.update!(updated_by: current_user)
  succeed(resource)
end

This one is correctly inline. "Who clicked the button" is context the presentation layer holds and nothing else does β€” current_user is read straight off the view_context. A job has no answer for it, so there is no second caller to extract for.

Interaction types ​

Attribute patternAction typeWhere it shows up
attribute :resourceRecord actionShow page + per-row in table
attribute :resources (plural)Bulk actionBulk toolbar above table
neitherResource actionIndex page header

Record action ​

ruby
class ArchiveInteraction < Plutonium::Resource::Interaction
  attribute :resource

  def execute
    resource.archive!
    succeed(resource).with_message("Archived")
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end
end

Bulk action ​

ruby
class BulkArchiveInteraction < Plutonium::Resource::Interaction
  attribute :resources

  def execute
    resources.update_all(archived: true)
    succeed(resources).with_message("Archived #{resources.size} records")
  end
end

update_all stays inline on purpose: it's a single-statement SQL update whose whole point is skipping per-record model machinery. If archiving means more than setting a column β€” callbacks, an audit row, a webhook β€” this is the wrong shape; call resources.each(&:archive!) and let the model own it.

Per-record authorization details in Resource β€Ί Actions β€Ί Bulk action.

Resource action (no record) ​

ruby
class ImportInteraction < Plutonium::Resource::Interaction
  attribute :file
  input :file, as: :file
  validates :file, presence: true

  def execute
    # …import logic
    succeed(nil).with_message("Import completed.")
  end
end

Calling interactions directly ​

The controller handles this for interactive actions. You can also call one by hand β€” chiefly in tests, where you're exercising the interaction itself.

Needing this in a job or a rake task is the signal to refactor

Both entry points require view_context:, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction β€” you want the model method it wraps. See What an interaction is for.

Class method ​

ruby
outcome = PublishPost.call(view_context: view_context, resource: post)

if outcome.success?
  # …
else
  # …
end

Instance method ​

ruby
interaction = PublishPost.new(view_context: view_context, resource: post)
outcome = interaction.call

The view_context: argument is required β€” interactions use it to access controller helpers and the current user. It is also the boundary marker: everything reachable only through an interaction is reachable only from a page.

Immediate vs form ​

Interaction shapeBehavior
Only :resource / :resources (no extra attribute or input)Immediate β€” browser confirmation ("#{label}?", e.g. "Archive?"), then runs. Override with confirmation: "Custom" or confirmation: false on the action.
Additional attribute / input declaredForm β€” renders modal form first; no auto-confirmation.

See Resource β€Ί Actions β€Ί Immediate vs form.

Generating interaction URLs ​

resource_url_for with the interaction: kwarg. The action type (record / bulk / resource) is inferred from the element and the presence of ids::

ruby
# Record action β€” instance argument
resource_url_for(@post, interaction: :publish)
# => /posts/:id/record_actions/publish

# Resource action β€” class, no ids
resource_url_for(Post, interaction: :import)
# => /posts/resource_actions/import

# Bulk action β€” class + ids
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
# => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3

# Composes with parent / entity scoping
resource_url_for(@post, parent: @user, interaction: :publish)

The same URL serves GET (form/confirmation) and POST (commit) β€” the HTTP verb routes to the right controller action. Passing both interaction: and action: raises ArgumentError.

Complete example ​

Inviting a user is a textbook second-caller case β€” a seats-provisioning job, an admin rake task and a signup API all need to send the same invitation. So the operation lives on Company, and the interaction is the button in front of it.

ruby
# app/models/company.rb
class Company < ApplicationRecord
  has_many :user_invites

  # Everything inviting means: the row, the mail, the audit trail.
  def invite!(email:, role:, by:)
    user_invites.create!(email: email, role: role, invited_by: by).tap do |invite|
      UserInviteMailer.invitation(invite).deliver_later
    end
  end

  def pending_invite_for?(email) = user_invites.exists?(email: email, state: :pending)
end
ruby
# app/interactions/company/invite_user_interaction.rb
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
  presents label: "Invite User",
           icon:  Phlex::TablerIcons::UserPlus

  attribute :resource         # the company
  attribute :email, :string
  attribute :role, :string

  input :email
  input :role, as: :select, choices: -> { UserInvite.roles.keys }

  # Input shape β€” is this a readable email, is this a role that exists?
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
  validates :role,  presence: true, inclusion: {in: UserInvite.roles.keys}
  validate  :not_already_invited

  private

  def execute
    resource.invite!(email: email, role: role, by: current_user)
    succeed(resource).with_message("Invitation sent to #{email}")
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end

  # Deliberately duplicated. `UserInvite` enforces uniqueness for real (a job
  # calling `company.invite!` must hit it too); this copy exists only so the
  # message lands on the :email field instead of in the base error summary.
  def not_already_invited
    return if email.blank?
    errors.add(:email, "already has a pending invitation") if resource.pending_invite_for?(email)
  end
end

Testing ​

ruby
RSpec.describe PublishPost do
  let(:view_context) { double("view_context", controller: double(helpers: double(current_user: user))) }
  let(:user) { create(:user) }
  let(:post) { create(:post, user: user, published: false) }

  it "publishes the post" do
    outcome = described_class.call(view_context: view_context, resource: post)

    expect(outcome).to be_success
    expect(post.reload).to be_published
  end
end

See Testing for Plutonium's built-in testing helpers β€” ResourceInteraction concern wraps these patterns.

  • Async Interactions β€” async a persisted run instead of running execute inline
  • Resource β€Ί Actions β€” registering interactions, inferred visibility, immediate vs form
  • Policies β€” def <action>? authorization methods
  • Controllers β€” resource_url_for(..., interaction: …) URL generation
  • UI β€Ί Forms β€” customizing the modal form rendered for actions with inputs

Released under the MIT License.