2026-04-05
Declarative programming can be a powerful paradigm for organizing software systems. By defining the business processes once, we ensure there is a single source of domain knowledge. From this foundation, we can derive other parts of the system such as API endpoints, database schemas, and even user interfaces. This approach reduces repetition and helps prevent bugs caused by misaligned domain models.
The Ash Framework seems like an excellent way to see declarative programming in action. Written in Elixir, it allows you to describe your domain in a consistent and expressive way, from which it can automatically generate data layers, REST or GraphQL APIs, and admin interfaces. With Ash, you define what your application should do, and the framework takes care of how to make it happen. It can derive JSON REST endpoints, handle validation, manage persistence, and provide authorization logic, all from the same declarative definitions.
I am new to Ash and I tend to learn best by writing things out, so this article is as much for me as it is for you. Rather than trying to understand everything up front, I prefer to get hands-on quickly with a small, self-contained project. We’ll start with the basics here, and if things go well, expand on it in a follow-up or two.
Given the name Ash, it felt appropriate to build something inspired by the gigantic ash tree of Norse mythology: Yggdrasil, the World Tree.
Yggdrasil is said to connect the Nine Worlds of Norse cosmology, though the exact number and nature of these worlds vary between sources. Each world has its own nature, inhabitants, and relationships with the others, making it an ideal metaphor for exploring how Ash models resources, attributes, and relationships. In this project, we will create a domain model for these concepts with Ash and derive a JSON REST API from them.
The first step is getting started with a basic Ash project for which we will use the Igniter tool. (I will assume Elixir is already installed, but if not, see the Elixir Install page for instructions). This is used for project setup and code generation, which will help us get started a lot quicker.
To start off following command will install Igniter:
mix archive.install hex igniter_new
Once we have Igniter, the next step is creating a new project in the yggdrasil directory, adding Ash, and moving into it.
mix igniter.new yggdrasil --install ash && cd yggdrasil
This will land us in a new Elixir project directory with Ash installed. From this seed we will evolve our application to represent the worlds and characters of Norse mythology.
The newly created project comes with a hello function in the lib/yggdrasil.ex module. Let's try it out in the iex, the Elixir interactive shell which we can start with:
iex -S mix
Running it will bring us into the shell, where we can run the hello function in the yggdrasil module:
iex(1)> Yggdrasil.hello()
:world
We now have a hello world, but in yggdrasil we want to represent the worlds of Norse mythology that are linked by Yggdrasil. The first thing we need for this is a Domain. This will function as a container for the various concepts, such as the worlds, that we will introduce later.
For simplicity's sake, first we will replace the contents of lib/Yggdrasil.ex with the following:
defmodule Yggdrasil do
@moduledoc """
The Yggdrasil domain — acts as the trunk of the tree
and organizes all resources like World and Character.
"""
use Ash.Domain
resources do
# Resources will be registered here
resource Yggdrasil.World
end
end
We create a file lib/resources/world.ex with the following contents:
defmodule Yggdrasil.World do
@moduledoc """
A resource representing a world in Yggdrasil.
"""
use Ash.Resource,
# in-memory store
data_layer: Ash.DataLayer.Ets,
domain: Yggdrasil
actions do
create :create do
accept [:name, :description]
end
update :update do
accept [:description]
end
# Provide default actions
defaults [:read, :destroy]
end
attributes do
# Primary key
uuid_primary_key :id
# World name and description
attribute :name, :string, allow_nil?: false, public?: true
attribute :description, :string, public?: true
end
end
And finally
config :yggdrasil, :ash_domains, [Yggdrasil]
With these files in place, we now have a minimal Ash domain containing a single resource: World. Let’s take a moment to unpack what we just created before moving on.
At the top level, Yggdrasil acts as our domain, the trunk of our system. It brings together all the resources that make up the application and defines how they relate to each other. Right now, our domain only includes one resource, Yggdrasil.World, but we’ll add more later.
The Yggdrasil.World module itself is declared as a resource. In Ash, a resource is the fundamental building block. It describes a specific type of data and what can be done with it. Instead of writing separate schemas, changesets, and controllers, we declare everything about a resource in one place, and Ash takes care of the details.
Our World resource uses the Ash.DataLayer.Ets data layer, which stores data in Elixir’s in-memory ETS tables. This setup is fast and simple, making it perfect for early experimentation, though data won’t persist between runs. Later, this can be swapped out for a different data layer to gain full persistence. The argument domain: Yggdrasil connects the resource back to the domain we just defined so that the framework knows where it belongs.
Inside the actions block, we declare what operations are available for this resource. The create action accepts a name and a description, while the defaults [:read, :destroy] line automatically adds the standard read, update, and delete actions. There’s no need to write any manual CRUD logic—Ash generates it for us.
The attributes block defines the structure of each world. Every world has a UUID primary key (:id) and two fields, :name and :description. The :name attribute is made required using (allow_nil?: false), ensuring that each world must have one. Both attributes are marked public?: true so they appear in APIs and outputs.
Finally, the configuration line we added tells Ash which domains to load when the application starts. Without this, the framework wouldn’t know about our new resource.
At this point, our small Ash tree has already taken root. We’ve declared the first piece of our domain, and Ash now knows how to create, read, update, and delete worlds. Let’s see that in action next by exploring our resource interactively in iex.
First, start the interactive shell from your project root:
iex -S mix
Once inside iex, we want to create our first world Asgard, the shining realm of the gods, with the following command:
asgard = (
Yggdrasil.World
|> Ash.Changeset.for_create(:create, %{
name: "Asgard",
description: "A shining realm of order and power, suspended high above the clouds."
})
|> Ash.create!()
)
which would return the world as such:
14:29:14.665 [debug] Creating Yggdrasil.World:
Setting %{id: "726b678e-6cb6-4277-b291-85ecfa313d3a", name: "Asgard",
description: "A shining realm of order and power,...}
%Yggdrasil.World{
id: "726b678e-6cb6-4277-b291-85ecfa313d3a",
name: "Asgard",
description: "A shining realm of order and power, suspended high above the clouds.",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
There are a multiple things happening here, so let's unwrap things step by step.
First we start off with our Ash resource that we have defined Yggdrasil.World. In Ash, resources describe the structure of our data, including attributes like name and description, as well as the actions that can be performed on them.
Next we are using the pipe operator. |>, to pass this result to our next function. Elixir’s pipe operator takes the result of the expression on the left and passes it as the first argument to the function on the right.
For example instead of writing:
function(value, a, b)
with the pipe operator we can equivalently write:
value |> function(a, b)
This allows us to write a sequence of operations in a readable, step-by-step style. In our code example, it means Yggdrasil.World is passed into the Ash.Changeset.for_create function, as the first parameter. This function also takes the identifier of our create action :create, as well as the structure representing Asgard, with its name and description.
What this function returns is a changeset, a data structure representing the intended change of a resource in Ash (e.g.: creating, updating, etc). This is especially useful when it comes to validation and error checking, as we will see it later down the line. For now we use this changeset and pipe it into the function that executes the actual creation: Ash.create!().
The resulting value is a %Yggdrasil.World{} struct, which represents the newly created world. Ash also automatically generated a UUID for the id field, which uniquely identifies this world inside the system.
Before returning the struct, Ash logs the operation it performed. That is why we see the debug output:
[debug] Creating Yggdrasil.World
The final line is the Elixir struct that was created:
%Yggdrasil.World{
id: "726b678e-6cb6-4277-b291-85ecfa313d3a",
name: "Asgard",
description: "A shining realm of order and power, suspended high above the clouds.",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
This struct is also stored in the variable asgard, so we can reference it later in the session.
Now that we understand how creating a world works, let’s add another one.
midgard = (
Yggdrasil.World
|> Ash.Changeset.for_create(:create, %{
name: "Midgard",
description: "The realm of humans, bound to the earth and everyday struggles."
})
|> Ash.create!()
)
This follows the exact same pattern as before. We build a changeset that describes the creation of Midgard, and then execute it with Ash.create!(). Much simpler than the mythological creation of Midgard, which involved the slaying of the giant Ymir.
Now that we have some worlds, let's read them using the read action:
worlds = (
Yggdrasil.World
|> Ash.Query.for_read(:read)
|> Ash.read!()
)
which would give us our list of worlds:
[
%Yggdrasil.World{
id: "some-uuid-1",
name: "Asgard",
description: "A shining realm of order and power, suspended high above the clouds."
},
%Yggdrasil.World{
id: "some-uuid-2",
name: "Midgard",
description: "The realm of humans, bound to the earth and everyday struggles."
}
]
As one can expect, we can also do an update call. For example, let’s change the description of Asgard:
asgard = (
asgard
|> Ash.Changeset.for_update(:update, %{
description: "The fortified realm of the Aesir, ruled by Odin."
})
|> Ash.update!()
)
There are a few things to note here. Instead of starting from the Yggdrasil.World module, we now start from the existing asgard struct. This is because we are modifying a resource that already exists.
The function for_update creates a changeset that describes the intended update. Just like with creation, the changeset itself does not perform the update, it only represents the change we want to make.
We then pass this changeset into Ash.update!(), which executes the update. Ash applies the changes, runs any validations, and returns the updated %Yggdrasil.World{} struct.
We can verify the change by reading the list of worlds again:
worlds = (
Yggdrasil.World
|> Ash.Query.for_read(:read)
|> Ash.read!()
)
which would give us a result such as:
[
%Yggdrasil.World{
id: "6b62b3ea-b08b-4387-8539-37e645e53026",
name: "Midgard",
description: "The realm of humans, bound to the earth and everyday struggles.",
__meta__: #Ecto.Schema.Metadata<:loaded>
},
%Yggdrasil.World{
id: "d2646509-6c92-4049-a2db-0555612fc365",
name: "Asgard",
description: "The fortified realm of the Aesir, ruled by Odin.",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
]
An interesting thing we could try out is updating the name of a world instead:
asgard2 = (
asgard
|> Ash.Changeset.for_update(:update, %{
name: "Asgard2"
})
|> Ash.update!()
)
We get the following error:
** (Ash.Error.Invalid)
Invalid Error
* No such input `name` for action Yggdrasil.World.update
The attribute exists on Yggdrasil.World, but is not accepted by Yggdrasil.World.update
Perhaps you meant to add it to the accept list for Yggdrasil.World.update?
Valid Inputs:
* description
This is because when we were defining our update action in our module, the only attribute we accept is :description, see fragment below:
update :update do
accept [:description]
end
In other words, while the name attribute exists on the resource, it is not allowed to be modified through the update action. This is a domain modelling decision, and gives us fine-grained control over how our data can change. In this case, we decided that a world’s name is fixed after creation, while its description can evolve over time.
Finally we get to do delete, where we destroy asgard, our Ragnarok action if you will. We can do this by the following:
Ash.destroy!(asgard)
Ash.destroy! takes a resource struct, in this case asgard, and removes it from the data store. Since we’re using an in-memory ETS store, it deletes it from memory immediately. The function should return :ok on success. We can double check this by requesting our list of worlds again by our usual means:
worlds = (
Yggdrasil.World
|> Ash.Query.for_read(:read)
|> Ash.read!()
)
which returns only Midgard:
[
%Yggdrasil.World{
id: "6b62b3ea-b08b-4387-8539-37e645e53026",
name: "Midgard",
description: "The realm of humans, bound to the earth and everyday struggles.",
__meta__: #Ecto.Schema.Metadata<:loaded>
}
]
At this point, we’ve taken the first steps in modeling our little piece of Yggdrasil. We have a domain, a resource, and a way to create, read, update, and delete worlds, enough to bring about a small Ragnarok.
Next, we will explore how we can start connecting resources together. After all, the worlds need their heroes and villains to really come alive.