Posted on 2025-07-11 by Matt.
I've been interested in the Ash Framework for a few years. Over the past month, I've gotten a chance to go through the excellent Prag Prog book, and I felt ready to take it for a spin. I decided to convert an existing Elixir project to use Ash and just see what happened.
Ash is a framework for your domain itself. You describe what things are, what actions you can take, and how they relate to other parts of your domain; Ash handles the implementation.
The basic building block is a resource, where you declare attributes and actions among other things.
For example, let's say we have some resource called TodoTask
, we can describe it like this:
defmodule TaskApp.TodoTasks.TodoTask do
use Ash.Resource, ...
resource do
attributes do
integer_primary_key :id
attribute :title, :string do
allow_nil? false
end
attribute :status, TodoTask.Status
attribute :archived?, :boolean, default: false do
allow_nil? false
end
attribute :body, :string
attribute :priority, :integer
create_timestamp :inserted_at
update_timestamp :updated_at
end
end
end
Now all we have to do is add some actions:
actions do
defaults [:create, :read, :update, :destroy]
accept [:title, :status, :body, :priority, :archived?]
end
From just this little bit of code, Ash gives you structs, migrations, changesets, and functions for reading, writing, and deleting instances of this resource.
Ash is insanely powerful, and this is just the tip of the iceberg.
The project we're working with is a simple personal project that I use for managing tasks, creatively named TaskApp. You have your basic tasks, which are something you want to do, and different contexts that tasks relate to. This lets me easily see only tasks that are related to Work (my day job) while I'm at work, without putting my attention on things that I want to do for personal projects, or just other aspects of life.
The first step was just installing Ash and getting the basic pieces set up. Luckily, Ash makes this as easy as possible with their Igniter tool. Igniter combines code generation, code modification, and scripting so that you can run one command to add a package to your dependencies, patch your config, and generate any other necessary files.
Now, it was actually time to add our first domain and resource.
TaskApp has an intake area, which is a place to quickly add a note to yourself that you will create a full fledged task from later.
The IntakeItem
is very simple and self-contained, so I figured it would be the easiest place to start.
All I had to do was take the existing Ecto schema and convert it to an Ash Resource
.
defmodule TaskApp.Intake.IntakeItem do
- use Ecto.Schema
+ use Ash.Resource, otp_app: :task_app, domain: TaskApp.Intake, data_layer: AshPostgres.DataLayer
- @type t :: %__MODULE__{
- id: integer() | nil,
- body: String.t() | nil
- }
- schema "intake_items" do
- field :body, :string
- timestamps(type: :utc_datetime)
- end
+ postgres do
+ table "intake_items"
+ repo TaskApp.Repo
+ end
+
+ actions do
+ defaults [:create, :read, :update, :destroy]
+ default_accept [:body]
+ end
+ attributes do
+ integer_primary_key :id
+ attribute :body, :string do
+ allow_nil? false
+ end
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+ validations do
+ validate string_length(:body, min: 3)
+ end
end
Once I was done, I was able to create the initial snapshot using mix ash.codegen --snapshots-only
.
Next, I wanted to convert the context module into an Ash Domain
and define my code interfaces.
My process was pretty simple and effective.
I took each function in my Intake
context module, commented it out and ran my now-failing tests.
Then I defined an interface function with the same name, re-ran my tests, made some minor tweaks, and I was back to green.
For example, a function like this:
@spec get_intake_item(integer()) :: {:ok, IntakeItem.t()} | {:error, :not_found}
def get_intake_item(id) do
IntakeItem
|> Repo.get(id)
|> case do
nil -> {:error, :not_found}
intake_item -> {:ok, intake_item}
end
end
gets replaced with this:
define :get_intake_item, action: :read, get_by: :id
The minor tweaks usually were pretty minor, like now matching on {:error, %Ash.Error.Invalid{}}
instead of {:error, :not_found}
, or changing the name of a function call from list_intake_items
to list_intake_items!
.
Since Ash uses Ecto under the hood, everything that currently reads from this table works correctly.
Once we had these new interfaces set up, we added AshPhoenix
to support our forms and updated our LiveView.
AshPhoenix
can derive your forms, so when I defined a create_intake_item
interface function, my live view can just call Intake.form_to_create_intake_item/1
, and pass its output into to_form
like I normally would with my Ecto changeset.
My validation handler now calls AshPhoenix.Form.validate(form, params)
, and my save handler calls AshPhoenix.Form.submit(socket.assigns.form, params: intake_item_params)
and I'm good to go.
Once we're done there, we can go ahead and delete my now-unused IntakeItem.changeset/2
and Intake.change_intake_item/2
functions.
After that, we just migrated our Phoenix-generated intake_item_fixture
into our new Generator
module, making it easy to generate data for our tests.
That was it! We had our first domain and resource.
Most of the rest of the project continued on the same way.
AshPhoenix
forms.Most of this process was pretty easy, but we did hit a few speed bumps.
My existing test fixtures were created by Phoenix generators, which relied on creating a struct and calling Repo.insert/2
.
This didn't always work out; usually there were issues around missing timestamps.
Trying to use Repo.insert/2
with a struct created by an Ecto schema seems to handle this fine, but using a struct created by an Ash.Resource
doesn't.
The fix here was pretty simple, I would add the generator, update my test fixture to pass its arguments into the generator function, and I was good to go.
My first real question was what to do with a basic query function like this?
@spec intake_items_count :: integer()
def intake_items_count do
from(i in "intake_items", select: count())
|> Repo.one()
end
It doesn't fit into any of the basic CRUD actions. Luckily, Zach Daniel, the creator of Ash, is pretty responsive on Elixir Form and answered my question. Either just leave it as a plain function, or add a generic action to your resource, like this:
action :count, :integer do
run fn _input, _context ->
Ash.count(__MODULE__)
end
end
This was the only real headache I had in the whole process.
When you create or update a TodoTask
, the form lets you select which Context
s your task is for.
This is a many-to-many relationship where we add a row in a join table for each Context
your TodoTask
.
The existing implementation was pretty simple, each checkbox has a value
set to the id
of the context, and the data that we submit looks like this:
%{ "task" => %{ "name" => "...", "contexts" => [ "1", "2" ] }}
I had a hell of a time getting this working. Everything I tried either populated the form values properly, but caused the form to fail validation, or allowed for a valid form but not populating the existing values.
Thankfully, Zach was willing to go back and forth with me on an Elixir Forum thread, over the course of a few days and 20+ messages, and we found a working (but hacky) solution. When I wrote up a guide with the solution, Rebecca Le (the author of the above mentioned Prag Prog book) pointed out how I was making this more complicated than it needed to be. I was able to ditch the workaround and everything worked right out of the box.
create :create do
# ...
change manage_relationship(:context_ids, :contexts, type: :append_and_remove)
end
Migrating this project was an overall success. For an app this simple, Ash might not provide any game-changing benefits, but it was a great learning experience.
A small personal project like this is a perfect place to experiment. The whole process was an easy way to get some reps working with Ash, getting comfortable with the docs and guides. Most importantly, it was a good chance to find out what concepts didn't quite click when I was working through the book, and get a better feel for what did.