← Back to list

Part 38: How to Build a Maker-Checker Approval Workflow in Ash (Part 1: Intercepting Changes)

Ash Framework for Phoenix Developers

Kamaro Lambert · 2025-12-03 10:05 · 1 claps · 3.8 min read
#ash-framework #phoenix-framework #elixir #makers #approval
Open on Medium ↗
Wiki topics: 🛠️ · Crafts & DIY

Part 38: How to Build a Maker-Checker Approval Workflow in Ash (Part 1: Intercepting Changes)

Ash Framework for Phoenix Developers

Photo by Jakub Żerdzicki on Unsplash

Photo by Jakub Żerdzicki on Unsplash

Imagine you’re tasked with adding a maker-checker workflow: no change to the data (create, update, or delete) should hit the database without explicit approval from a designated reviewer.

That’s what I was asked to implement using Ash.

Here is the logic I used to do that:

  1. Intercept changes, including create, update and destroy.
  2. Move changes to the change_requests table.
  3. Notify the approver to review the changes.
  4. If approved, then persist changes.
  5. Otherwise, ignore changes.

Today, I want to show you how to achieve the first step. The rest are relatively out of this article’s scope. Let me know in the comment if you’d like to see how I implement the rest of other steps.

If you’re looking to dive deeper into building production-ready apps with Ash and Phoenix, check out my book **Ash Framework for Phoenix Developers**.

What we want is to intercept database changes without throwing exception and give the user an experience as if changes are effected, but the reality is that they are waiting for approval.

Without further adue, This is how to intercept database changes without compromising on the user experience, and build custom behavior that makes you a hero to your boss.

