Ash Framework: Calculations and Uncertainty
How Ash calculations work and ways to deal with missing or uncertain data.
We are Mr. Chaos in Mission Control. Our client interface lets us make all sorts of poor decisions. The application is relying on Ash resources to protect itself from our behavior.
Let’s add a bit more chaos. A superhero is no longer just a record in a database. A superhero is an actor. Superheroes fight crime and report back metadata, such as their current health. We optimistically save our requests to a superhero. Those requests notify the actor. You are dispatched. Go recover. Whatever the instruction is, the actor receives it and reacts inside its own process.
After a superhero is dispatched, they head to their assignment and fight crime. Fighting crime reduces health. The superhero always keeps us updated about every change to their health. Over time they win or lose the assignment. While all of this is happening, the actor reports back each change and, when the assignment finishes, the actor reports the final result.
Before a superhero terminates, they tell us they have lost the current battle and they will shut down gracefully. However, we are Mr. Chaos, and we now have a secret command that can terminate a superhero at any time, shutting down the actor immediately and without ceremony.
If a superhero’s health reaches zero, or if we decide their time is up, the actor stops. A terminated superhero cannot be regenerated.
Hide Closed Incidents
Our incident list is getting a bit unwieldy, we need the ability to hide the closed ones.
def closed?(%{status: status}), do: status == :closed
Even though the idea of closed is simple, we want to keep it DRY (Don’t Repeat Yourself). We want everywhere that needs to determine whether an assignment is closed to use this function. That way, when the definition of closed changes, we have a single place to update.
Code organization matters. We cannot keep things DRY if people cannot find them. Let’s keep this in our Assignment resource so we can call Assignment.closed?/1.
This is a pure function, it contains all the information it needs in order to make its decision, it is safe for anyone to call it wherever they want.
But what if we need to share this bit of domain logic outside of our program? We could document how other teams should derive closed? and then make sure we update them when the logic inevitably changes. Another strategy is to decorate our API response, letting other teams use it directly. In Ecto, we’d do this with a virtual field. In Ash, we’ll decorate using a calculation.
calculations do
calculate :closed?, :boolean, Closed
end
And the implementation:
defmodule MissionControl.Assignment.Calculations.Closed do
use Ash.Resource.Calculation
alias MissionControl.Assignment
@impl true
def init(opts), do: {:ok, opts}
@impl true
def describe(_opts) do
"Whether the assignment is closed"
end
@impl true
def expression(_opts, _context) do
expr(status == :closed)
end
@impl true
def calculate(records, _opts, _context) do
results =
Enum.map(records, &Assignment.closed?/1)
{:ok, results}
end
end
Here init is where we would handle extra options, in this case we have none so we return {:ok, opts}. Next, describe documents the API, and finally, calculate applies the logic, in this case calling Assignment.closed?/1.
Why is Ash working with a list of records?
We like lists in functional programming. They can represent one record or many. More importantly, they have an identity element. The empty list can be merged with any other list without changing it, which is what makes it the identity. That identity gives us a safe and explicit way to represent none. If a query returns no results, we can return [] instead of falling back to an unsafe sentinel like nil.
Load
When Ash returns data, it only includes attributes marked as public? true:
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :status, :atom do
allow_nil? false
public? true
constraints one_of: [:open, :dispatched, :fighting, :closed]
default :open
end
attribute :result, :atom do
public? true
constraints one_of: [:won, :lost, :unknown]
default :unknown
end
timestamps()
end
calculations do
calculate :closed?, :boolean, Closed
end
By default, we will only get id, name, status, and result. If we want the closed? calculation, we need to tell Ash to explicitly load! it:
Ash.load!(assignment, [:closed?])
Now Ash will return our assignment struct decorated with our closed? logic. This logic does not call the database. It already has the values it needs to decide whether the assignment is closed.
Notifications
Our assignment resource includes Ash notifications:
pub_sub do
module MissionControlWeb.Endpoint
prefix "assignment"
publish :update, [:_pkey]
end
Here, we have Ash send notifications on each assignment update. The notification includes the updated assignment as the payload. But remember, Ash only includes the id and public attributes by default, so our listeners need to load the closed? calculation before inserting the record into the stream.
@impl true
def handle_info(
%{topic: "assignment:" <> _id, payload: %Ash.Notifier.Notification{data: assignment}},
socket
) do
assignment = Ash.load!(assignment, [:closed?])
{:noreply, stream_insert(socket, :assignments, assignment)}
end
It is a bit of a bummer that we have to remember to reload the calculations, but the tradeoff would be Ash running expensive calculations that we do not actually need.
Order/Filter
We would like to sort our assignments so the closed appear at the bottom of the list. We could add a sort step after the query, or we could save a loop and update the query itself.
Ash will update our query using the expression callback in the Calculations.Closed module:
@impl true
def expression(_opts, _context) do
expr(status == :closed)
end
An expression is part of Ash’s DSL. Ash can read it, analyze it, and combine it with other expressions. Plugins understand these expressions, which is how Ash supports different storage backends without needing to change our code.
(https://hexdocs.pm/ash/expressions.html)
Now that we have an expression callback, we can express the closed? business logic using Ash.Query.
We can ask Ash to filter:
MissionControl.Assignment
|> Ash.Query.filter(closed?: true)
|> Ash.read!()
Or sort:
MissionControl.Assignment
|> Ash.Query.sort(closed?: :asc, inserted_at: :desc)
|> Ash.read!()
Even though we use closed? in our query, Ash will not decorate unless we load it:
MissionControl.Assignment
|> Ash.Query.sort(closed?: :asc, inserted_at: :desc)
|> Ash.Query.load(:closed?)
|> Ash.read!()
Note that we can still load after the query:
MissionControl.Assignment
|> Ash.Query.sort(closed?: :asc, inserted_at: :desc)
|> Ash.read!()
|> Ash.load!(:closed?)
But note that we now have different logic depending on how we call them:
Ash.Query.load(in query) → uses expressionAsh.load!(post-read) → uses calculate
This is a spot where our business logic stops being DRY. It’s a place where future changes or more complex rules can drift apart.
However, Ash does give us something in return, it lets us use the same options for each calculation.
Opts
Our superhero has a health value (0-100) and our domain has a concept of healthy?, a threshold in the health scale. Like so much domain knowledge, it will change as we learn more.
In our superhero resource we add:
calculate :healthy?, :boolean, {Healthy, threshold: 50}
And then implement our Healthy module:
defmodule MissionControl.Superhero.Calculations.Healthy do
use Ash.Resource.Calculation
@impl true
def init(opts) do
case Keyword.fetch(opts, :threshold) do
{:ok, threshold} when is_integer(threshold) and threshold > 0 and threshold <= 100 ->
{:ok, opts}
{:ok, threshold} when is_integer(threshold) ->
{:error, "threshold must be between 1 and 100, got: #{threshold}"}
{:ok, _} ->
{:error, "threshold must be an integer"}
:error ->
{:error, "threshold option is required"}
end
end
@impl true
def describe(opts) do
threshold = Keyword.fetch!(opts, :threshold)
"Whether the superhero is in good health (>#{threshold} HP)"
end
@impl true
def expression(opts, _context) do
threshold = Keyword.fetch!(opts, :threshold)
expr(health > ^threshold)
end
@impl true
def calculate(records, opts, _context) do
threshold = Keyword.fetch!(opts, :threshold)
results =
Enum.map(records, fn superhero ->
superhero.health > threshold
end)
{:ok, results}
end
end
Here we have two representations, expression and calculate, which we have to keep aligned, but it’s super helpful that they share the same threshold.
Errors
Let’s take a closer look at calculation errors.
The expression is just directions in the DSL, and by definition it cannot fail.
The calculate function can fail, but only if we choose to implement it that way. If we always stay on the {ok: result} side, then Ash.load! will not fail. This is the best practice. We don’t want a world where Ash.Query.load is safe but Ash.load! sometimes crashes.
Also, notice that our init can fail, but Ash treats those failures as compile-time errors. If the code compiles, init is already validated and safe at runtime.
Relationships
Calculating the win rate
Our superhero can have zero or more assignments. We need to calculate what percentage of the closed assignments they won.
First, we need to tell Ash about these relationships:
relationships do
has_many :assignments, MissionControl.Assignment do
destination_attribute :superhero_id
end
end
And we can calculate the win rate.
defmodule MissionControl.Superhero.Calculations.WinRate do
use Ash.Resource.Calculation
alias MissionControl.Assignment
@impl true
def init(opts), do: {:ok, opts}
@impl true
def describe(_opts) do
"Win rate calculated from assignment results"
end
@impl true
def load(_query, _opts, _context) do
[assignments: [:status, :result]]
end
@impl true
def calculate(records, _opts, _context) do
results =
Enum.map(records, fn record ->
closed_assignments =
record.assignments
|> Enum.filter(&Assignment.closed?/1)
total = length(closed_assignments)
if total > 0 do
wins = Enum.count(closed_assignments, &Assignment.won?/1)
wins / total
else
0.0
end
end)
{:ok, results}
end
end
Note that we’re missing the expression callback, which means we cannot use Query to sort or filter by win rate.
We’re using the load callback, loading our superhero’s assignments so they are available for our calculation, specifically we are limiting to the status and result fields.
Win rate is calculated as the number of wins divided by the number of closed assignments.
We have a bigger problem. Calculating the win rate for a hero with no closed assignments will produce a divide by zero error. We don’t want to throw an error.
We could pass back nil, which would act as a sentinel for no closed assignments. But our clients do not need to know the difference between no assignments and no wins, so we can use 0.0 instead.
Calculating The Superhero’s Alias
Each assignment has a superhero, and we’d like to display its alias.
First, we need to express the relationship:
relationships do
belongs_to :superhero, MissionControl.Superhero do
allow_nil? false
end
end
Next we can tell Ash to load the superhero using load!:
Ash.load!(updated_assignment, [:closed?, :superhero])
In this case, superhero is not a calculation, is is a relationship. But from our perspective they work the same, where an assignment will include its superhero’s public attributes.
We can add the alias to our template:
{assignment.superhero.alias}
Everything works swimmingly… until it doesn’t.
allow_nil? false looks like a rule Ash will enforce, but it is not. Ash treats it as metadata for the storage layer, similar to database constraints, not a runtime validation. If we were using Postgres, we would leverage the database would enforce this foreign key constraint, but we are using ETS.
This means Ash’s relationship loads are best-effort, they might return a value. If they can’t, they fall back to the nil sentinel.
We can work around this by using an Ash calculation instead of the default relationship load. Here we could return a safer sentinel, something like %Superhero{id: -1, alias: "missing", name: "missing"}. Callers will need to know that a Superhero with id: -1 really means “missing”, but we don’t have to worry about an unexpected nil.alias crash.
What we actually have here is control flow that needs to account for uncertainty. In functional programming we would use Maybe. Instead of promising a Superhero we promise a MaybeSuperhero. In this context Nothing is our identity and callers must handle both branches.
defmodule MissionControl.Assignment.Calculations.MaybeSuperhero do
use Ash.Resource.Calculation
alias Funx.Monad.Maybe
@impl true
def init(opts), do: {:ok, opts}
@impl true
def describe(_opts) do
"Returns the superhero wrapped in a Maybe monad (Just or Nothing)"
end
@impl true
def load(_query, _opts, _context) do
[superhero: [:name, :alias, :status, :health]]
end
@impl true
def calculate(records, _opts, _context) do
results =
Enum.map(records, fn assignment ->
Maybe.from_nil(assignment.superhero)
end)
{:ok, results}
end
end
Funx includes from_nil/1, a function that lifts the nil uncertainty into a Maybe.
Instead of loading :superhero, we can load the :maybe_superhero calculation:
Ash.load!(updated_assignment, [:closed?, :maybe_superhero])
Within in our superhero resource we can add a function that provides a default:
def safe_alias(maybe_superhero) do
fold_l(
maybe_superhero,
fn hero -> hero.alias end,
fn -> "[Missing Superhero]" end
)
end
And call it in our template:
{Superhero.safe_alias(assignment.maybe_superhero)} Assignment
The absence case is no longer hidden or accidental. Callers know they are receiving Just(x) or Nothing and the control flow stays direct, with no workarounds or surprises.
If we have a complex load pipeline and we worry about accidentally loading our expensive calculations, we can tell Ash to ignore a load where the value already exists.
Ash.load!(updated_assignment, [:closed?, :maybe_superhero], lazy?: true)
The ! in Ash.load! means the load operation itself will either succeed or raise. It does not mean the relationship will have a value. Ash raises when the operation cannot be completed, such as an authorization policy or requesting a field that does not exist.
But when a relationship’s target record is missing, that is not an operation failure. The load succeeds, the foreign key is valid, and Ash returns nil to represent the missing record. The ! promises that Ash will finish the load, not that the related record exists.
Conclusion
Calculations give us a stable place to express domain logic. They let us push rules into queries through expressions and decorate data after it has been read. We still maintain two representations, but they live behind one conceptual boundary. When the rules change, we update the calculation module and everything that depends on it stays in step, including any clients consuming our API.
This is part of a series on the Ash Framework book. Previous: Diving into Validation.
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.