Skip to content

Class Methods

.expects and .exposes

Actions have a declarative interface, whereby you explicitly declare both inbound and outbound arguments. Specifically, variables you expect to receive are specified via expects, and variables you intend to expose are specified via exposes.

Both expects and exposes support the same core options:

OptionExample (same for exposes)Meaning
sensitiveexpects :password, sensitive: trueFilters the field's value when logging, reporting errors, or calling inspect
defaultexpects :foo, default: 123If foo is missing or explicitly nil, it'll default to this value (not applied for blank values)
optionalexpects :foo, optional: trueRecommended: Don't fail if the value is missing, nil, or blank. Equivalent to allow_blank: true
allow_nilexpects :foo, allow_nil: trueDon't fail if the value is nil (but will fail for blank strings)
allow_blankexpects :foo, allow_blank: trueDon't fail if the value is blank (nil, empty string, whitespace, etc.)
typeexpects :foo, type: StringCustom type validation -- fail unless name.is_a?(String)
anything elseexpects :foo, inclusion: { in: [:apple, :peach] }Any other arguments will be processed as ActiveModel validations (i.e. as if passed to validates :foo, <...> on an ActiveRecord model)

Validation details

WARNING

While we support complex interface validations, in practice you usually just want a type, if anything. Remember this is your validation about how the action is called, not pretty user-facing errors (there's a different pattern for that).

In addition to the standard ActiveModel validations, we also support four additional custom validators:

  • type: Foo - fails unless the provided value .is_a?(Foo)
    • Edge case: use type: :boolean to handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)
    • Edge case: use type: :uuid to handle a confirming given string is a UUID (with or without - chars)
    • Edge case: use type: :params to accept either a Hash or ActionController::Parameters (Rails-compatible)
  • validate: [callable] - Support custom validations (fails if any string is returned OR if it raises an exception)
    • Example:
      ruby
      expects :foo, validate: ->(value) { "must be pretty big" unless value > 10 }
  • model: true (or model: TheModelClass or model: { klass: TheModelClass, finder: :find }) - allows auto-hydrating a record when only given its ID
    • Example:

      ruby
      expects :user, model: true
      # or
      expects :user, model: User
      # or with custom finder
      expects :user, model: { klass: User, finder: :find }

      This line will add expectations that:

      • user_id is provided (automatically derived from field name)
      • User.find(user_id) (or custom finder) returns a record

      And, when used on expects, will create a reader method for you:

      • user (the auto-found record)

      NOTES

      • The system automatically looks for #{field}_id (e.g., :user:user_id)
      • The klass option defaults to the field name classified (e.g., :userUser)
      • The finder option defaults to :find but can be any method that takes an ID directly
      • This works with any class that has a finder method (e.g., User.find, ApiService.find_by_id, etc.)
      • For external APIs, you can pass a Method object as the finder

How optional, allow_blank and allow_nil work with validators

When you specify optional: true, allow_blank: true, or allow_nil: true on a field, these options are automatically passed through to all validators applied to that field. This means:

  • ActiveModel validations (like inclusion, length, etc.) will respect these options
  • Custom validators (type, validate, model) will also respect these options
  • Type validator edge case: Note passing allow_blank is nonsensical for type: :params and type: :boolean

Recommended approach: Use optional: true instead of allow_blank: true for better clarity. The optional parameter is equivalent to allow_blank: true and makes the intent clearer.

If neither optional, allow_blank nor allow_nil is specified, a default presence validation is automatically added (unless the type is :boolean or :params, which have their own validation logic as described above).

Details specific to .exposes

Remember that you'll need a corresponding expose call for every variable you declare via exposes.

Details specific to .expects

Nested/Subfield expectations

expects is for defining the inbound interface. Usually it's enough to declare the top-level fields you receive, but sometimes you want to make expectations about the shape of that data, and/or to define easy accessor methods for deeply nested fields. expects supports the on option for this (all the normal attributes can be applied as well):

ruby
class Foo
  expects :event
  expects :data, type: Hash, on: :event
  expects :some, :random, :fields, on: :data
  expects :optional_field, on: :data, default: "default value"

  def call
    puts "THe event.data.random field's value is: #{random}"
  end
end

Subfield Defaults

Defaults work the same way for subfields as they do for top-level fields - they are applied when the subfield is missing or explicitly nil, but not for blank values.

