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.

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 Ordering

Important: Static success/error messages (those without conditions) should be defined first in your action class. If you define conditional messages before static ones, the conditional messages will never be reached because the static message will always match first.

Correct order:

ruby
class MyAction
  include Axn

  # Define static fallback first
  success "Default success message"
  error "Default error message"

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

Incorrect order (conditional messages will be shadowed):

ruby
class MyAction
  include Axn

  # These conditional messages will never be reached!
  success "Special success", if: :special_condition?
  error "Special error", if: ArgumentError

  # Static messages defined last will always match first
  success "Default success message"
  error "Default error message"
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).

```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

  # Customize error messages from InnerAction
  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
  • The final error message will be "Outer action failed: Something went wrong in the inner action"

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 and inheritance

Messages are evaluated in last-defined-first order, meaning the most recently defined message that matches its conditions will be used. This applies to both success and error messages:

ruby
class ParentAction
  include Axn

  success "Parent success message"
  error "Parent error message"
end

class ChildAction < ParentAction
  success "Child success message"  # This will be used when action succeeds
  error "Child error message"      # This will be used when action fails
end

Within a single class, later definitions override earlier ones:

ruby
class MyAction
  include Axn

  success "First success message"           # Ignored
  success "Second success message"          # Ignored
  success "Final success message"           # This will be used

  error "First error message"               # Ignored
  error "Second error message"              # Ignored
  error "Final error message"               # This will be used
end

Message Evaluation Order

The system evaluates handlers in the order they were defined until it finds one that matches and doesn't raise an exception. If a handler raises an exception, it falls back to the next matching handler, then to static messages, and finally to the default message.

Key point: Static messages (without conditions) are evaluated first in the order they were defined. This means you should define your static fallback messages at the top of your class, before any conditional messages, to ensure proper fallback behavior.

.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.