Skip to content

How to build an Action

The core boilerplate is pretty minimal:

ruby
class Foo
  include Axn

  def call
    # ... do some stuff here?
  end
end

Declare the interface

The first step is to determine what arguments you expect to be passed into call. These are declared via the expects keyword.

If you want to expose any results to the caller, declare that via the exposes keyword.

Both of these optionally accept type:, optional:, allow_nil:, allow_blank:, and any other ActiveModel validation (see: reference).

ruby
class Foo
  include Axn

  expects :name, type: String
  expects :email, type: String, optional: true
  exposes :meaning_of_life

  def call
    # ... do some stuff here?
  end
end

Implement the action

Once the interface is defined, you're primarily focused on defining the call method.

To abort execution with a specific error message, call fail!. You can also provide exposures as keyword arguments.

To complete execution early with a success result, call done! with an optional success message and exposures as keyword arguments.

If you declare that your action exposes anything, you need to actually expose it.

ruby
class Foo
  include Axn

  expects :name, type: String
  exposes :meaning_of_life

  def call
    fail! "Douglas already knows the meaning" if name == "Doug"

    msg = "Hello #{name}, the meaning of life is 42"
    expose meaning_of_life: msg 
  end
end

See the reference doc for a few more handy helper methods (e.g. #log).

Convenient failure with context

Both fail! and done! can accept keyword arguments to expose data before halting execution:

ruby
class UserValidator
  include Axn

  expects :email
  exposes :error_code, :field

  def call
    if email.blank?
      fail!("Email is required", error_code: 422, field: "email")
    end

    # ... validation logic
  end
end

Early completion with done!

The done! method allows you to complete an action early with a success result, bypassing the rest of the execution:

ruby
class UserLookup
  include Axn

  expects :user_id
  exposes :user, :cached

  def call
    # Check cache first
    cached_user = Rails.cache.read("user:#{user_id}")
    if cached_user
      done!("User found in cache", user: cached_user, cached: true) # Early completion with exposures
    end

    # This won't execute if done! was called above
    user = User.find(user_id)
    expose user: user, cached: false
  end
end

Important behavior notes

Hook execution:

  • done! skips any after hooks (or call method if called from a before hook)
  • around hooks will complete normally, allowing transactions and tracing to finish properly
  • If you want code that executes on both normal AND early success, use an on_success callback instead of an after hook

Transaction handling:

  • done! is implemented internally via an exception, so it will roll back manually applied ActiveRecord::Base.transaction blocks
  • Use the use :transaction strategy instead - transactions applied via this strategy will NOT be rolled back by done!
  • This ensures database consistency while allowing early completion

Validation:

  • Outbound validation (required exposes) still runs even with early completion
  • If required fields are not provided, the action will fail despite the early completion
ruby
class BadExample
  include Axn

  expects :user_id
  exposes :user  # Required field

  def call
    done!("Early completion") # This will FAIL - user not exposed
  end
end

BadExample.call(user_id: 123).ok? # => false
BadExample.call(user_id: 123).exception # => Axn::OutboundValidationError

Customizing messages

The default error and success message strings ("Something went wrong" / "Action completed successfully", respectively) are technically safe to show users, but you'll often want to set them to something more useful.

There are success and error declarations for that -- you can set strings (most common) or a callable (note for the error case, if you give it a callable that expects a single argument, the exception that was raised will be passed in).

For instance, configuring the action like this:

ruby
class Foo
  include Axn

  expects :name, type: String
  exposes :meaning_of_life

  success { "Revealed to #{name}: #{result.meaning_of_life}" } 
  error { |e| "No secret of life for you: #{e.message}" }

  def call
    fail! "Douglas already knows the meaning" if name == "Doug"

    msg = "Hello #{name}, the meaning of life is 42"
    expose meaning_of_life: msg
  end
end

Would give us these outputs:

ruby
Foo.call.error # => "No secret of life for you: Name can't be blank"
Foo.call(name: "Doug").error # => "Douglas already knows the meaning"
Foo.call(name: "Adams").success # => "Revealed the secret of life to Adams"
Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life is 42"

Advanced Error Message Configuration

You can also use conditional error messages with the prefix: keyword and combine them with the from: parameter for nested actions:

ruby
class ValidationAction
  include Axn

  expects :input

  error if: ArgumentError, prefix: "Validation Error: " do |e|
    "Invalid input: #{e.message}"
  end

  error if: StandardError, prefix: "System Error: "

  def call
    raise ArgumentError, "input too short" if input.length < 3
    raise StandardError, "unexpected error" if input == "error"
  end
end

class ApiAction
  include Axn

  expects :data

  # Combine prefix with from for consistent error formatting
  error from: ValidationAction, prefix: "API Error: " do |e|
    "Request validation failed: #{e.message}"
  end

  # Or use prefix only (falls back to exception message)
  error from: ValidationAction, prefix: "API Error: "

  def call
    ValidationAction.call!(input: data)
  end
end

This configuration provides:

  • Consistent error message formatting with prefixes
  • Automatic fallback to exception messages when no custom message is provided
  • Proper error message inheritance from nested actions

Message Ordering

Important: When using conditional messages, always define your static fallback messages first in your class, before any conditional messages. This ensures proper fallback behavior.

Correct order:

ruby
class Foo
  include Axn

  # Static fallback messages first
  success "Default success message"
  error "Default error message"

  # Then conditional messages
  success "Special success", if: :special_condition?
  error "Special error", if: ArgumentError
end

Lifecycle methods

In addition to #call, there are a few additional pieces to be aware of:

Hooks

before, after, and around hooks are supported. They can receive a block directly, or the symbol name of a local method.

Note execution is halted whenever fail! is called, done! is called, or an exception is raised (so a before block failure won't execute call or after, while an after block failure will make result.ok? be false even though call completed successfully). The done! method specifically skips after hooks and any remaining call method execution, but allows around hooks to complete normally.

For instance, given this configuration:

ruby
class Foo
  include Axn

  before { log("before hook") } 
  after :log_after

  def call
    log("in call")
  end

  private

  def log_after
    log("after hook")
    raise "oh no something borked"
    log("after after hook raised")
  end
end

Foo.call would fail (because of the raise), but along the way would end up logging:

text
before hook
in call
after hook

Hook Ordering with Inheritance:

  • Around hooks: Parent wraps child (parent outside, child inside)
  • Before hooks: Parent → Child (general setup first, then specific)
  • After hooks: Child → Parent (specific cleanup first, then general)

This follows the natural pattern of setup (general → specific) and teardown (specific → general).

Callbacks

A number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails. See the Class Interface docs for details.

Strategies

A number of Strategies, which are DRYed bits of commonly-used configuration, are available for your use as well.