The magic is in the set_result/2 functions available on the [Ash.Query](https://hexdocs.pm/ash/Ash.Query.html#set_result/2) and [Ash.Changeset](https://hexdocs.pm/ash/Ash.Changeset.html#set_result/2).

Since changes are controlled via Ash.Changeset, that’s what I am going to focus on for now.

First let’s add RequestApproval to the resource global changes like the following:

defmodule MyApp.Courses.Category do
  use Ash.Resource, domain: MyApp.Loans
  # More resource definitions 

  changes do 
    # Every change to this resource will go through 
    # Request approval before proceeding
    change MyApp.Changes.RequestApproval
  end
end

Next, let’s define the change.

What we want is to intercept changes that are not approved. Thus, we need to be able to pass a flag for approved or authorized changes.

defmodule MyApp.Changes.RequestApproval do
  use Ash.Resource.Change

  @doc """
  If changes have been approved, then continue the normal journey and
  have it persisted in the database, otherwise, take the change
  through approval process first
  """
  @impl Ash.Resource.Change
  def change(%{context: %{change_authorized?: true}} = changeset, _opts, _context) do
    changeset
  end

  @impl Ash.Resource.Change
  def change(changeset, _opts, _context) do
    # Ensure the interception happens before the action begins
    Ash.Changeset.before_action(changeset, &request_approval/1)
  end

  def request_approval(changeset) do
    # To store the whole changeset in the DB we need to change it to binary 
    # then later change it back to term() for execution.
    mimiked_results = build_result(changeset)
    Ash.Changeset.set_result(changeset, {:ok, mimiked_results})
  end

  def build_result(%{action_type: :create} = changeset) do
    # Create actions don't have existing data, so we need to 
    # build the results based on data submitted
    struct(changeset.data.__struct__, changeset.attributes)
  end

  # Existing data can be return without hitting the db
  def build_result(changeset), do: changeset.data
end

Here’s the breakdown:

  1. First we checked if this change is authorized based on the set context. We achieved this with the following lines of code
@impl Ash.Resource.Change
  def change(%{context: %{change_authorized?: true}} = changeset, _opts, _context) do
    changeset
  end
  1. Second, if the change is not approved, then we intercept it before the action runs with the following code
@impl Ash.Resource.Change
  def change(changeset, _opts, _context) do
    # Ensure the interception happens before the action begins
    Ash.Changeset.before_action(changeset, &request_approval/1)
  end
  1. Finally, we run set_result and passed fake results with the struct similar to the resource running this action in the following lines of code.
def request_approval(changeset) do
    # To store the whole changeset in the DB we need to change it to binary 
    # then later change it back to term() for execution.
    mimiked_results = build_result(changeset)
    Ash.Changeset.set_result(changeset, {:ok, mimiked_results})
  end

  def build_result(%{action_type: :create} = changeset) do
    # Create actions don't have existing data, so we need to 
    # build the results based on data submitted
    struct(changeset.data.__struct__, changeset.attributes)
  end

  # Existing data can be return without hitting the db
  def build_result(changeset), do: changeset.data

We pattern matched on the build_result/1 function to handle differences that are in changing new data vs changing existing data.

Let’s confirm that all works well with tests

defmodule MyApp.Approvals.RequestChangeTest do
  use ExUnit.Case

  require Ash.Query

  defmodule Category do
    use Ash.Resource,
      domain: MyApp.Approvals.RequestChangeTest.Domain,
      data_layer: Ash.DataLayer.Ets

    ets do
      table :categories
    end

    changes do 
      # Every change to this resource will go through 
      # Request approval before proceeding
      change MyApp.Changes.RequestApproval
    end

    actions do
      default_accept [:name]
      defaults [:create, :read, :update, :destroy]
    end

    attributes do
      uuid_primary_key :id
      attribute :name, :string
    end
  end

  # Define a domain to hold the resource for testing
  defmodule Domain do
    use Ash.Domain, validate_config_inclusion?: false

    resources do
      resource MyApp.Approvals.RequestChangeTest.Category
    end
  end
 describe "Create" do
    test "It should intercept create actions" do
      {:ok, record} =
        Category
        |> Ash.Changeset.for_create(:create, %{name: "Cat 1 #{Ash.UUIDv7.generate()}"})
        |> Ash.create()

      # Confirm no database changes happened
      refute Category
             |> Ash.Query.filter(name == ^record.name)
             |> Ash.exists?()

    end
  end

  describe "UPDATE" do
    test "Update should skip datalayer unless specified" do
      {:ok, record} =
        Ash.create(Category, %{name: "Kamaro"}, context: %{change_authorized?: true})

      # Confirm changes were persisted
      assert Category
             |> Ash.Query.filter(id == ^record.id)
             |> Ash.exists?()

      # Attempt update
      params = %{name: "Updated Category"}

      {:ok, _updated_cat} =
        record
        |> Ash.Changeset.for_update(:update, params)
        |> Ash.update()

      # Confirm changes are not persisted
      refute Category
             |> Ash.Query.filter(id == ^record.id)
             |> Ash.Query.filter(name == ^params.name)
             |> Ash.exists?()
    end
  end

  describe "Destroying" do
    test "It shoud skip underlying database layer " do
      {:ok, record} = Ash.create(Category, %{name: "Kamaro"}, context: %{change_authorized?: true})


      assert Category
             |> Ash.Query.filter(id == ^record.id)
             |> Ash.exists?()

      {:ok, _fake_record} =
        record
        |> Ash.Changeset.for_destroy(:destroy)
        |> Ash.destroy(return_destroyed?: true)

      # Confirm zero database changess
      assert Category
             |> Ash.Query.filter(name == ^record.name)
             |> Ash.Query.filter(id == ^record.id)
             |> Ash.exists?()
    end
  end
end

Let know now what you think of this approach or if you have a question down in the comment.


메타데이터
post_id
d6c2f7726d1e
slug
part-38-how-to-build-a-maker-checker-approval-workflow-in-ash-part-1-intercepting-changes-d6c2f7726d1e
url
https://medium.com/@lambert.kamaro/part-38-how-to-build-a-maker-checker-approval-workflow-in-ash-part-1-intercepting-changes-d6c2f7726d1e
canonical_url
https://medium.com/@lambert.kamaro/part-38-how-to-build-a-maker-checker-approval-workflow-in-ash-part-1-intercepting-changes-d6c2f7726d1e
author_url
https://medium.com/@lambert.kamaro
status
ok
fetched_at
2026-07-21 10:43:05