One of the first questions when learning Ash: where should you put side effects?
Resources and actions are declarative and clean… until you need to make that dirty, long-running API call. Where does it go?
This post breaks down the Ash action lifecycle so you can confidently place external service calls at the right point. We'll cover the simple tools Ash provides to control exactly when your code runs.
The problem
When building an Ash application, it's tempting to use before_action hooks for all pre-processing needs. The name suggests it runs before the action happens, so that's where you'd make an API call, send an email, or perform other side effects, right?
Not quite. This is where developers often go wrong. It's actually a mistake that leads to subtle bugs and degraded performance.
Claude also gets this wrong frequently, so it's an important thing to check carefully when generating Ash Resources.
The transaction boundary matters
The key insight is understanding transaction boundaries. When you run a create, update, or destroy action in Ash, the framework automatically wraps your operation in a database transaction. This transaction starts before before_action runs and commits (or rolls back) after after_action completes.
Here's what that means in practice:
defmodule MyApp.Accounts.User do
use Ash.Resource
actions do
create :create do
accept [:email, :name]
# ⚠️ This runs INSIDE the database transaction
change before_action(fn changeset, _context ->
# Making a network request here is problematic
case ThirdPartyAPI.validate_email(changeset.attributes.email) do
{:ok, _} -> changeset
{:error, reason} -> Ash.Changeset.add_error(changeset, reason)
end
end)
end
end
end
Why is this a bad thing?
When you make a network request inside a database transaction, you're holding open a database connection whilst waiting for that external service to respond. This creates several problems:
Connection pool exhaustion: Database connections are a limited resource. If your external API is slow or times out, you're holding that connection hostage when it could be serving other requests.
Deadlock potential: Long-running transactions increase the likelihood of database deadlocks, especially if other operations are trying to access the same records.
Inconsistent state: Your API call succeeds, then the transaction rolls back. Now the external service thinks the user exists but your database doesn't.
Performance degradation: External API calls can take hundreds of milliseconds or even seconds. Holding a transaction open for that long directly impacts your application's throughput.
The lifecycle in detail
Let's break down the action lifecycle to understand where your code should actually live. The lifecycle divides into three distinct phases:

