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, 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. The ResourceInteraction concern wraps these patterns.

Released under the MIT License.