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