Ash Framework: Evaluating Ash for Existing Systems
How difficult is it to incorporate Ash into an existing codebase?
Ash’s motto is Model your domain, derive the rest. That works well for new projects, but what if the project is already underway?
My previous post explored Ash with Electric, a strategy for managing distributed state synchronization with Postgres. I wanted to stay in that problem space while testing Ash in an existing project.
Also, Ash clearly works well with Postgres and Ecto, but how does it fit in a system that doesn’t use Ecto, or even a database at all?
In this post I’m updating Elixir and Raft: Running a cluster with state to use Ash. It isn’t production code, but it’s complete enough to get a feeling for what the process is like. And it doesn’t use Ecto or a database.
Code for this post: github.com/JKWA/superhero-raft-cluster
Superhero Context
The project has a Superhero, an actor fighting crime in one of a cluster of dispatch centers. Each superhero reports metadata about their health, location, and activity to their dispatch center, which shares it with the rest of the cluster using Raft. When a superhero’s health reaches zero, they send a final message and terminate.
Converting a Struct to an Ash Resource
Here’s the original struct:
defmodule Dispatch.Superhero do
@enforce_keys [:id]
defstruct id: nil,
name: nil,
node: nil,
is_patrolling: false,
fights_won: 0,
fights_lost: 0,
health: 100
end
Here is a Superhero as an Ash resource:
attributes do
attribute :id, :string do
description "Unique identifier for the superhero"
allow_nil? false
primary_key? true
end
attribute :name, :string do
description "Superhero name (e.g., 'Super Batman')"
allow_nil? false
constraints min_length: 3, max_length: 100
end
attribute :node, :atom do
description "Erlang node where the superhero's GenServer is running"
allow_nil? false
default :none
end
attribute :is_patrolling, :boolean do
description "Whether the superhero is currently patrolling"
allow_nil? false
default false
end
attribute :fights_won, :integer do
description "Number of fights won"
allow_nil? false
constraints min: 0
default 0
end
attribute :fights_lost, :integer do
description "Number of fights lost"
allow_nil? false
constraints min: 0
default 0
end
attribute :health, :integer do
description "Current health points (0–100)"
allow_nil? false
constraints min: 0, max: 100
default 100
end
end
We still have a Superhero struct, now wrapped with the additional context of an Ash resource.
The system stores metadata in Raft, not a database. This logic is already trusted and in use, so it is important to be able to integrate Ash without rewrites.
First, we declare data_layer: Ash.DataLayer.Simple, letting Ash know we’ll be handling the internal logic.
Next, we create a RaftActions module and implement the Ash behaviors:
use Ash.Resource.ManualRead
use Ash.Resource.ManualCreate
use Ash.Resource.ManualUpdate
use Ash.Resource.ManualDestroy
Finally, we wire our existing RaftStore.
Create Superhero
def create(changeset, _opts, _context) do
hero = changeset.attributes
Logger.debug("Creating superhero with attrs: #{inspect(hero)}")
case RaftStore.dirty_write(@cluster_name, hero.id, hero) do
{:ok, _} ->
ash_record = struct!(MissionControl.Superhero, hero)
{:ok, ash_record}
error ->
error
end
end
Here, we are reusing the existing RaftStore create logic (dirty_write) and just adding a bit of logging.
Like Ecto, Ash includes a changeset, which has the existing values changeset.data and incoming changes changeset.attributes. Because this is a new Superhero, we just use the attributes.
To facilitate chaining, Ash uses the typical Erlang tagged tuple pattern: ({:ok, Superhero} or {:error, reason}).
We then use this action within the Superhero resource:
create :create do
description "Save superhero metadata and create superhero"
primary? true
accept [:id, :name]
manual RaftActions
change StartSuperhero
end
The existing application also starts a superhero actor on create. With Ash, we wire up the logic as an Ash change module (StartSuperhero) and add it to our create action with Ash’s change hook.
So far, this is just reorganizing; we have not had to rewrite core behavior.
However, at this point we can add some niceties such as leveraging accept to limit creation to :id and :name, where the rest of the values are defaults.
Update Superhero
The update logic is a bit more complex:
def update(changeset, _opts, _context) do
expected_map =
Map.from_struct(changeset.data)
|> Map.take(Ash.Resource.Info.attribute_names(MissionControl.Superhero))
new_map = Map.merge(expected_map, changeset.attributes)
case RaftStore.write(@cluster_name, new_map.id, expected_map, new_map) do
{:ok, _} ->
Logger.debug("Update successful for #{new_map.id}")
ash_record = struct!(MissionControl.Superhero, new_map)
{:ok, ash_record}
{:error, :not_match, current_value} ->
Logger.warning("Optimistic lock failed for #{new_map.id}")
ash_current = struct!(MissionControl.Superhero, current_value)
{:error,
Ash.Error.to_ash_error("Conflict: superhero was updated by another node",
vars: [current_value: ash_current]
)}
error ->
Logger.error("Update error for #{new_map.id}: #{inspect(error)}")
error
end
end
An Ash changeset includes data (the current state) and attributes (the proposed updates). We use Ash.Resource.Info.attribute_names/1 and Map.take/2 to extract only the domain attributes and merge with attributes to construct the new state.
Then wire up the action:
update :update do
description "Update superhero metadata"
primary? true
accept [:node, :is_patrolling, :fights_won, :fights_lost, :health]
manual RaftActions
change HealthWarning
end
This adds new logic via HealthWarning to alert the dispatch center when a hero’s health is low.
We’ve also added constraints, such as constraints min: 0, max: 100 for health. Ash will validate changes against these constraints before calling RaftActions.
Destroy Logic
Destroy wires up the existing Raft delete function and adds logging:
def destroy(changeset, _opts, _context) do
superhero_id = changeset.data.id
case RaftStore.delete(@cluster_name, superhero_id) do
{:ok, _} ->
Logger.debug("Delete successful for #{superhero_id}")
{:ok, changeset.data}
error ->
Logger.error("Delete error for #{superhero_id}: #{inspect(error)}")
error
end
end
And again, we add it to the Ash resource:
destroy :destroy do
description "Delete superhero metadata and terminate superhero"
primary? true
manual RaftActions
change StopSuperhero
end
Here, we’re using the action pipeline to call an additional side effect StopSuperhero, which we wired up using our existing GenServer logic.
So far, except for a bit of “while we’re at it” improvements, this has been a refactor.
So for something that already had a context (Superhero) and CRUD actions (RaftStore), Ash is relatively easy to implement. But how about something with less structure?
Dispatch Center
Dispatch centers are nodes in the Mission Control cluster. A dispatch center is ephemeral: it can be gracefully shut down or unexpectedly destroyed. Superhero actors reside in a dispatch center. When a center is lost, the actor moves to another running center.
The original app had no struct for dispatch centers. To integrate with Ash resources, we need to define a new struct using attributes.
attributes do
attribute :node, :atom do
description "Erlang node name (e.g., :[email protected])"
allow_nil? false
primary_key? true
end
attribute :city_name, :string do
description "City name for this dispatch center (from node's Application config)"
allow_nil? false
default "Unknown"
end
attribute :status, :atom do
description "Node status (:up or :down)"
allow_nil? false
default :up
end
end
The dispatch center had more technical debt. Its logic was scattered in the client code. It worked, but it wasn’t accessible or reusable. So before integrating with Ash, we need to extract this logic into a service module (ClusterService). From there we can wire it into Ash using a new ClusterActions module:
@impl true
def read(query, _opts, _context, _data_layer_query) do
case query.action.name do
:list_all ->
read_all()
:by_node ->
read_by_node(query)
action ->
{:error, "Unknown action: #{action}"}
end
end
@impl true
def destroy(changeset, _opts, _context) do
dispatch_center = changeset.data
case ClusterService.shutdown(dispatch_center.node) do
{:ok, _result} ->
Logger.info("MissionControl center #{dispatch_center.node} shutdown initiated")
{:ok, dispatch_center}
{:error, reason} ->
Logger.error(
"Failed to shutdown dispatch center #{dispatch_center.node}: #{inspect(reason)}"
)
{:error, "Failed to shutdown node: #{inspect(reason)}"}
end
end
Then wire these actions into the Ash resource:
actions do
read :list_all do
description "List all dispatch centers in the cluster"
manual ClusterActions
end
read :by_node do
description "Get a specific dispatch center by node name"
argument :node, :atom, allow_nil?: false
manual ClusterActions
end
destroy :shutdown do
description "Shutdown a dispatch center (triggers RPC call to stop the node)"
primary? true
manual ClusterActions
end
end
This wasn’t a pure refactor. The scattered client code needed consolidation. But once extracted into ClusterService, the underlying logic didn’t change, and now we have structure and a consistent interface.
Updating Existing Code
With the domain logic wrapped in Ash resources, we now need to update the rest of the codebase. This means finding every place that used the old API and switching to call our new Ash actions.
Most of these are trivial changes. With the change to an Ash struct, code that reads superhero.name still works. But as we saw when connecting Superhero to our RaftStore, in cases where we expect the struct to include only the domain fields, such as storage layers, serialization, or external APIs, we need filtering logic.
However, Ash generates helpful introspection functions such as:
Ash.Resource.Info.attribute_names/1- Get just the domain attributesAsh.Resource.Info.action(MissionControl.Superhero, :update).accept- Get just the attributes for this action.
What I Learned
Is Ash worth the squeeze on an existing project? Yes, absolutely.
You gain helpful features: validation, domain constraints, and composable side effects.
How hard is it to bring Ash into an existing system?
If your system already has clear boundaries and solid logic, adopting Ash is mostly a matter of fitting that logic into its resource structure. It will not fix tech debt, but it gives you a strong framework to organize what is already working. Ash also supports incremental adoption, so you can introduce it gradually without disrupting existing behavior.
Ash’s motto is Model your domain, derive the rest. When you are starting a new project, that derive the rest part sounds especially appealing. But in this case, working with an established project and trusted logic, model your domain took the front seat. The changes to implement were relatively quick, but the real value came from the time spent rethinking names, boundaries, and contexts.
This is part of a series on the Ash Framework book. Previous: Combining Ash Writes with Electric Reads.
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.
Project Links
- Original Implementation Blog Post - The superhero dispatch system before Ash integration
- GitHub Repository - Complete source code with Ash integration