Time for a closer look at actions.

I tend to look at code through the lens of functional programming, so to understand Ash actions, I first need to understand the context they operate in.

Ash actions run through a series of stages: before, db_transaction, after_action, and notify. The result of that pipeline is either a success or an error.

In functional terms, this behaves like an Either, a sequence of computations where each stage returns either {:ok, result} or {:error, reason}. If any stage fails, the pipeline short circuits and skips the rest.

This post focuses on the db_transaction stage and everything that comes after it. I’ll leave before logic and batch operations for another time. Also, I’ll explore after_transaction, a hook in the pipeline with different behavior.

Ash provides a declarative interface within Elixir, but parts of the pipeline drop into typical Elixir code, which is usually written procedurally. Here, I’ll take the opportunity to introduce how functional programming patterns can help keep things more declarative.

Bind

In set theory, closure means that applying an operation to members of a set produces another member of the same set. For example, functions that take an integer and return an integer can be easily piped together:

add_one → add_two → add_one

This pipeline creates an add_four function.

Functions that take a value and return a context, such as Maybe or Either, are called Kleisli functions. These are not closed under ordinary composition, so they cannot just be piped together. Instead, we need Kleisli composition, which can go by the name bind, chain, or flat_map.

In the Either context, bind applies logic only to the Right (success) side and short-circuits on the first Left (error). The composition is right-biased. It can move from Right to Left, but not the other way.

In Ash actions we write the functions and let Ash handle the sequencing.

validation → transaction → after_action

Note that Ash supports multiple after_action stages, which allows us to compose declarative pipelines.

validation → transaction → SetOffDuty → ReportHealth

Again, as long as each after_action returns a tagged tuple {:ok, result} or {:error, reason}, Ash will handle the bind logic.

Well, not quite.

Closed Under the Resource

Ash’s db_transaction and after_action stages do not just return {:ok, result}. They specifically return {:ok, resource}, meaning they are closed under the resource.

This allows Ash to enforce resource-level rules, such as stripping private fields. There is nothing you can do within an after_action that could accidentally leak private information.

For example, if SetOffDuty were to return private data, Ash would remove it before passing control to ReportHealth:

validation → transaction → SetOffDuty → ReportHealth

But this creates a constraint. If the pipeline runs within the Superhero resource, everything works as expected. However, if it runs within the Assignment resource, perhaps as part of assignment update logic, SetOffDuty cannot pass the superhero to ReportHealth. Each step must fetch its own copy of the superhero and return the assignment.

This leads to two side effects: after_action logic can be shared within a resource but not across resources, and because each after_action must be fully self-contained, they can grow quite large.

Notify

In functional programming we sometimes need logic that does not affect the main computation, such as sending notifications or recording telemetry. This logic runs at most once and is best effort, not guaranteed to succeed. In the context of Either, this effect is executed only when the pipeline is Right. In functional programming the name for this pattern is tap.

Ash actions include a notification tap at the end of the transaction chain.

validation → transaction → after_action → notify

What makes this tap unique is its scope. It applies only within the current resource. If an after_action calls another resource, Ash will execute that resource’s validations, transactions, and after_actions, but it will not trigger one resource’s notification tap from within another.

After Transaction

Ash includes an after_transaction hook that runs after after_action.

validation → transaction → after_action |> after_transaction → notify

Up to this point, the pipeline uses Kleisli composition () to sequence operations in the Either context. Each stage passes on success and short-circuits on error. In contrast, after_transaction is a plain pipe (|>). It takes an Either and returns an Either. Like after_action, it is closed under the resource.

The hook is intended for I/O operations, such as calling external APIs. But the failure semantics can be awkward. If an external call fails, the action reports failure and skips notify, even though the database transaction succeeded.

Because the hook is closed under the resource, we always need to return that resource, even when we only care about the side effect. There is no way to propagate the result of an external call directly.

I see after_transaction as an escape hatch. Ash actions only give us a bind, so if I needed other logic, such as flip, else_if, or traverse, I could add them here. Also, Ash’s pipeline is Either, which means it is single threaded, but we could use this escape hatch to run parallel logic.

Superhero App

I built a version of the Superhero Dispatch problem to explore Ash actions.

Get the code

In most client applications we prevent dead ends by showing or hiding options so users can only make valid choices. Here I intentionally avoid that logic to explore how Ash actions protect domain logic.

Dispatch a Superhero

The domain rules for dispatch are simple: only an on-duty superhero can be dispatched, and only an open assignment can be dispatched. A dispatch transaction must update both.

