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::RecordInvalidis NOT rescued automatically. Always rescue when usingcreate!/update!/save!, returnfailed(e.record.errors).- Return
succeed(...)orfailed(...)fromexecute: the controller can't tell what happened otherwise. Returning anything else raises. - Redirect is automatic on success: only use
with_redirect_responsefor 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 owns | An 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, redirect | The 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:
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.
# app/models/post.rb
class Post < ApplicationRecord
def publish!(on: Time.current)
update!(published: true, published_at: on)
end
end# 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)
endName 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 β
# π« 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.
# β
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!")
endChaining 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 β
# 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
endNote 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:
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, :datetimeThe 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.
input :email # auto: :email type from name match
input :role, as: :select, choices: %w[admin user]
input :content, as: :textSee Resource βΊ Definition for all as: types, options, and dynamic blocks.
Presentation β
presents label: "Archive Record",
icon: Phlex::TablerIcons::Archive,
description: "Move to archive"Access:
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 β
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 β
failed("Something went wrong")
failed(resource.errors)
failed(email: "is invalid", name: "is required") # hash form
failed("Invalid value", :email) # string + attributeManual error addition β
def execute
errors.add(:base, "Post must have content")
return failure if errors.any?
# β¦continue
endChaining β
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.
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)
endDon'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.
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?
endWhich validation goes where β
Interactions have validations and so do models, and they are not competing: they answer different questions:
| Interaction validation | Model 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 to | render a form error next to the field | protect the data from every caller, including the ones with no form |
| Runs | before execute, without ever touching the model | inside 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)flattensActiveModel::Errorsinto full messages on:base(Array(errors)callserrors.to_a, which isfull_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):
def execute
resource.update!(updated_by: current_user)
succeed(resource)
endThis 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 pattern | Action type | Where it shows up |
|---|---|---|
attribute :resource | Record action | Show page + per-row in table |
attribute :resources (plural) | Bulk action | Bulk toolbar above table |
| neither | Resource action | Index page header |
Record action β
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
endBulk action β
class BulkArchiveInteraction < Plutonium::Resource::Interaction
attribute :resources
def execute
resources.update_all(archived: true)
succeed(resources).with_message("Archived #{resources.size} records")
end
endupdate_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) β
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
endCalling 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 β
outcome = PublishPost.call(view_context: view_context, resource: post)
if outcome.success?
# β¦
else
# β¦
endInstance method β
interaction = PublishPost.new(view_context: view_context, resource: post)
outcome = interaction.callThe 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 shape | Behavior |
|---|---|
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 declared | Form: 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::
# 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.
# 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# 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
endTesting β
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
endSee Testing for Plutonium's built-in testing helpers. The ResourceInteraction concern wraps these patterns.
Related β
- Async Interactions:
asynca persisted run instead of runningexecuteinline - 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
