Skip to content

Operations

Wiki Updater edited this page Jun 3, 2025 · 2 revisions

An Operation is a service object which is executed with a State.

Operations should not be named with the Operation suffix; name them what they do!

class CreateCharge < ApplicationOperation
  state_reader :order
  state_reader :user
  state_reader :payment_method

  state_accessor :charge

  def behavior
    return if charge.present?

    state.charge = Charge.create!(payment_method: payment_method, order: order, user: user)
  end

  private

  def payment_method
    payment_method.present? payment_method : user.default_payment_method
  end
end
class SubmitCharge < ApplicationOperation
  failure :charge_unsuccessful

  state_reader :charge
  state_writer :response

  def behavior
    charge_unsuccessful_failure!(response_body: response.body) unless success?

    charge.update!(success: true)

    state.response = response
  end

  private

  def success?
    response.body.success == "true"
  end

  def response
    PaymentProcessorClient.submit_charge(charge)
  end
  memoize :response
end

Operations take a state as input and define a #behavior that occurs when #execute is called.

💁‍ Pro Tip: Operations are just objects! They can be used outside of Flows. Just give them a State (or a State-like object) and you can use them in isolation!

class ExampleOperation < ApplicationOperation
  state_reader :first_name

  def behavior
    puts "Hello, #{first_name}"
  end
end

operation = ExampleOperation.new(OpenStruct.new(first_name: "Eric"))
operation.execute
# Hello, Eric
operation.executed? # => true

Accessors

Operations have access to an wide array of helpful methods.

Inputs and Outputs

Operations can access state data with the state method, which returns the State of the flow.

class ChargeFlow < ApplicationFlow
  operations CreateCharge,
             SubmitCharge
end

class CreateCharge < ApplicationOperation
  def execute
    charge = Charge.create!(user: state.user, amount: state.order.total)
    state.output.charge = charge
  end
end

State

⚠️ Note: Direct state access in Operations should be used carefully and is generally discouraged in favor of explicit input/output handling.

Operations can access:

  • state.input - Input data passed to the flow
  • state.output - Output data from previous operations
  • state - Direct state access (use sparingly)

Best Practices

  • Prefer explicit input/output over direct state access
  • Keep operations focused on a single responsibility
  • Use descriptive operation names that express intent
  • Handle errors gracefully within operations

Accessors

Operations define, through State Accessors, what data they will read from and/or write to a State.

These accessors provide an explicit means to declare input needs and output expectations.

class ExampleOperation < ApplicationOperation
  state_reader :foo
  state_writer :bar
  state_accessor :baz
end

Under the hood, a StateProxy adapts a State to an Operation using these accessors.

⚠️ Warning: Your Operation will not be able to access methods on your State if you do not declare them using these accessors!

The following are considered best practices for working with state accessors:

state_reader should only read data and not alter it

# Bad, don't do it this way:
class BadOperation < ApplicationOperation
  state_reader :foo

  def behavior
    foo << :more_dataz
  end
end

# Good, do it this way:
class GoodOperation < ApplicationOperation
  state_reader :foo

  def behavior
    SomeThirdParty.do_a_thing if foo == :foo
  end
end

state_writer should map to a field marked as output on the State

⚠️ Warning: You should use state.foo when defining state_writer :foo. Ruby has no way of distinguishing a function scope argument creation from a class scope assignment so it needs to have the prefix

class ExampleOperation < ApplicationOperation
  state_writer :foo

  def behavior
    state.foo = :foo
  end
end

# Bad, don't do it this way:
class BadState < ApplicationState
  option :foo
end

# Good, do it this way:
class GoodState < ApplicationState
  output :foo
end

state_accessor should be used rather than defining both setters and getters:

# Bad, don't do it this way:
class BadOperation < ApplicationOperation
  state_reader :foo
  state_writer :foo

  def behavior
    state.foo = :bar if foo == :baz
  end
end

# Good, do it this way:
class GoodOperation < ApplicationOperation
  state_accessor :foo

  def behavior
    state.foo = :bar if foo == :baz
  end
end

state_accessor should also be used when altering a memory object (ex: Array, Hash, ActiveModel):

# Bad, don't do it this way:
class BadOperation < ApplicationOperation
  state_reader :foo

  def behavior
    foo << :more_dataz
  end
end

# Good, do it this way:
class GoodOperation < ApplicationOperation
  state_accessor :foo

  def behavior
    foo << :more_dataz
  end
end

Next: States

Clone this wiki locally