This makes the assignment and superhero part of a single logical transaction, but they live in different resources. In a Postgres setup, an atomic transaction would handle this, rolling back automatically on failure.

With ETS, rollback must be handled explicitly.

Dispatch logic spans resources, but it needs to live in one or the other. In this case, the assignment resource will hold the action and the superhero update will happen in its after_action.

Once the assignment transaction succeeds, the superhero needs to be updated:

  1. Load the superhero.
  2. Run the superhero’s dispatch logic.
  3. Send the superhero’s update message.
  4. On failure, roll back the assignment.
  5. If the rollback itself fails, return both errors.

Here is a typical procedural implementation.

defmodule MissionControl.Assignment.Changes.DispatchSuperhero do
  use Ash.Resource.Change

  @impl true
  def change(changeset, _opts, _context) do
    Ash.Changeset.after_action(changeset, fn _changeset, assignment ->
      case MissionControl.get_superhero(assignment.superhero_id) do
        {:ok, %MissionControl.Superhero{} = superhero} ->
          case MissionControl.dispatch_superhero(superhero) do
            {:ok, updated_superhero} ->
              broadcast_update(MissionControl.Superhero, updated_superhero)
              {:ok, assignment}

            {:error, dispatch_error} ->
              case rollback_assignment(assignment) do
                {:ok, _} ->
                  {:error, dispatch_error}

                {:error, rollback_error} ->
                  {:error, aggregate_errors(dispatch_error, rollback_error)}
              end
          end

        {:error, %Ash.Error.Query.NotFound{}} ->
          error =
            Ash.Error.Changes.InvalidChanges.exception(
              fields: [:superhero_id],
              message:
                "Cannot dispatch assignment: superhero with ID #{assignment.superhero_id} not found"
            )

          case rollback_assignment(assignment) do
            {:ok, _} ->
              {:error, error}

            {:error, rollback_error} ->
              {:error, aggregate_errors(error, rollback_error)}
          end

        {:error, other_error} ->
          case rollback_assignment(assignment) do
            {:ok, _} ->
              {:error, other_error}

            {:error, rollback_error} ->
              {:error, aggregate_errors(other_error, rollback_error)}
          end
      end
    end)
  end

  defp rollback_assignment(assignment) do
    assignment
    |> Ash.Changeset.for_action(:update, %{status: :open})
    |> Ash.update()
  end

  defp aggregate_errors(%Ash.Error.Invalid{errors: e1}, %Ash.Error.Invalid{errors: e2}),
    do: %Ash.Error.Invalid{errors: e1 ++ e2}

  defp aggregate_errors(e1, e2),
    do: Ash.Error.to_error_class([e1, e2])

  defp broadcast_update(resource, record) do
    topic = "#{resource_prefix(resource)}:#{record.id}"

    notification =
      Ash.Notifier.Notification.new(resource,
        data: record,
        action: %{type: :update}
      )

    Phoenix.PubSub.broadcast(
      MissionControl.PubSub,
      topic,
      %{topic: topic, payload: notification}
    )
  end

  defp resource_prefix(MissionControl.Superhero), do: "superhero"
end

If you’re comfortable with Elixir, you’ll probably start with an exasperated sigh, but you can make your way through the logic. The issue is complexity. There’s just too much to comfortably hold in your head.

So what’s really the problem here?

Our chain of logic doesn’t understand its own context. We keep unwrapping and rewrapping results, layering conditionals until the core idea is buried.

Erlang gave us the {:ok, success} and {:error, reason} convention, but it offers no built-in way to compose within that structure. We can make things a little clearer with helper functions and Elixir’s with syntax, or we can reach for a library like Funx, which provides monadic composition.

Here is the same logic expressed using Funx:

