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.

Get the code

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 expression
  • Ash.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.