Ash Framework: Lessons from its DSL
Lifting (or perhaps stealing) some of Ash’s good ideas.
I’ve been tinkering with macros to improve the syntax for Funx, but most of my ideas have dead ended. Elixir lets us define custom operators, so I tried the Haskell strategy of using an operator for bind. But that only made things worse. Then I tried a few approaches inspired by Haskell’s do style. They fit reasonably well with Elixir’s with syntax, but Elixir developers tend to prefer pipelines.
My big take away from Ash’s DSL is that it feels like an Elixir pipeline: drop in functions and it passes the current item to the first position. It also drops parentheses, which clears out some of the nesting clutter. The result reads like a pipeline that uses keywords to express intent.
I thought I’d give that pattern a shot in Funx.
You might also like to Try the DSL in Livebook
The Problem with Pipes
Elixir greatly improved Erlang’s composition with the pipe operator. These let us compose left to right (top to down), making them easy to read and write.
But pipes only handle the simple case. When a function returns multiple branches, such as Ash.read/1 or Ash.load/2, we’re forced back into Erlang’s case expressions.
The Bang Approach
Elixir gives us the bang (!) function convention, where instead of returning the {:ok, value} or {:error, reason} branch, we return the success and raise the error.
superheroes =
Superhero
|> Ash.Query.sort(healthy?: :asc, alias: :asc)
|> Ash.Query.load(:healthy?)
|> Ash.read!()
|> Ash.load!([:win_rate])
This idea of coding the happy path and raising the errors is common in OOP. However, in functional programming, we tend to frown on lifting logic outside our control path, hoping the caller has the time or inclination to bother with the sad path.
It’s fair to say Elixir developers like pipes enough that we are sometimes willing to write unsafe code just to stay in a pipe.
The Functional Approach
The pipe itself isn’t the problem. It’s the shape of the pipe. A simple pipe can’t handle branching functions. What we want is a pipeline that can handle Kleisli composition.
In Funx, I have functions that wrap branching logic, allowing us to use Elixir’s pipe:
alias Funx.Monad
alias Funx.Monad.Either
superheroes =
Superhero
|> Ash.Query.sort(healthy?: :asc, alias: :asc)
|> Ash.Query.load(:healthy?)
|> Ash.read()
|> Either.from_result()
|> Monad.bind(fn s -> Either.from_result(Ash.load(s, [:win_rate])) end)
|> Either.to_try!()
This handles the branching context correctly, where we are lifting Ash.read/1 into the Either monad, where we bind it to Ash.load/2, which we’ve also lifted. Finally, we convert the results of the pipeline back to the happy path by using the helper Either.to_try!/1 to throw the error path.
It is correct, but awkward. If we already believe developers will trade ergonomics for safety, this does not get us closer to solving the problem.
A DSL Solution
So what if we used something closer to Ash’s DSL?
use Funx.Monad.Either
superheroes =
either Superhero do
bind Ash.read()
bind Ash.load([:win_rate])
end
Logic in the either block should run in the Either context, which requires the input to be lifted. But I can handle that with a bit of pattern matching, passing the input if it is an Either and lifting it when it is not.
Second, I can also pattern match to understand when a function returns the Elixir’s typical {:ok, value} or {:error, reason} pattern, and transform it to Either, letting you use common libraries without extra ceremony.
The big win is that the code now reads declaratively: take the Superhero resource, get the data, if successful, load the calculation, and return the success or error of the pipeline.
Flexible Return Values
We can let the user choose to raise the error of the pipeline:
use Funx.Monad.Either
superheroes =
either Superhero, as: :raise do
bind Ash.read()
bind Ash.load([:win_rate])
end
We have as, which lets the user choose what they want as the return value of the pipe. Here, :raise will throw the error, :tuple will return the familiar {:ok, _} or {:error, _} pattern, and :either will return Funx’s Either. This is Funx after all, so default is :either, but it is trivial for a user to add as: :tuple.
Now users can write a pipeline that performs Kleisli composition and our users don’t need to know anything about the Either internals.
Using Map and Bind
We can expand on this a bit:
use Funx.Monad.Either
superheroes =
either Superhero, as: :raise do
map Ash.Query.sort(healthy?: :asc, alias: :asc)
map Ash.Query.load(:healthy?)
bind Ash.read()
bind Ash.load([:win_rate])
end
We use map to identify a step that will not fail. In this case, Ash Query’s sort and load functions always return a value; they cannot fail.
Now we have something that feels much more declarative. Tell the DSL whether your function can fail (bind) or cannot fail (map), and the DSL handles the rest. If an earlier function fails, it will skip all further maps and binds and just return the failure.
We could rely on pattern matching to infer whether a step is a map or a bind, but using explicit keywords does a better job of expressing intent.
Improving Code
Rather than just refactoring existing logic, let’s improve something.
Here is some typical Elixir code:
def handle_event("assignment_close", %{"id" => id}, socket) do
assignment = Ash.get!(MissionControl.Assignment, id)
case MissionControl.close_assignment(assignment) do
{:ok, updated_assignment} ->
updated_assignment = Ash.load!(updated_assignment, [:closed?, :maybe_superhero])
{:noreply,
socket
|> stream_insert(:assignments, updated_assignment)}
{:error, error} ->
handle_error(error, "close assignment", socket)
end
end
Here we use Ash.get!/2, thinking “what are the chances the record they’re looking at was deleted?” Then we call MissionControl.close_assignment/1 and, this time, we do handle the failure by pattern matching. Inside the success branch we call Ash.load!/2, again telling ourselves, “this certainly won’t fail.” We have two separate bang calls hiding error paths and one place where we actually handle errors.
We can refactor this with the Either DSL:
def handle_event("assignment_close", %{"id" => id}, socket) do
result =
either MissionControl.Assignment, as: :tuple do
bind Ash.get(id)
bind MissionControl.close_assignment()
bind Ash.load([:closed?, :maybe_superhero])
end
case result do
{:ok, updated_assignment} ->
{:noreply, stream_insert(socket, :assignments, updated_assignment)}
{:error, error} ->
handle_error(error, "close assignment", socket)
end
end
Now, instead of hoping those bang (!) calls never fail, we handle the errors in one place. If any bind fails, the rest of the steps are skipped and the error flows down to the final case.
This is where the DSL gets interesting. The code is not only safer, but more ergonomic. From a developers perspective, we might as well pick this version over the original.
Using Modules
Ash’s DSL allows users to extract reusable operations into modules. Let’s do that in Funx as well:
Let’s see if we can clean up our ReleaseSuperheroBestEffort change:
defmodule MissionControl.Assignment.Changes.ReleaseSuperheroBestEffort do
use Ash.Resource.Change
import Funx.Monad
import Funx.Monad.Either
@impl true
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
defp load_superhero(assignment) do
MissionControl.get_superhero(assignment.superhero_id)
|> from_result()
end
defp off_duty_superhero(superhero) do
superhero
|> MissionControl.off_duty_superhero()
|> from_result()
end
defp tap_broadcast_off_duty_superhero(updated_superhero) do
broadcast_update(MissionControl.Superhero, updated_superhero)
updated_superhero
end
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
Let’s extract that tap_broadcast_off_duty_superhero function to it’s own module.
defmodule MissionControl.Superhero.Notifiers.BroadcastUpdate do
@behaviour Funx.Monad.Either.Dsl.Behaviour
@impl true
def run(superhero, _opts, _env) do
notification =
Ash.Notifier.Notification.new(
MissionControl.Superhero,
data: superhero,
action: %{type: :update}
)
Phoenix.PubSub.broadcast(
MissionControl.PubSub,
"superhero:#{superhero.id}",
%{
topic: "superhero:#{superhero.id}",
payload: notification
}
)
superhero
end
end
The behaviour requires a run/3 function that receives the current value, options, and environment. Since this is a tap operation, it performs the broadcast and returns the superhero unchanged.
Now the original code collapses to this:
defmodule MissionControl.Assignment.Changes.ReleaseSuperheroBestEffort do
use Ash.Resource.Change
use Funx.Monad.Either
alias MissionControl.Superhero.Notifiers.BroadcastUpdate
@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn _changeset, assignment ->
either assignment.superhero_id do
bind MissionControl.get_superhero()
bind MissionControl.off_duty_superhero()
map BroadcastUpdate
end
{:ok, assignment}
end)
end
end
What used to be a long chain of wrappers and helpers becomes a declarative pipeline: get the superhero, mark them off duty, broadcast the update.
If we look at Ash’s DSL, it’s all about surfacing intent. It uses complex logic, but presents an interface focused on giving the user a clear expression of what the code is meant to do, not how the framework accomplishes it.
This is the core insight behind the Either DSL: use functional machinery behind the scenes and focus on surfacing intent.
Capture Syntax
Here is our EnforceSingleAssignment. We are using curry_r and the capture syntax (&check_no_other_assignments/2).
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
It is fair to say this is NOT how most Elixir developers prefer to write code.
Using the DSL moves us closer.
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, assignment ->
either assignment.superhero_id, as: :tuple do
bind MissionControl.get_superhero()
bind check_no_other_assignments(assignment)
map_left revert_assignment(assignment, changeset)
map fn _superhero -> assignment end
end
end)
end
It now reads:
- Try to get the superhero
- If successful, check whether there are other assignments
- If the superhero cannot be loaded or it already has an assignment (error path), revert the assignment
- Because we are in the context of superheroes but running inside an assignment action, return the assignment on success
Again, the goal of the DSL is to do a better job of surfacing intent.
This is part of a series on the Ash Framework book. Previous: Calculations and Uncertainty.
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.