defmodule MissionControl.Assignment.Changes.DispatchSuperhero do
  use Ash.Resource.Change

  import Funx.Monad
  import Funx.Monad.Either
  import Funx.Foldable
  import Funx.Utils

  @impl true
  def change(changeset, _opts, _context) do
    Ash.Changeset.after_action(changeset, fn _changeset, assignment ->
      load_superhero(assignment)
      |> bind(&dispatch_superhero/1)
      |> map(&broadcast_dispatch_tap/1)
      |> map_left(curry_r(&handle_dispatch_error/2).(assignment))
      |> map(fn _ -> assignment end)
      |> to_result()
    end)
  end

  defp load_superhero(assignment) do
    MissionControl.get_superhero(assignment.superhero_id)
    |> from_result()
    |> map_left(curry_r(&format_superhero_error/2).(assignment))
  end

  defp dispatch_superhero(superhero) do
    superhero
    |> MissionControl.dispatch_superhero()
    |> from_result()
  end

  defp broadcast_dispatch_tap(updated_superhero) do
    broadcast_update(MissionControl.Superhero, updated_superhero)
    updated_superhero
  end

  defp handle_dispatch_error(error, assignment) do
    rollback_assignment(assignment)
    |> fold_l(
      fn rollback_error -> aggregate_errors(error, rollback_error) end,
      fn _ -> error end
    )
  end

  defp rollback_assignment(assignment) do
    assignment
    |> MissionControl.update_assignment(%{status: :open})
    |> from_result()
  end

  defp format_superhero_error(%Ash.Error.Query.NotFound{}, assignment) do
    Ash.Error.Changes.InvalidChanges.exception(
      fields: [:superhero_id],
      message:
        "Cannot dispatch assignment: superhero with ID #{assignment.superhero_id} not found"
    )
  end

  defp format_superhero_error(error, _assignment), do: error

  defp aggregate_errors(%Ash.Error.Invalid{errors: e1}, %Ash.Error.Invalid{errors: e2}),
    do: %Ash.Error.Invalid{errors: e1 ++ e2}

  defp aggregate_errors(e1, e2),
    do: Ash.Error.to_error_class([e1, e2])

  defp broadcast_update(resource, record) do
    notification =
      Ash.Notifier.Notification.new(resource,
        data: record,
        action: %{type: :update}
      )

    Phoenix.PubSub.broadcast(
      MissionControl.PubSub,
      "#{resource_prefix(resource)}:#{record.id}",
      %{
        topic: "#{resource_prefix(resource)}:#{record.id}",
        payload: notification
      }
    )
  end

  defp resource_prefix(MissionControl.Superhero), do: "superhero"
end

Here is the crux of the logic:

def change(changeset, _opts, _context) do
    Ash.Changeset.after_action(changeset, fn _changeset, assignment ->
      load_superhero(assignment)
      |> bind(&dispatch_superhero/1)
      |> map(&broadcast_dispatch_tap/1)
      |> map_left(curry_r(&handle_dispatch_error/2).(assignment))
      |> map(fn _ -> assignment end)
      |> to_result()
    end)
end
  1. Load the superhero
  2. Dispatch the superhero
  3. Notify listeners
  4. Roll back the assignment on error
  5. Return the assignment resource
  6. Convert from the Funx Either representation back to the Erlang tuple {:ok, value} or {:error, reason}

Functional Combinators

  • bind sequences steps that may fail. It only continues if the result is a success.
  • map transforms the result on success.
  • map_left transforms the result on error.

In the procedural version, we have to describe how to handle each possibility, success or error, at every step. In the Either version, we declare what should happen in each logical stage, leaving the context to handle the control flow.

Now in our assignment :dispatch action we have:

update :dispatch do
    require_atomic? false
    accept []
    validate MustBeOpen
    change set_attribute(:status, :dispatched)
    change DispatchSuperhero
end

If an assignment is not open, short-circuit before starting the transaction. If the transaction itself fails, short-circuit before calling DispatchSuperhero. If the superhero after_action fails, it needs to handle the rollback.

Because DispatchSuperhero performs effectful work that may partially succeed, we set require_atomic? false.

Assignment

We can share logic between actions, using the action type for conditional logic.

Before a superhero can be dispatched, they need an assignment. An assignment can only be connected to one superhero and a superhero can only have one open assignment at a time.

Assignment actions include create and reopen. Both follow the same domain rules, but the rollback is different. If the reopen fails, the assignment reverts to closed. If the create action fails, the new assignment should be deleted.