The diagram above illustrates the complete flow from changeset creation through to the final transaction cleanup. Notice the clear boundaries: changeset creation and before_transaction sit outside the transaction block, whilst before_action, the database operation, and after_action all occur within the transactional boundary.
Pre-transaction phase
This phase occurs before any database transaction begins. It includes:
- Action preparations, validations, and changes: These run during changeset creation in the order you define them. Despite their names suggesting they're grouped by type, they actually execute sequentially as specified in your action.
around_transaction(start): The opening phase of the transaction wrapper, useful for setting up resources or logging.before_transaction: This is where you want to place operations that need to happen before the database transaction starts—external API calls, file system operations, cache warming, and other non-transactional work.
Transaction phase
Once the database transaction begins, you're inside the critical path:
around_action(start): Wraps the actual database operation, useful for performance monitoring and debugging.before_action: The last chance to modify your changeset before it hits the database. Use this for final data transformations and transactional side effects like audit logging.- Data layer operation: The actual database interaction (INSERT, UPDATE, DELETE, or SELECT).
after_action: Runs only on successful operations, still within the transaction. Perfect for transactional side effects that depend on the operation succeeding.around_action(end): Cleanup phase within the transaction, but only runs if the action succeeds.
Post-transaction phase
After the transaction commits or rolls back:
after_transaction: Always runs, regardless of success or failure. This is your opportunity to perform cleanup, send notifications, invalidate caches, or even implement retry logic.around_transaction(end): Final cleanup of resources allocated during the transaction wrapper.- Notifications: Event broadcasting and webhook calls.
Where to place your side effects
Armed with this knowledge, let's refactor our earlier example correctly:
defmodule MyApp.Accounts.User do
use Ash.Resource
actions do
create :create do
accept [:email, :name]
# ✅ External API call before transaction
change before_transaction(fn changeset, _context ->
case ThirdPartyAPI.validate_email(changeset.attributes.email) do
{:ok, _} -> changeset
{:error, reason} -> Ash.Changeset.add_error(changeset, reason)
end
end)
# ✅ Database-level operations inside transaction
change before_action(fn changeset, _context ->
changeset
|> Ash.Changeset.change_attribute(:email_verified_at, DateTime.utc_now())
|> Ash.Changeset.change_attribute(:created_at, DateTime.utc_now())
end)
# ✅ Post-transaction notification
change after_transaction(fn changeset, result, _context ->
case result do
{:ok, user} ->
NotificationService.send_welcome_email(user)
result
error ->
error
end
end)
end
end
end
The double execution pattern
Both around_action and around_transaction execute twice in the lifecycle—once at the start and once at the end. However, there's a crucial detail: the end phase of around_action only runs if the action succeeds. If your action fails, that cleanup code won't execute.
change around_action(fn changeset, callback ->
start_time = System.monotonic_time()
result = callback.(changeset)
# ⚠️ This only runs if the action succeeds
duration = System.monotonic_time() - start_time
Logger.info("Action took #{duration}ms")
result
end)
In contrast, around_transaction always completes its end phase:
change around_transaction(fn changeset, callback ->
Logger.info("Transaction starting")
result = callback.(changeset)
# ✅ This always runs, success or failure
Logger.info("Transaction completed with result: #{inspect(result)}")
result
end)
Optimising with only_when_valid?
The action lifecycle executes preparations, validations, and changes in the order you define them. This is generally fine, but if you have expensive operations, you might want to skip them when the changeset is already invalid.
Both preparations and validations support the only_when_valid? option:
actions do
create :create do
accept [:email, :password, :name]
# Fast validations first
validate present(:email)
validate present(:password)
# Expensive operation only runs if previous validations passed
validate validate_password_strength(:password), only_when_valid?: true
# Expensive external check as the last validation
prepare fn query, _context ->
# Complex database query or external API call
if valid_changeset?(query) do
# Do expensive work
end
end, only_when_valid?: true
end
end
Here's the docs for implementing that option with preparations and validations.
You'll notice that changes don't have this option. That's because changes have their own lifecycle helpers for ordering: before_action, after_action, before_transaction, and after_transaction. These give you fine-grained control over when your change logic executes relative to the database operation.
Practical example: User registration with confirmation
Let's look at a complete example that uses the lifecycle effectively:
defmodule MyApp.Accounts.User do
use Ash.Resource
actions do
create :register do
accept [:email, :password, :name]
# Validate input early
validate present([:email, :password, :name])
validate string_length(:password, min: 8), only_when_valid?: true
# Check email availability before transaction
change before_transaction(fn changeset, _context ->
email = Ash.Changeset.get_attribute(changeset, :email)
case EmailChecker.check_deliverability(email) do
:ok ->
changeset
{:error, reason} ->
Ash.Changeset.add_error(changeset, field: :email, message: reason)
end
end)
# Hash password inside transaction
change before_action(fn changeset, _context ->
password = Ash.Changeset.get_attribute(changeset, :password)
hashed = Bcrypt.hash_pwd_salt(password)
changeset
|> Ash.Changeset.change_attribute(:hashed_password, hashed)
|> Ash.Changeset.change_attribute(:confirmation_token, generate_token())
|> Ash.Changeset.change_attribute(:confirmation_sent_at, DateTime.utc_now())
end)
# Send confirmation email after transaction commits
change after_transaction(fn _changeset, result, _context ->
case result do
{:ok, user} ->
Task.start(fn ->
Mailer.send_confirmation_email(user)
end)
result
error ->
error
end
end)
end
end
end
This example demonstrates the proper separation of concerns:
- Fast validations run early to fail quickly
- External email validation happens before we start a transaction
- Password hashing and token generation occur inside the transaction
- Email sending happens after the transaction commits, in a separate process
Key takeaways
Understanding the Ash action lifecycle prevents subtle bugs and improves your application's performance. Remember these principles:
- External API calls and network requests belong in
before_transactionorafter_transaction, never inside the transaction - Use
before_actionfor final data transformations that need transactional guarantees - Use
after_actionfor operations that should only run on success and need to be part of the transaction - Use
after_transactionfor side effects like sending emails, invalidating caches, or calling webhooks - Leverage
only_when_valid?to skip expensive operations when validation has already failed - Remember that
around_action's cleanup phase won't run on failures
Getting your hooks in the right place isn't just about following best practices—it's about building reliable, performant systems that handle errors gracefully and scale under load. The Ash framework gives you powerful tools to structure your code properly; understanding the lifecycle ensures you use them effectively.
References
The Ash docs cover this well. Here are the relevant functions in lifecycle order:
Now go put your side effects in the right place.