Disabling subfield readers

By default, subfields create top-level reader methods (e.g., random in the example above). You can disable this with readers: false:

ruby
expects :data, type: Hash, on: :event, readers: false

This is useful when you have duplicate sub-keys across different parent fields, or when you want to access subfields only through the parent. Note that readers: false is only valid for subfields (i.e., when using on:) — using it on top-level fields will raise an ArgumentError.

preprocess

expects also supports a preprocess option that, if set to a callable, will be executed before applying any defaults or validations. This can be useful for type coercion, e.g.:

ruby
expects :date, type: Date, preprocess: ->(d) { d.is_a?(Date) ? d : Date.parse(d) }

will succeed if given either an actual Date object or a string that Date.parse can convert into one. If the preprocess callable raises an exception, that'll be swallowed and the action failed.

.success and .error

The success and error declarations allow you to customize the error and success messages on the returned result.

Both methods accept a string (returned directly), a symbol (resolved as a local instance method on the action), or a block (evaluated in the action's context, so can access instance methods and variables).

When an exception is available (e.g., during error), handlers can receive it in either of two equivalent ways:

  • Keyword form: accept exception: and it will be passed as a keyword
  • Positional form: if the handler accepts a single positional argument, it will be passed positionally

This applies to both blocks and symbol-backed instance methods. Choose the style that best fits your codebase (clarity vs concision).

In callables and symbol-backed methods, you can access:

  • Input data: Use field names directly (e.g., name)
  • Output data: Use result.field pattern (e.g., result.greeting)
  • Instance methods and variables: Direct access
ruby
success { "Hello #{name}, your greeting: #{result.greeting}" }
error { |e| "Bad news: #{e.message}" }
error { |exception:| "Bad news: #{exception.message}" }

# Using symbol method names
success :build_success_message
error :build_error_message

def build_success_message
  "Hello #{name}, your greeting: #{result.greeting}"
end

def build_error_message(e)
  "Bad news: #{e.message}"
end

def build_error_message(exception:)
  "Bad news: #{exception.message}"
end

Message Matching Order

Important: Understanding Handler Evaluation Order

Message handlers are stored in last-defined-first order and evaluated in that order until a match is found. This has critical implications for how you structure your message declarations.

How It Works

  1. Handlers are registered in reverse definition order (last defined = first evaluated)
  2. The system evaluates handlers one by one until finding one that matches
  3. Static handlers (no if: or unless: condition) always match
  4. Once a match is found, evaluation stops

Correct Pattern: Static Fallbacks First

Because static handlers always match, they must be defined first (so they're evaluated last):

ruby
class MyAction
  include Axn

  # 1. Define static fallback FIRST (evaluated last, catches anything unmatched)
  error "Something went wrong"

  # 2. Define conditional handlers AFTER (evaluated first, catch specific cases)
  error "Invalid input provided", if: ArgumentError
  error "Record not found", if: ActiveRecord::RecordNotFound
end

Incorrect Pattern: Conditional Messages Shadowed

If you define static handlers last, they match first and conditional handlers are never reached:

ruby
class MyAction
  include Axn

  # These will NEVER be reached!
  error "Invalid input provided", if: ArgumentError
  error "Record not found", if: ActiveRecord::RecordNotFound

  # This static handler is evaluated FIRST and always matches
  error "Something went wrong"
end

With Inheritance

Child class handlers are evaluated before parent class handlers:

ruby
class ParentAction
  include Axn
  error "Parent error"  # Evaluated last
end

class ChildAction < ParentAction
  error "Child error"   # Evaluated first
end

Conditional messages

While .error and .success set the default messages, you can register conditional messages using an optional if: or unless: matcher. The matcher can be:

  • an exception class (e.g., ArgumentError)
  • a class name string (e.g., "Axn::InboundValidationError")
  • a symbol referencing a local instance method predicate (arity 0 or 1, or keyword exception:), e.g. :bad_input?
  • a callable (arity 0 or 1, or keyword exception:)

Symbols are resolved as methods on the action instance. If the method accepts exception: it will be passed as a keyword; otherwise, if it accepts one positional argument, the raised exception is passed positionally; otherwise it is called with no arguments. If the action does not respond to the symbol, we fall back to constant lookup (e.g., if: :ArgumentError behaves like if: ArgumentError). Symbols are also supported for the message itself (e.g., success :method_name), resolved via the same rules.

ruby
error "bad"

# Custom message with exception class matcher
error "Invalid params provided", if: ActiveRecord::InvalidRecord

# Custom message with callable matcher and message
error(if: ArgumentError) { |e| "Argument error: #{e.message}" }
error(if: -> { name == "bad" }) { "Bad input #{name}, result: #{result.status}" }

# Custom message with prefix (falls back to exception message when no block/message provided)
error(if: ArgumentError, prefix: "Foo: ") { "bar" }  # Results in "Foo: bar"
error(if: StandardError, prefix: "Baz: ")            # Results in "Baz: [exception message]"

# Custom message with symbol predicate (arity 0)
error "Transient error, please retry", if: :transient_error?

def transient_error?
  # local decision based on inputs/outputs
  name == "temporary"
end

# Symbol predicate (arity 1), receives the exception
error(if: :argument_error?) { |e| "Bad argument: #{e.message}" }

def argument_error?(e)
  e.is_a?(ArgumentError)
end

# Symbol predicate (keyword), receives the exception via keyword
error(if: :argument_error_kw?) { |exception:| "Bad argument: #{exception.message}" }

def argument_error_kw?(exception:)
  exception.is_a?(ArgumentError)
end

# Lambda predicate with keyword
error "AE", if: ->(exception:) { exception.is_a?(ArgumentError) }

# Using unless: for inverse logic
error "Custom error", unless: :should_skip?

def should_skip?
  # local decision based on inputs/outputs
  name == "temporary"
end

::: warning
You cannot use both `if:` and `unless:` for the same message - this will raise an `ArgumentError`.
:::

## Error message inheritance with `from:`

The `from:` parameter allows you to customize error messages when an action calls another action that fails. This is particularly useful for adding context or prefixing error messages from child actions.

When using `from:`, the error handler receives the exception from the child action, and you can access the child's error message via `e.message` (which contains the `result.error` from the child action).

### Basic usage

You can use `from:` with a single child action class. The prefix and custom handler are optional:

```ruby
class InnerAction
  include Axn

  error "Something went wrong in the inner action"

  def call
    raise StandardError, "inner action failed"
  end
end

class OuterAction
  include Axn

  # Simply inherit child's error message (no prefix or custom handler needed)
  error from: InnerAction

  # Or customize the message
  error from: InnerAction do |e|
    "Outer action failed: #{e.message}"
  end

  def call
    InnerAction.call!
  end
end

In this example:

  • When InnerAction fails, OuterAction will catch the exception
  • The e.message contains the error message from InnerAction's result
  • With no handler: the error message will be "Something went wrong in the inner action" (inherited directly)
  • With custom handler: the error message will be "Outer action failed: Something went wrong in the inner action"

Matching multiple child actions

You can pass an array of child action classes to match multiple children:

ruby
class OuterAction
  include Axn

  # Match errors from multiple child actions
  error from: [FirstChildAction, SecondChildAction]

  # Or with custom handler
  error from: [FirstChildAction, SecondChildAction] do |e|
    "Parent caught: #{e.message}"
  end

  def call
    # Calls one of the child actions
  end
end

You can also mix class references and string class names:

ruby
error from: [FirstChildAction, "SecondChildAction"]

Matching any child action

Use from: true to match errors from any child action without listing them explicitly:

ruby
class OuterAction
  include Axn

  # Match errors from any child action
  error from: true

  # Or with custom handler
  error from: true do |e|
    "Any child failed: #{e.message}"
  end

  def call
    # Can call any child action
  end
end

This pattern is especially useful for:

  • Adding context to error messages from sub-actions
  • Implementing consistent error message formatting across action hierarchies
  • Providing user-friendly error messages that include details from underlying failures

Combining from: with prefix:

You can also combine the from: parameter with the prefix: keyword to create consistent error message formatting:

ruby
class OuterAction
  include Axn

  # Add prefix to error messages from InnerAction
  error from: InnerAction, prefix: "API Error: " do |e|
    "Request failed: #{e.message}"
  end

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

  def call
    InnerAction.call!
  end
end

This results in:

  • With custom message: "API Error: Request failed: Something went wrong in the inner action"
  • With prefix only: "API Error: Something went wrong in the inner action"

Message ordering with from:

When using from: with inheritance, the same message matching order applies. Define your from: handlers after your static fallback:

ruby
class OuterAction
  include Axn

  # Static fallback first
  error "Something went wrong"

  # Then from: handlers for specific child actions
  error from: InnerAction, prefix: "Inner failed: "
  error from: AnotherAction, prefix: "Another failed: "
end

.async

Configures the async execution behavior for the action. This determines how the action will be executed when call_async is called.

ruby
class MyAction
  include Axn

  # Configure Sidekiq
  async :sidekiq do
    sidekiq_options queue: "high_priority", retry: 5, priority: 10
  end

  # Or use keyword arguments (shorthand)
  async :sidekiq, queue: "high_priority", retry: 5

  # Configure ActiveJob
  async :active_job do
    queue_as "data_processing"
    self.priority = 10
    self.wait = 5.minutes
  end

  # Disable async execution
  async false

  expects :input

  def call
    # Action logic here
  end
end

Available Adapters

:sidekiq - Integrates with Sidekiq background job processing

  • Supports all Sidekiq configuration options via sidekiq_options
  • Supports keyword argument shorthand for common options (queue, retry, priority)

:active_job - Integrates with Rails' ActiveJob framework

  • Supports all ActiveJob configuration options
  • Works with any ActiveJob backend (Sidekiq, Delayed Job, etc.)

false - Disables async execution

  • call_async will raise a NotImplementedError

Inheritance

Async configuration is inherited from parent classes. Child classes can override the parent's configuration:

ruby
class ParentAction
  include Axn

  async :sidekiq do
    sidekiq_options queue: "parent_queue"
  end
end

class ChildAction < ParentAction
  # Inherits parent's Sidekiq configuration
  # Can override with its own configuration
  async :active_job do
    queue_as "child_queue"
  end
end

Default Configuration

If no async configuration is specified, the action will use the default configuration set via Axn.config.set_default_async. If no default is set, async execution is disabled.

Callbacks

In addition to the global exception handler, 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.

Callback Ordering

  • Callbacks are executed in last-defined-first order, similar to messages
  • Child class callbacks execute before parent class callbacks
  • Multiple matching callbacks of the same type will all execute

Callbacks vs Hooks

  • Hooks (before/after) are executed as part of the call -- exceptions or fail!s here will change a successful action call to a failure (i.e. result.ok? will be false)
  • Callbacks (defined below) are executed after the call -- exceptions or fail!s here will not change result.ok?

Note: Symbol method handlers for all callback types follow the same argument pattern as message handlers:

  • If the method accepts exception: as a keyword, the exception is passed as a keyword
  • If the method accepts one positional argument, the exception is passed positionally
  • Otherwise, the method is called with no arguments

WARNING

You cannot use both if: and unless: for the same callback - this will raise an ArgumentError.

on_success

This is triggered after the Axn completes, if it was successful. Difference from after: if the given block raises an error, this WILL be reported to the global exception handler, but will NOT change ok? to false.

on_error

Triggered on ANY error (explicit fail! or uncaught exception). Optional filter argument works the same as on_exception (documented below).

on_failure

Triggered ONLY on explicit fail! (i.e. not by an uncaught exception). Optional filter argument works the same as on_exception (documented below).

on_exception

Much like the globally-configured on_exception hook, you can also specify exception handlers for a specific Axn class:

ruby
class Foo
  include Axn

  on_exception do |exception| 
    # e.g. trigger a slack error
  end
end

Note that by default the on_exception block will be applied to any StandardError that is raised, but you can specify a matcher using the same logic as for conditional messages (if: or unless:):

ruby
class Foo
  include Axn

  on_exception(if: NoMethodError) do |exception| 
    # e.g. trigger a slack error
  end

on_exception(unless: :transient_error?) do |exception| 
    # e.g. trigger a slack error for non-transient errors
  end

def transient_error?
  # local decision based on inputs/outputs
  name == "temporary"
end

  on_exception(if: ->(e) { e.is_a?(ZeroDivisionError) }) do
    # e.g. trigger a slack error
  end
end

If multiple on_exception handlers are provided, ALL that match the raised exception will be triggered in the order provided.

The global handler will be triggered after all class-specific handlers.