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:
| Option | Example (same for exposes) | Meaning |
|---|---|---|
sensitive | expects :password, sensitive: true | Filters the field's value when logging, reporting errors, or calling inspect |
default | expects :foo, default: 123 | If foo is missing or explicitly nil, it'll default to this value (not applied for blank values) |
optional | expects :foo, optional: true | Recommended: Don't fail if the value is missing, nil, or blank. Equivalent to allow_blank: true |
allow_nil | expects :foo, allow_nil: true | Don't fail if the value is nil (but will fail for blank strings) |
allow_blank | expects :foo, allow_blank: true | Don't fail if the value is blank (nil, empty string, whitespace, etc.) |
type | expects :foo, type: String | Custom type validation -- fail unless name.is_a?(String) |
| anything else | expects :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: :booleanto handle a boolean field (since ruby doesn't have a Boolean class to pass in directly) - Edge case: use
type: :uuidto handle a confirming given string is a UUID (with or without-chars) - Edge case: use
type: :paramsto accept either a Hash or ActionController::Parameters (Rails-compatible)
- Edge case: use
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 }
- Example:
model: true(ormodel: TheModelClassormodel: { klass: TheModelClass, finder: :find }) - allows auto-hydrating a record when only given its IDExample:
rubyexpects :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_idis 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
klassoption defaults to the field name classified (e.g.,:user→User) - The
finderoption defaults to:findbut 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
Methodobject 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_blankis 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):
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
endSubfield 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.:
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.fieldpattern (e.g.,result.greeting) - Instance methods and variables: Direct access
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}"
endMessage 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:
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
endIncorrect order (conditional messages will be shadowed):
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"
endConditional 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.
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
endIn this example:
- When
InnerActionfails,OuterActionwill catch the exception - The
e.messagecontains the error message fromInnerAction'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:
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
endThis 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:
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
endWithin a single class, later definitions override earlier ones:
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
endMessage 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.
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
endAvailable 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_asyncwill raise aNotImplementedError
Inheritance
Async configuration is inherited from parent classes. Child classes can override the parent's configuration:
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
endDefault 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 thecall-- exceptions orfail!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 orfail!s here will not changeresult.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:
class Foo
include Axn
on_exception do |exception|
# e.g. trigger a slack error
end
endNote 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:):
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
endIf 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.