computer circuit board

Understanding the Ash Action Lifecycle

Conor Sinclair

Conor Sinclair

18 December 2025 · 5 min read

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:

  1. 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.
  2. around_transaction (start): The opening phase of the transaction wrapper, useful for setting up resources or logging.
  3. 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:

  1. around_action (start): Wraps the actual database operation, useful for performance monitoring and debugging.
  2. 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.
  3. Data layer operation: The actual database interaction (INSERT, UPDATE, DELETE, or SELECT).
  4. after_action: Runs only on successful operations, still within the transaction. Perfect for transactional side effects that depend on the operation succeeding.
  5. 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:

  1. 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.
  2. around_transaction (end): Final cleanup of resources allocated during the transaction wrapper.
  3. 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_transaction or after_transaction, never inside the transaction
  • Use before_action for final data transformations that need transactional guarantees
  • Use after_action for operations that should only run on success and need to be part of the transaction
  • Use after_transaction for 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:

around_transaction/3

before_transaction/2

around_action/2

before_action/2

after_action/2

after_transaction/2

Now go put your side effects in the right place.

Conor Sinclair

WRITTEN BY

Conor Sinclair

Conor Sinclair is a Lead Software Engineer at Alembic, where he helps teams take Ash Framework from exploration to production. He's passionate about AI/LLM technology and building future-proof software that balances development velocity with long-term maintainability.

Need help building future proof software?

We'd love to see how we can help.