Mountable Actions
The mountable functionality is an advanced feature that allows you to mount actions directly to classes, providing convenient access patterns and reducing boilerplate. This is particularly useful for API clients to automatically wrap bits of logic in full Axn affordances, and for creating batch enqueueing methods that can process multiple items and enqueue them as individual background jobs.
ALPHA
This is in VERY EXPERIMENTAL use at Teamshares, but the API is still definitely in flux.
Overview
When you attach an action to a class, you get multiple ways to access it:
- Direct method calls on the class (e.g.,
SomeClass.foo
), which depend on how you told it to mount - Namespace method calls (e.g.,
SomeClass::Axns.foo
) which always call the underlying axn directly (i.e. returning Axn::Result like a normal SomeAxn.call)
Attachment Strategies
axn
Strategy
The axn
strategy attaches an action that returns an Axn::Result
object.
class UserService
include Axn
mount_axn(:create_user) do |email:, name:|
user = User.create!(email: email, name: name)
expose :user_id, user.id
end
end
# Usage
result = UserService.create_user(email: "user@example.com", name: "John")
if result.ok?
puts "User created with ID: #{result.user_id}"
else
puts "Error: #{result.error}"
end
Mounted methods:
UserService.create_user(**kwargs)
- ReturnsAxn::Result
UserService.create_user!(**kwargs)
- ReturnsAxn::Result
on success, raises on errorUserService.create_user_async(**kwargs)
- Executes asynchronously (requires async adapter configuration)
mount_axn_method
Strategy
The mount_axn_method
strategy creates methods that automatically extract the return value from the Axn::Result
. This is a useful shorthand when you have a snippet that needs to return one or zero values, when you don't want to manually check if the result was ok?.
Note we only attach a bang version to be clear that on failure it'll raise an exception.
class Calculator
include Axn
mount_axn_method(:add) do |a:, b:|
a + b
end
mount_axn_method(:multiply) do |a:, b:|
a * b
end
end
# Usage
sum = Calculator.add!(a: 5, b: 3) # Returns 8 directly
product = Calculator.multiply!(a: 4, b: 6) # Returns 24 directly
# NOTE: you can still access the underlying Axn on the <wrapping_class>::Axns namespace
result = Calculator::Axns.add(a: 5, b: 3) # Returns Axn::Result
Mounted methods:
Calculator.add!(**kwargs)
- Returns the extracted value directly, raises on errorCalculator::Axns.add(**kwargs)
- ReturnsAxn::Result
step
Strategy
The step
strategy is designed for composing actions into sequential workflows. Steps are executed as part of a larger action flow.
class OrderProcessor
include Axn
expects :order_data
exposes :order_id, :confirmation_number
step :validate_order, expects: [:order_data], exposes: [:validated_data] do
fail! "Invalid order data" if order_data[:items].empty?
expose :validated_data, order_data
end
step :create_order, expects: [:validated_data], exposes: [:order_id] do
order = Order.create!(validated_data)
expose :order_id, order.id
end
step :send_confirmation, expects: [:order_id], exposes: [:confirmation_number] do
confirmation = ConfirmationMailer.send_order_confirmation(order_id).deliver_now
expose :confirmation_number, confirmation.number
end
# call is automatically defined -- will execute steps in sequence
end
# Usage
result = OrderProcessor.call(order_data: { items: [...] })
if result.ok?
puts "Order #{result.order_id} created with confirmation #{result.confirmation_number}"
end
Available methods:
OrderProcessor.call(**kwargs)
- Executes all steps in sequence
enqueue_all_via
The enqueue_all_via
method is designed for batch processing scenarios where you need to enqueue multiple instances of an action. It creates methods that can process a collection of items and enqueue each as a separate background job.
class SyncForCompany
include Axn
async :sidekiq
expects :company_id
def call
company = Company.find(company_id)
puts "Syncing data for company: #{company.name}"
# Sync individual company data
end
enqueue_all_via do
puts "About to enqueue sync jobs for all companies"
Company.find_each.map do |company|
enqueue(company_id: company.id)
end
end
end
# Usage
# Enqueue all companies immediately
SyncForCompany.enqueue_all
# Enqueue the enqueue_all action itself as a background job
SyncForCompany.enqueue_all_async
Mounted methods:
SyncForCompany.enqueue_all
- Executes the block immediately and enqueues individual jobsSyncForCompany.enqueue_all_async
- Enqueues the enqueue_all action itself as a background job
Key Features
- Inheritance: Uses
:async_only
mode by default (only inherits async config, nothing else) - enqueue Shortcut: Use
enqueue
as syntactic sugar forClassName.call_async
within the enqueue_all block
Async Execution
Mountable actions automatically support async execution when an async adapter is configured. Each mounted action gets a _async
method that executes the action in the background.
Configuring Async Adapters
class DataProcessor
include Axn
# Configure async adapter (e.g., Sidekiq, ActiveJob)
async :sidekiq
mount_axn(:process_data, async: :sidekiq) do |data:|
# Processing logic
expose :processed_count, data.count
end
end
# Usage
# Synchronous execution
result = DataProcessor.process_data(data: large_dataset)
# Asynchronous execution
DataProcessor.process_data_async(data: large_dataset)
Available Async Methods
When you attach an action using the axn
strategy, you automatically get:
ClassName.action_name(**kwargs)
- Synchronous executionClassName.action_name!(**kwargs)
- Synchronous execution, raises on errorClassName.action_name_async(**kwargs)
- Asynchronous execution
The _async
methods require an async adapter to be configured. See the Async Execution documentation for more details on available adapters and configuration options.
Advanced Options
Inheritance Behavior
Mounted actions inherit features from their target class in different ways depending on the mounting strategy. Each strategy has sensible defaults, but you can customize inheritance behavior using the inherit
parameter.
Default Behavior
Each mounting strategy has a default inheritance mode that fits its typical use case:
mount_axn
andmount_axn_method
: Use:lifecycle
mode (inherits hooks, callbacks, messages, and async config, but not fields)step
: Uses:none
mode (completely independent to avoid conflicts)enqueue_all_via
: Uses:async_only
mode (only inherits async configuration for enqueueing)
class UserService
include Axn
before :log_start
on_success :track_success
error "Parent error occurred"
async :sidekiq
def log_start
puts "Starting..."
end
def track_success
puts "Success!"
end
# Inherits lifecycle (hooks, callbacks, messages, async) but not fields
mount_axn :create_user do
# Will run log_start before and track_success after
expose :user_id, 123
end
# Completely independent - no inheritance
step :validate_user do
# Will NOT run log_start or track_success
expose :valid, true
end
# Only inherits async config for enqueueing
enqueue_all_via do
# Can call enqueue (uses inherited async config)
# Does NOT inherit hooks, callbacks, or messages
User.find_each { |u| enqueue(user_id: u.id) }
end
end
Inheritance Profiles
You can control what gets inherited using predefined profiles:
:lifecycle
Profile
Inherits everything except fields. Use this when the mounted action should fully participate in the parent's execution lifecycle:
mount_axn :process, inherit: :lifecycle do
# Inherits: hooks, callbacks, messages, async config
# Does NOT inherit: fields
end
What's inherited:
- ✅ Hooks (
before
,after
,around
) - ✅ Callbacks (
on_success
,on_failure
,on_error
,on_exception
) - ✅ Messages (
success
,error
) - ✅ Async configuration (
async :sidekiq
, etc.) - ❌ Fields (
expects
,exposes
)
:async_only
Profile
Only inherits async configuration. Use this for utility methods that need async capability but nothing else:
enqueue_all_via inherit: :async_only do
# Only inherits async config for enqueueing
# Completely independent otherwise
end
What's inherited:
- ✅ Async configuration
- ❌ Everything else
:none
Profile
Completely standalone with no inheritance. Use this when the mounted action should be fully independent:
step :independent_step, inherit: :none do
# Completely isolated from parent
end
What's inherited:
- ❌ Nothing - completely independent
Granular Control
For advanced use cases, you can use a hash to specify exactly what should be inherited:
mount_axn :custom, inherit: {
fields: false,
hooks: true,
callbacks: false,
messages: true,
async: true
} do
# Custom inheritance: only hooks, messages, and async
end
Available options:
fields
- Field declarations (expects
,exposes
)hooks
- Execution hooks (before
,after
,around
)callbacks
- Result callbacks (on_success
,on_failure
,on_error
,on_exception
)messages
- Success and error messagesasync
- Async adapter configuration
Strategies Always Inherit
Strategies (like use :transaction
) are always inherited as they're part of the class ancestry chain. This cannot be controlled via the inherit
parameter.
Practical Examples
Example 1: Step that needs parent's error messages
class DataProcessor
include Axn
error "Data processing failed"
# Inherit only error messages, nothing else
step :validate, inherit: { fields: false, messages: true } do
fail! "Invalid data" # Will use parent's error message format
end
end
Example 2: Mounted action with custom hooks but no callbacks
class ApiClient
include Axn
before :authenticate
on_success :log_success
def authenticate
# Auth logic
end
# Inherit hooks but not callbacks
mount_axn :fetch_data, inherit: { hooks: true, callbacks: false } do
# Will run authenticate before
# Will NOT run log_success callback
end
end
Example 3: Override default for a step
class Workflow
include Axn
before :setup
# Steps default to :none, but we can override to inherit lifecycle
step :special_step, inherit: :lifecycle do
# Will run setup hook (unusual for a step)
end
end
Error Prefixing for Steps
Steps automatically prefix error messages with the step name:
step :validation, expects: [:input] do
fail! "Input is invalid"
end
# If this step fails, the error message becomes: "validation: Input is invalid"
You can customize the error prefix:
step :validation, expects: [:input], error_prefix: "Custom: " do
fail! "Input is invalid"
end
# Error message becomes: "Custom: Input is invalid"
Method Naming and Validation
Valid Method Names
Method names must be convertible to valid Ruby constant names:
# ✅ Valid names
mount_axn(:create_user) # Creates CreateUser constant
mount_axn(:process_payment) # Creates ProcessPayment constant
mount_axn(:send-email) # Creates SendEmail constant (parameterized)
mount_axn(:step_1) # Creates Step1 constant
# ❌ Invalid names
mount_axn(:create_user!) # Cannot contain method suffixes (!?=)
mount_axn(:123invalid) # Cannot start with number
Special Character Handling
The system automatically handles special characters using parameterize
:
mount_axn(:send-email) # Becomes SendEmail constant
mount_axn(:step 1) # Becomes Step1 constant
mount_axn(:user@domain) # Becomes UserDomain constant
Best Practices
1. Choose the Right Strategy
- Use
mount_axn
when you need fullAxn::Result
objects and error handling - Use
mount_axn_method
when you want direct return values for simple operations - Use
step
when composing complex workflows with multiple sequential operations - Use
enqueue_all_via
when you need to process multiple items and enqueue each as a separate background job
2. Keep Actions Focused
# ✅ Good: Focused action
mount_axn(:send_welcome_email) do |user_id:|
WelcomeMailer.send_welcome(user_id).deliver_now
end
# ❌ Bad: Too many responsibilities - prefer a standalone class
mount_axn(:process_user) do |user_data:|
user = User.create!(user_data)
WelcomeMailer.send_welcome(user.id).deliver_now
Analytics.track_user_signup(user.id)
# ... more logic
end
3. Use Descriptive Names
# ✅ Good: Clear intent
mount_axn(:validate_email_format)
mount_axn_method(:calculate_tax)
step(:send_confirmation_email)
# ❌ Bad: Unclear purpose
mount_axn(:process)
mount_axn_method(:do_thing)
step(:step1)
Common Patterns
Service Objects
class UserService
include Axn
mount_axn(:create) do |email:, name:|
user = User.create!(email: email, name: name)
expose :user_id, user.id
end
mount_axn_method(:find_by_email) do |email:|
User.find_by(email: email)
end
end
# Usage
result = UserService.create(email: "user@example.com", name: "John")
user = UserService.find_by_email!(email: "user@example.com")
Workflow Composition
class OrderWorkflow
include Axn
expects :order_data
exposes :order_id, :confirmation_number
step :validate, expects: [:order_data], exposes: [:validated_data] do
# Validation logic
expose :validated_data, order_data
end
step :create_order, expects: [:validated_data], exposes: [:order_id] do
order = Order.create!(validated_data)
expose :order_id, order.id
end
step :send_confirmation, expects: [:order_id], exposes: [:confirmation_number] do
# Send confirmation logic
expose :confirmation_number, "CONF-123"
end
def call
# Steps execute automatically
end
end
Batch Processing
class EmailProcessor
include Axn
async :sidekiq
expects :email_id
def call
email = Email.find(email_id)
email.deliver!
end
enqueue_all_via do |email_ids:, priority: :normal|
puts "Processing #{email_ids.count} emails with priority: #{priority}"
email_ids.map do |email_id|
enqueue(email_id: email_id)
end
end
end
# Process all pending emails immediately
EmailProcessor.enqueue_all(
email_ids: Email.pending.pluck(:id),
priority: :high
)
# Or enqueue the batch processing as a background job
EmailProcessor.enqueue_all_async(
email_ids: Email.pending.pluck(:id),
priority: :normal
)