Part 38: How to Build a Maker-Checker Approval Workflow in Ash (Part 1: Intercepting Changes)
Ash Framework for Phoenix Developers
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
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:
- Intercept changes, including create, update and destroy.
- Move changes to the
change_requeststable. - Notify the approver to review the changes.
- If approved, then persist changes.
- 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:
- 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
- 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
- Finally, we run
set_resultand passed fake results with thestructsimilar 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