defmodule MissionControl.Assignment.Changes.EnforceSingleAssignment do
  use Ash.Resource.Change
  import Funx.Monad
  import Funx.Monad.Either
  import Funx.Foldable
  import Funx.Utils

  @impl true
  def change(changeset, _opts, _context) do
    Ash.Changeset.after_action(changeset, fn changeset, assignment ->
      load_superhero(assignment)
      |> bind(curry_r(&check_no_other_assignments/2).(assignment))
      |> map_left(curry_r(&revert_assignment/3).(changeset).(assignment))
      |> map(fn _superhero -> assignment end)
      |> to_result()
    end)
  end

  defp load_superhero(assignment) do
    MissionControl.get_superhero(assignment.superhero_id)
    |> from_result()
  end

  defp check_no_other_assignments(superhero, assignment) do
    MissionControl.list_assignments_by_superhero(superhero.id)
    |> from_result()
    |> map(curry_r(&filter_other_assignments/2).(assignment))
    |> bind(curry_r(&validate_no_conflicts/2).(superhero))
  end

  defp filter_other_assignments(assignments, assignment) do
    Enum.reject(assignments, &(&1.id == assignment.id))
  end

  defp validate_no_conflicts(assignments, superhero) do
    lift_predicate(
      superhero,
      fn _ -> Enum.empty?(assignments) end,
      fn s ->
        Ash.Error.Changes.InvalidChanges.exception(
          fields: [:superhero_id],
          message: "#{s.alias} already has an active assignment"
        )
      end
    )
  end

  defp delete_assignment(assignment) do
    Ash.destroy(assignment)
    |> normalize_destroy_result()
    |> from_result()
  end

  defp normalize_destroy_result(:ok), do: {:ok, nil}
  defp normalize_destroy_result(result), do: result

  defp close_assignment(assignment) do
    assignment
    |> MissionControl.close_assignment()
    |> from_result()
  end

  defp aggregate_errors(error1, error2) do
    Ash.Error.to_error_class([error1, error2])
  end

  defp revert_assignment(error, assignment, changeset) do
    rollback_operation =
      case changeset.action.name do
        :create -> &delete_assignment/1
        :reopen -> &close_assignment/1
        _ -> &delete_assignment/1
      end

    rollback_operation.(assignment)
    |> fold_l(
      fn rollback_error -> aggregate_errors(error, rollback_error) end,
      fn _ -> error end
    )
  end
end

The core pipeline looks like this:

def change(changeset, _opts, _context) do
    Ash.Changeset.after_action(changeset, fn changeset, assignment ->
        load_superhero(assignment)
        |> bind(curry_r(&check_no_other_assignments/2).(assignment))
        |> map_left(curry_r(&revert_assignment/3).(changeset).(assignment))
        |> map(fn _superhero -> assignment end)
        |> to_result()
    end)
end
  1. Load the associated superhero.
  2. Check that the superhero has no other open assignments.
  3. On error, revert the assignment.
  4. On success, return the assignment as the result.
  5. Convert from the Funx Either to the expected {:ok, assignment}, {:error, reason}.

Here, both actions share the EnforceSingleAssignment logic.


    create :create do
      accept [:superhero_id, :name, :difficulty]
      primary? true
      change EnforceSingleAssignment
    end

    update :reopen do
      require_atomic? false
      accept []
      validate MustBeClosed
      change set_attribute(:status, :open)
      change EnforceSingleAssignment
    end

Release Superhero

When an assignment is closed, the superhero needs to be released (placed into off_duty). This should be best-effort, where we try to release the superhero, but its results should not impact the close action.

def change(changeset, _opts, _context) do
    Ash.Changeset.after_action(changeset, fn _changeset, assignment ->
        load_superhero(assignment)
        |> bind(&off_duty_superhero/1)
        |> map(&tap_broadcast_off_duty_superhero/1)
        |> then(fn _ -> {:ok, assignment} end)
    end)
end

Here’s what this pipeline does:

  1. Loads the superhero, which might fail.
  2. Tries to mark the superhero as off duty, which can also fail.
  3. Broadcasts the update, but only if the previous steps succeeded.
  4. Ignore the results of the pipeline and always return {:ok, assignment}.

Takeaway

From a functional programming perspective, Ash actions operate in the Either context. The pipeline uses Kleisli composition (bind) to sequence operations from transaction through after_action, and Ash handles the composition for you. Resource closure means each stage must return the resource it belongs to, which provides safety but also constrains how logic can be shared.

When you need to coordinate across resources, you work within an after_action hook. The three examples in this post show different patterns: error handling with rollback, conditional logic based on action type, and best-effort operations that don’t affect success or failure.

The after_transaction hook is an escape hatch. Because it gives you direct access to the Either, you can implement patterns beyond bind, such as recovery logic, parallel operations, or custom sequencing.

If you like working declaratively, a library like Funx provides the abstractions. But even without Funx, understanding the Either context, resource closure, and where the escape hatches are helps functional programmers (like me) work with Ash’s design rather than against it.

This is part of a series on the Ash Framework book. Previous: Evaluating Ash for Existing Systems”.

Resources

Ash Framework

Learn how to use Ash Framework to build robust, maintainable Elixir applications. This book covers the principles and practices of domain-driven design with Ash's declarative approach.

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.

Funx - Functional Programming for Elixir

A library of functional programming abstractions for Elixir, including monads, monoids, Eq, Ord, and more. Built as an ecosystem where learning is the priority from the start.