Using Steps in Actions
Steps let you compose a complex action by chaining smaller actions together. The steps share one accumulating context: each step is invoked with everything available so far (the parent's inputs plus whatever earlier steps exposed), and whatever it exposes is merged back in for the steps that follow.
Basic Concepts
What are Steps?
Steps are a way to organize action logic into smaller, focused pieces that:
- Execute sequentially, in the order declared
- Chain through a shared, accumulating context — a later step sees everything earlier steps exposed
- Are reusable: any existing Axn can be mounted as a step
- Propagate failures and exceptions to the parent with the right semantics (see Error Handling)
The shared context (and collisions)
The context is a shared blackboard, not isolated per-step state:
- A step receives the full accumulated context, regardless of what it declares via
expects(itsexpectsonly controls what it reads and validates). - A step's exposures are merged into the parent's context, visible to every later step.
- If two steps expose the same key, the later step overwrites the earlier value — silently. This is intentional (it's how chaining transforms a value through the pipeline), but name your exposures deliberately.
Defining the orchestrator
Declaring steps generates the action's #call — it is the orchestrator that runs the steps. So a steps-using class must not define its own #call; doing so raises an ArgumentError at load time. Use before/after hooks for setup or teardown around the steps.
Defining Steps
Using the step Method
The step method allows you to define steps inline with blocks:
class UserRegistration
include Axn
expects :email, :password, :name
exposes :user_id, :welcome_message
step :validate_input, expects: [:email, :password, :name], exposes: [:validated_data] do
# Validation logic
fail! "Email is invalid" unless email.match?(/\A[^@\s]+@[^@\s]+\z/)
fail! "Password too short" if password.length < 8
fail! "Name is required" if name.blank?
expose :validated_data, { email: email.downcase, password: password, name: name.strip }
end
step :create_user, expects: [:validated_data], exposes: [:user_id] do
user = User.create!(validated_data)
expose :user_id, user.id
end
step :send_welcome, expects: [:user_id, :validated_data], exposes: [:welcome_message] do
WelcomeMailer.send_welcome(user_id, validated_data[:email]).deliver_now
expose :welcome_message, "Welcome #{validated_data[:name]}!"
end
endNote there is no
def call— declaring steps generates it. Adding your own would raise.
Using the steps Method
The steps method allows you to compose existing action classes:
class ValidateInput
include Axn
expects :email, :password, :name
exposes :validated_data
def call
fail! "Email is invalid" unless email.match?(/\A[^@\s]+@[^@\s]+\z/)
fail! "Password too short" if password.length < 8
fail! "Name is required" if name.blank?
expose :validated_data, { email: email.downcase, password: password, name: name.strip }
end
end
class CreateUser
include Axn
expects :validated_data
exposes :user_id
def call
user = User.create!(validated_data)
expose :user_id, user.id
end
end
class SendWelcome
include Axn
expects :user_id, :validated_data
exposes :welcome_message
def call
WelcomeMailer.send_welcome(user_id, validated_data[:email]).deliver_now
expose :welcome_message, "Welcome #{validated_data[:name]}!"
end
end
class UserRegistration
include Axn
expects :email, :password, :name
exposes :user_id, :welcome_message
# Use existing action classes as steps
steps(ValidateInput, CreateUser, SendWelcome)
endMixed Approach
You can combine both approaches:
class UserRegistration
include Axn
expects :email, :password, :name
exposes :user_id, :welcome_message
# Use existing action for validation
steps(ValidateInput)
# Define custom step for user creation
step :create_user, expects: [:validated_data], exposes: [:user_id] do
user = User.create!(validated_data)
expose :user_id, user.id
end
# Use existing action for welcome email
steps(SendWelcome)
endData Flow Between Steps
Expecting Data
Steps can expect data from:
- Parent context: Data passed to the parent action
- Previous steps: Data exposed by earlier steps
step :step1, expects: [:input], exposes: [:processed_data] do
expose :processed_data, input.upcase
end
step :step2, expects: [:processed_data], exposes: [:final_result] do
# This step can access both 'input' (from parent) and 'processed_data' (from step1)
expose :final_result, "Result: #{processed_data}"
endExposing Data
Steps expose data using the expose method:
step :calculation, expects: [:base_value], exposes: [:doubled_value, :final_result] do
doubled = base_value * 2
expose :doubled_value, doubled
expose :final_result, doubled + 10
endUsing expose_return_as
For simple calculations, you can use expose_return_as:
step :calculation, expects: [:input], expose_return_as: :result do
input * 2 + 10 # Return value is automatically exposed as 'result'
endError Handling
Automatic Error Prefixing
When a step fails, error messages are automatically prefixed with the step name:
step :validation, expects: [:input] do
fail! "Input too short"
end
# If this step fails, the error message becomes: "validation: Input too short"Failure vs. exception propagation
A step propagates its outcome category to the parent — it does not flatten everything into a generic failure. This preserves Axn's distinction between a deliberate failure and an unexpected bug:
A step that calls fail! (a deliberate, expected failure):
- The parent settles as a failure: its
on_failureandon_errorcallbacks fire (on_exceptiondoes not). - The parent's
erroris the step's message, prefixed:"#{step_name}: #{step_error}"(and cascaded under the parent's baseerrorif it declares one). - Nothing is reported to the global
on_exceptionhandler — afail!is not a bug.
A step that raises an unexpected exception (a bug):
- The original exception is re-raised, so the parent settles as an exception: its
on_exceptionandon_errorcallbacks fire (on_failuredoes not). - The global
on_exceptionhandler fires exactly once, at the step (with the step's context). It is not reported again as it propagates. - The parent's
erroris its declared baseerror(or"Something went wrong") — exception internals are not surfaced into the caller-facing message, so the step-name prefix is not applied on this path. The full exception, including which step raised it, goes to the report.
In short: on_error is the catch-all at every level; on_failure means a deliberate fail!; on_exception means a real bug bubbled through.
step :validate do
fail! "Input too short" # → parent FAILS with "validate: Input too short"; no report
end
step :risky_operation do
raise SomeClient::Error, "..." # → parent settles as an EXCEPTION; reported once at the step
endRollback
There is no built-in rollback DSL. Because a failed/erroring step settles the parent as not-ok, wrap the orchestrator (or the relevant steps) in a database transaction to get all-or-nothing behavior — Axn defers on_success until the enclosing transaction commits, so committed side effects only fire if the whole chain succeeds.
Conditional Steps
Run a step only when a condition holds, using if: and/or unless::
step :charge_card, ChargeCard, if: -> { paid_plan }
step :send_invoice, SendInvoice, unless: :free_tier?
step :provision, Provision, if: :ready?, unless: :dry_run? # both must pass- A condition is a Proc (evaluated on the parent instance) or a Symbol naming a parent method — the same forms hooks accept.
if:andunless:may be combined; the step runs only ifif:is truthy andunless:is falsey.- A skipped step simply does not run: it exposes nothing and cannot fail. Later steps still run.
Conditions are evaluated on the parent, so they read data the same way the rest of the action does:
- Inputs — via the
expectsreader (-> { tier == "paid" }) orinputs(-> { inputs[:tier] == "paid" }). - A prior step's output — via
result.<field>(-> { result.flag }), exactly as insuccess/error/sensitive:procs. The parent must declare the field inexposes, and the earlier step's value is live in the context by the time the next step's condition runs.
exposes :eligible, allow_blank: true
step :check, CheckEligibility # exposes :eligible
step :enroll, Enroll, if: -> { result.eligible }A bare reference to an undeclared name (e.g. -> { flag } with no exposes :flag) raises NameError — exposes does not create bare instance readers; use result.flag.
Best Practices
1. Keep Steps Focused
Each step should have a single responsibility:
# ❌ Bad: Step does too many things
step :process_user, expects: [:user_data], exposes: [:user_id, :welcome_sent] do
user = User.create!(user_data)
WelcomeMailer.send_welcome(user.id).deliver_now
expose :user_id, user.id
expose :welcome_sent, true
end
# ✅ Good: Steps are focused
step :create_user, expects: [:user_data], exposes: [:user_id] do
user = User.create!(user_data)
expose :user_id, user.id
end
step :send_welcome, expects: [:user_id], exposes: [:welcome_sent] do
WelcomeMailer.send_welcome(user_id).deliver_now
expose :welcome_sent, true
end2. Use Descriptive Step Names
Step names should clearly indicate what the step does:
# ❌ Bad: Unclear names
step :step1, expects: [:input] do
# ...
end
# ✅ Good: Descriptive names
step :validate_email_format, expects: [:input] do
# ...
end3. Handle Failures Gracefully
Use fail! for expected failures and raise exceptions for unexpected errors:
step :validation, expects: [:input] do
# Expected failure - use fail!
fail! "Input too short" if input.length < 3
# Unexpected error - raise exception
raise StandardError, "Database connection failed" if database_unavailable?
end4. Expose Only Necessary Data
Only expose data that subsequent steps actually need:
# ❌ Bad: Exposing unnecessary data
step :validation, expects: [:input], exposes: [:input, :validated, :timestamp] do
expose :input, input
expose :validated, true
expose :timestamp, Time.current
end
# ✅ Good: Only exposing what's needed
step :validation, expects: [:input], exposes: [:validated_input] do
expose :validated_input, input.strip
endCommon Use Cases
API Request Processing
class ProcessAPIRequest
include Axn
expects :request_data
exposes :response_data
step :authenticate, expects: [:request_data], exposes: [:authenticated_user] do
# Authentication logic
expose :authenticated_user, authenticate_user(request_data[:token])
end
step :authorize, expects: [:authenticated_user, :request_data], exposes: [:authorized] do
# Authorization logic
fail! "Access denied" unless authorized_user?(authenticated_user, request_data[:action])
expose :authorized, true
end
step :process_request, expects: [:request_data, :authenticated_user], exposes: [:response_data] do
# Process the actual request
expose :response_data, process_user_request(request_data, authenticated_user)
end
endTroubleshooting
Common Issues
- Steps not executing: Ensure the Steps module is properly included
- Data not flowing: Check that step names match between
expectsandexposes - Error messages unclear: Verify step names are descriptive
Debugging Tips
- Use descriptive step names for better error messages
- Check that data is properly exposed between steps
- Verify that step dependencies are correctly specified
Summary
The steps functionality provides a powerful way to compose complex actions from smaller, focused pieces. By following the patterns and best practices outlined here, you can create maintainable, testable, and reusable action compositions.