Ash Framework

The Ash Framework is currently the most powerful and productive set of tools in the Phoenix ecosystem for developers. I wrote this book to have written material for my beginner’s training (please write me an email to sw@wintermeyer-consulting.de if you are interested in a training). But sharing is caring so I decided to make most of it available online too.

The Ash Framework does offer a lot of documentation and a vibrant community which is ready to help on Discord. But Discord is not everyone’s cup of tea (some companies don’t allow the usage at all) and the official documentation is often a bit too theoretical.

This beginner’s book provides small copy-and-pastable examples. In addition I publish short tutorial videos - without sound so that you can watch them in the office too - on the @elixir-phoenix-ash YouTube channel.

I do not build one big monolithic application in this book but use smaller examples to describe features. This has the disadvantage that I have to repeat some boilerplate code in each example. But it has the big advantage that you can easily understand each example without the rest of the book. If a specific feature is of no interest for you just skip that section.

Minimal Ash 2.x Setup Guide

There are a couple of steps we need to do for every new mix project which uses Ash. This guide will walk you through those steps. For this book we use the very generic name app for the application. Please use a more meaningful name for your own applications. We keep using App in this guide to make it easier to mix and match seperate parts of this book if you want to.

To use app as the default application name in this book has one major drawback: You have to either rename old project directories or delete them when creating a new one. Alternatively you can change the appname with all examples to something like app2, 'app2', 'foobar', etc.

Let’s start with a fresh Mix project:

$ mix new --sup app
$ cd app

We change the dependency in mix.exs to add Ash (find the latest Ash version number at https://hex.pm/packages/ash):

mix.exs
defp deps do
  [
    {:ash, "~> 2.14.16"} # Add this line
  ]
end

Run mix deps.get to install it:

$ mix deps.get

To make sure that we get the right formatting for our code, we change the file .formatter.exs to include the Ash formatter:

.formatter.exs
[
  # add the next line
  import_deps: [:ash],
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
The Elixir formatter is a great tool to keep your code consistent. It is highly recommended to use run the command mix format before committing your code into a repository.

Since this is a fresh project we need to create a config.exs file:

$ mkdir config
$ touch config/config.exs

And add the configuration for Ash to config/config.exs:

config/config.exs
import Config

config :ash, :use_all_identities_in_manage_relationship?, false

Resource

Ash resources are used to model data and define actions which are used to manipulate that data. In the Ash world we often compare the resources with nouns and the actions with verbs.

To-Do-List Example

To dive into resources we use a simple to-do-list application as an example. As a preperation for this please use the Minimal Ash 2.x Setup Guide to generate a new Elixir application.

We want to create a task resource which has a content attribute and an id attribute as a primary key. We also want to include the actions create, read, update and delete. Ash provides those actions for free but we have to include them into the resource.

In the Ash world a resource needs to be registered to an interal API. Please don’t think of this as an external WebAPI. Ash uses the term API for an internal programming interface. We call our API ToDoList.

Do you like video tutorials? Have a look at the 3 minute video "Ash Resource - Create, Read, Update and Destroy" in our @elixir-phoenix-ash YouTube Channel.

Configure the internal API

We have to add the new internal API to the :ash_apis in our config.exs file.

config/config.exs
import Config

config :ash, :use_all_identities_in_manage_relationship?, false
# Add this line
config :app, :ash_apis, [App.ToDoList]

Now we create the ToDoList API module which contains the resource Task.

lib/app/to_do_list.ex
defmodule App.ToDoList do
  use Ash.Api

  resources do
    resource App.ToDoList.Task
  end
end

For the resource(s) we create a new directory:

$ mkdir -p lib/app/to_do_list/resources

Configure the Resource

The resource defines attributes which are the fields of the resource. In our case we have two attributes: id and content. The id attribute is a special attribute because it is the primary key of the resource. We use the uuid_primary_key macro to define the id attribute as a primary key. The content attribute is a simple string.

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets (1)

  attributes do
    uuid_primary_key :id
    attribute :content, :string
  end
end
1 In this example we use the Ash.DataLayer.Ets as a database layer. ETS (Erlang Term Storage) is an in memory data store which is build into your Erlang system. For our training purpose this is ideal because we don’t have to install and configure a database (e.g. PostgreSQL). But ETS does not save any data to disk! With every restart of iex you have to re-create the example data.

The resulting directory structure should look like this:

$ tree lib
lib
├── app
│   ├── application.ex
│   ├── to_do_list
│   │   └── resources
│   │       └── task.ex
│   └── to_do_list.ex
└── app.ex

4 directories, 4 files

We now have a resource but because we haven’t defined any actions we can’t do anything with it. Let’s add a create action.

Create

To create a resource we have to add the needed create action to the resource. In addition we add a code_interface section to the task resource for some Ash magic which creates a App.ToDoList.Task.create/1 and a App.ToDoList.Task.create!/1 function.

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :content, :string
  end

  actions do
    defaults [:create]
  end

  code_interface do
    define_for App.ToDoList
    define :create
  end
end

Fire up the IEx (Elixir’s Interactive Shell) to create your first task:

$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [erts-14.0.2] [...]

Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create!(%{content: "Mow the lawn"})
#App.ToDoList.Task<
  __meta__: #Ecto.Schema.Metadata<:built, "">,
  id: "8e868c09-c0d0-4362-8270-09272acab769",
  content: "Mow the lawn",
  aggregates: %{},
  calculations: %{},
  ...
>
iex(2)>

The function App.ToDoList.Task.create!/1 raises an error if something goes wrong (e.g. a validation error). Alternatively you can use App.ToDoList.Task.create/1 which returns a tuple with the status and the resource.

iex(2)> App.ToDoList.Task.create(%{content: "Mow the lawn"})
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:built, "">,
   id: "a8430505-ef7e-4f64-bc2c-2a6db216d8ea",
   content: "Mow the lawn",
   aggregates: %{},
   calculations: %{},
   ...
 >}
iex(3)>

You can still create a task the long way with the following code:

App.ToDoList.Task
|> Ash.Changeset.for_create(:create, %{content: "Mow the lawn"})
|> App.ToDoList.create!()

Task.create/1 is just a lot more convenient.

Read

Writing is one thing but it only makes sense if you can read the written data too. To make our life a bit easier we add a read action and a code_interface define for read:

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :content, :string
  end

  actions do
    # add :read here
    defaults [:create, :read]
  end

  code_interface do
    define_for App.ToDoList
    define :create
    # add this line
    define :read
  end
end

Index

To fetch a list of all tasks in the database we can use the App.ToDoList.Task.read!/1 (results in a list) or App.ToDoList.Task.read/1 (results in a tuple with a status and a list) functions. Those are automatically generated by Ash by the code_interface part of the task resource.

$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create!(%{content: "Mow the lawn"})
#App.ToDoList.Task<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "881c6c08-223c-41b1-9d61-2d3a40e478bd",
  content: "Mow the lawn",
  ...
>
iex(2)> App.ToDoList.Task.create!(%{content: "Buy milk"})
#App.ToDoList.Task<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "22b11587-20fe-40d2-830e-50f8930c13c9",
  content: "Buy milk",
  ...
>
iex(3)> App.ToDoList.Task.read! |> Enum.map(& &1.content)
["Buy milk", "Mow the lawn"]
iex(4)> App.ToDoList.Task.read
{:ok,
 [
   #App.ToDoList.Task<
     __meta__: #Ecto.Schema.Metadata<:loaded>,
     id: "22b11587-20fe-40d2-830e-50f8930c13c9",
     content: "Buy milk",
     ...
   >,
   #App.ToDoList.Task<
     __meta__: #Ecto.Schema.Metadata<:loaded>,
     id: "881c6c08-223c-41b1-9d61-2d3a40e478bd",
     content: "Mow the lawn",
     ...
   >
 ]}
iex(5)>

If you have an empty database this is your result for both functions:

$ iex -S mix
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.read!
[]
iex(2)> App.ToDoList.Task.read
{:ok, []}
iex(3)>

Show

Often one wants to fetch a specific set of data by an id. The Ash code_interface has an easy solution for this common scenario.

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :content, :string
  end

  actions do
    defaults [:create, :read]
  end

  code_interface do
    define_for App.ToDoList
    define :create
    define :read
    # add this line
    define :by_id, get_by: [:id], action: :read (1)
  end
end
1 This generates the functions App.ToDoList.Task.by_id/1 and App.ToDoList.Task.by_id!/1

Let’s try it out:

$ iex -S mix
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> alias App.ToDoList.Task
App.ToDoList.Task
iex(2)> Task.read (1)
{:ok, []}
iex(3)> {:ok, task} = Task.create(%{content: "Mow the lawn"})
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
   content: "Mow the lawn",
   ...
 >}
iex(4)> task.id
"a5648b48-4eb3-443d-aba7-fafbbfedc564"
iex(5)> Task.by_id("a5648b48-4eb3-443d-aba7-fafbbfedc564")
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
   content: "Mow the lawn",
   ...
 >}
iex(6)> Task.by_id!("a5648b48-4eb3-443d-aba7-fafbbfedc564")
#App.ToDoList.Task<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "a5648b48-4eb3-443d-aba7-fafbbfedc564",
  content: "Mow the lawn",
  ...
>
1 Just to establish that there are no tasks in the database.

And here an example when there is no task in the database for the given id:

$ iex -S mix
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.by_id("not-in-the-db")
{:error,
 %Ash.Error.Query.NotFound{
   primary_key: nil,
   resource: App.ToDoList.Task,
   changeset: nil,
   query: nil,
   error_context: [],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}
 iex(2)> App.ToDoList.Task.by_id!("not-in-the-db")
 ** (Ash.Error.Query.NotFound) record not found
     [...]

Update

Ash provides a simple way to update a resource and by now you can probably guess how it works:

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :content, :string
  end

  actions do
    # add :update to the list
    defaults [:create, :read, :update]
  end

  code_interface do
    define_for App.ToDoList
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    # add this line
    define :update
  end
end

Let’s try it out:

$ iex -S mix
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> alias App.ToDoList.Task
App.ToDoList.Task
iex(2)> {:ok, task} = Task.create(%{content: "Mow the lawn"})
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
   content: "Mow the lawn",
   ...
 >}
iex(3)> Task.update(task, %{content: "Play golf"})
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
   content: "Play golf",
   ...
 >}
iex(4)> Task.update!(task, %{content: "Buy milk"})
#App.ToDoList.Task<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "d4c8cb9a-10b7-45f4-bece-dcea0fd16e5f",
  content: "Buy milk",
  ...
>

Destroy (delete)

And finally we can destroy a resource. Again, this is very similar to the other actions:

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :content, :string
  end

  actions do
    # add :delete to list
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.ToDoList
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :update
    # Add this line
    define :destroy
  end
end

Let’s try it out:

iex -S mix
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> {:ok, task} = App.ToDoList.Task.create(%{content: "Mow the lawn"})
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "5bd2b15e-fd29-4d3f-9356-cbfe06ea7eee",
   content: "Mow the lawn",
   ...
 >}
iex(2)> App.ToDoList.Task.destroy(task)
:ok
iex(3)> App.ToDoList.Task.by_id(task.id) (1)
{:error,
 %Ash.Error.Query.NotFound{
   primary_key: nil,
   resource: App.ToDoList.Task,
   changeset: nil,
   query: nil,
   error_context: [],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}
iex(4)>
1 Because the task is destroyed we can’t find it anymore. ## Validations

Validation of user input is a key for a smooth running application. Otherwise we end up with faulty datasets in our database. For our example we will add one validation for content to make sure that content will always have a length between 1 and 255 characters. And we add an other attribute priority which is an integer and has to be either nil or between 1 and 3.

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id

    attribute :content, :string do
      allow_nil? false
      constraints min_length: 1, max_length: 255
    end

    attribute :priority, :integer do
      allow_nil? true
      constraints min: 1, max: 3
    end
  end

  actions do
    defaults [:create]
  end

  code_interface do
    define_for App.ToDoList
    define :create
  end
end

Let’s try to create a new task with no content. I use Task.create!/1 and Task.create/1 to show the different output of each function.

$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create()
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.Required{
       field: :content,
       type: :attribute,
       resource: App.ToDoList.Task,
       changeset: nil,
       query: nil,
       error_context: [],
       vars: [],
       path: [],
       stacktrace: #Stacktrace<>,
       class: :invalid
     }
   ],
   stacktraces?: true,
   changeset: #Ash.Changeset<
     api: App.ToDoList,
     action_type: :create,
     action: :create,
     attributes: %{},
     relationships: %{},
     errors: [
       %Ash.Error.Changes.Required{
         field: :content,
         type: :attribute,
         resource: App.ToDoList.Task,
         changeset: nil,
         query: nil,
         error_context: [],
         vars: [],
         path: [],
         stacktrace: #Stacktrace<>,
         class: :invalid
       }
     ],
     data: #App.ToDoList.Task<
       __meta__: #Ecto.Schema.Metadata<:built, "">,
       id: nil,
       content: nil,
       priority: nil,
       ...
     >,
     valid?: false
   >,
   query: nil,
   error_context: [nil],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}
iex(2)> App.ToDoList.Task.create!()
** (Ash.Error.Invalid) Input Invalid

* attribute content is required
    (ash 2.14.16) lib/ash/api/api.ex:2169: Ash.Api.unwrap_or_raise!/3
iex(2)>

Now let’s see what happens when we try to create a task with a valid content but with a priority which is not between 1 and 3.

iex(2)> App.ToDoList.Task.create!(%{content: "Mown the lawn", priority: 10})
** (Ash.Error.Invalid) Input Invalid

* Invalid value provided for priority: must be less than or equal to 3.

10

    (ash 2.14.16) lib/ash/api/api.ex:2169: Ash.Api.unwrap_or_raise!/3
iex(3)>

Defaults

Attributes can have default values. Let’s add a is_done boolean attribute with a default of false and a validation that doesn’t allow nil for this attribute:

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id

    attribute :content, :string do
      allow_nil? false
      constraints min_length: 1, max_length: 255
    end

    attribute :priority, :integer do
      allow_nil? true
      constraints min: 1, max: 3
    end

    attribute :is_done, :boolean do
      allow_nil? false
      default false
    end
  end

  actions do
    defaults [:create]
  end

  code_interface do
    define_for App.ToDoList
    define :create
  end
end

Now we can create a new task without providing a value for is_done:

iex> App.ToDoList.Task.create(%{content: "Mown the lawn"})
{:ok,
 #App.ToDoList.Task<
   __meta__: #Ecto.Schema.Metadata<:built, "">,
   id: "07d5b3f1-b960-4390-8980-5e731251d7af",
   content: "Mown the lawn",
   priority: nil,
   is_done: false,
   aggregates: %{},
   calculations: %{},
   ...
 >}

default_accept

Sometimes a resource as an attribute which we don’t want to have writeble for the user. Ash provides a functionality for this. Within the actions we can use default_accept to define a whitelist of accepted attributes.

In our example application we want to allow the user to create and update the content and priority attributes but not the is_done attribute.

lib/app/to_do_list/resources/task.ex
defmodule App.ToDoList.Task do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  # ...

  actions do
    default_accept [:content, :priority] # add this line
    defaults [:create]
  end

  # ...
end

Should a user try to change the id_done attribute in a create or update the system will not accept it. See the "cannot be changed" message:

$ iex -S mix
Compiling 2 files (.ex)
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> App.ToDoList.Task.create(%{content: "Mow the lawn", is_done: true})
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidAttribute{
       field: :is_done,
       message: "cannot be changed",
       private_vars: nil,
       value: true,
       changeset: nil,
       query: nil,
       error_context: [],
       vars: [],
       path: [],
       stacktrace: #Stacktrace<>,
       class: :invalid
     }
   ],
   stacktraces?: true,
   changeset: #Ash.Changeset<
     api: App.ToDoList,
     action_type: :create,
     action: :create,
     attributes: %{content: "Mow the lawn", is_done: true},
     relationships: %{},
     errors: [
       %Ash.Error.Changes.InvalidAttribute{
         field: :is_done,
         message: "cannot be changed",
         private_vars: nil,
         value: true,
         changeset: nil,
         query: nil,
         error_context: [],
         vars: [],
         path: [],
         stacktrace: #Stacktrace<>,
         class: :invalid
       }
     ],
     data: #App.ToDoList.Task<
       __meta__: #Ecto.Schema.Metadata<:built, "">,
       id: nil,
       content: nil,
       priority: nil,
       is_done: nil,
       aggregates: %{},
       calculations: %{},
       ...
     >,
     valid?: false
   >,
   query: nil,
   error_context: [nil],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}
iex(2)>

Relationships

Relationships define connections between resources. In any application this is the bread and butter of the data modeling.

Setup

We discuss relationships in the context of a simple online shop. Please use Minimal Ash 2.x Setup Guide to generate a new Elixir application. After that include or adapt the following files for a Product resource:

config/config.exs
import Config

config :ash, :use_all_identities_in_manage_relationship?, false
config :app, :ash_apis, [App.Shop]
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Api

  resources do
    resource App.Shop.Product
  end
end
lib/app/shop/resources/product.ex
defmodule App.Shop.Product do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end

belongs_to

The belongs_to macro defines a relationship between two resources.

Do you like video tutorials? Have a look at the video tutorial "belongs_to in 2 minutes" in our @elixir-phoenix-ash YouTube Channel.

In our shop example, a Product belongs to a Category. For that to work we need a new Category resource:

lib/app/shop/resources/category.ex
defmodule App.Shop.Category do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end

And we need to add it to the internal API:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Api

  resources do
    resource App.Shop.Product
    resource App.Shop.Category
  end
end

To configure the belongs_to relationship to Category we add one line to the Product resource:

lib/app/shop/resources/product.ex
defmodule App.Shop.Product do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  relationships do (1)
    belongs_to :category, App.Shop.Category do (2)
      attribute_writable? true (3)
    end
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end
1 The relationships macro defines relationships between resources.
2 The source_attribute is defined as :<relationship_name>_id of the type :uuid on the source resource and the destination_attribute is assumed to be :id. To override those defaults have a look at https://hexdocs.pm/ash/relationships.html and https://ash-hq.org/docs/dsl/ash-resource#relationships-belongs_to
3 By default the attribute category_id is not writable (see https://ash-hq.org/docs/dsl/ash-resource#relationships-belongs_to-attribute_writable-). To make it writable we need to set attribute_writable? to true. Only than we can create a Product with a Category in on call.
$ iex -S mix
Compiling 3 files (.ex)
Generated app app
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> alias App.Shop.Product (1)
App.Shop.Product
iex(2)> alias App.Shop.Category
App.Shop.Category
iex(3)> fruits = Category.create!(%{name: "Fruits"}) (2)
#App.Shop.Category<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
  name: "Fruits",
  ...
>
iex(4)> orange = Product.create!(%{
                   name: "Orange",
                   price: 0.15,
                   category_id: fruits.id
                 }) (3)
#App.Shop.Product<
  category: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0",
  name: "Orange",
  price: Decimal.new("0.15"),
  category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
  ...
>
iex(5)> App.Shop.load(orange, :category) (4)
{:ok,
 #App.Shop.Product<
   category: #App.Shop.Category<
     __meta__: #Ecto.Schema.Metadata<:loaded>,
     id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
     name: "Fruits",
     ...
   >,
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0",
   name: "Orange",
   price: Decimal.new("0.15"),
   category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
   ...
 >}
iex(6)> orange2 = Product.by_name!("Orange", load: [:category])
#App.Shop.Product<
  category: #App.Shop.Category<
    __meta__: #Ecto.Schema.Metadata<:loaded>,
    id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
    name: "Fruits",
    ...
  >,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "6870b44b-67ed-4186-97ed-bbfffd1fc2a0",
  name: "Orange",
  price: Decimal.new("0.15"),
  category_id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
  ...
>
iex(7)> orange2.category
#App.Shop.Category<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "91cb42d8-45c2-451d-8261-72ae4d94a3c6",
  name: "Fruits",
  ...
>
iex(8)> orange2.category.name
"Fruits"
1 Let’s save a bit of typing by creating shorter Aliases.
2 Create a new Category for "Fruits" and store it in the variable fruits.
3 Create a new Product for "Orange" which belongs to the Category "Fruits" and store it in the variable orange.
4 One way to get the Category of a Product if that wasn’t sideloaded initially.
5 Sideload the Category of the Product when fetching The `Product from the database.

has_many

Using the belongs_to example and setup we can now add a has_many relationship to the Category resource:

lib/app/shop/resources/category.ex
defmodule App.Shop.Category do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
  end

  relationships do
    has_many :products, App.Shop.Product (1)
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end
1 The has_many macro defines a relationship between two resources. In our example, a Category has many Products. For that to work we need a Product resource. By default, the source_attribute is assumed to be :id and destination_attribute defaults to <snake_cased_last_part_of_module_name>_id. To override those defaults have a look at https://hexdocs.pm/ash/relationships.html and https://ash-hq.org/docs/dsl/ash-resource#relationships-has_many

Let’s play with the new relationship:

 iex -S mix
Compiling 1 file (.ex)
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> alias App.Shop.Product
App.Shop.Product
iex(2)> alias App.Shop.Category
App.Shop.Category
iex(3)> fruits = Category.create!(%{name: "Fruits"}) (1)
#App.Shop.Category<
  products: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
  name: "Fruits",
  ...
>
iex(4)> Product.create!(%{name: "Orange", category_id: fruits.id}) (2)
#App.Shop.Product<
  category: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "3ec1c834-70a8-403d-8814-3070c77b525e",
  name: "Orange",
  price: nil,
  category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
  ...
>
iex(5)> Product.create!(%{name: "Banana", category_id: fruits.id})
#App.Shop.Product<
  category: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "460d8cfa-2dad-4da0-95db-45012aa33621",
  name: "Banana",
  price: nil,
  category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
  ...
>
iex(6)> App.Shop.load(fruits, :products) (3)
{:ok,
 #App.Shop.Category<
   products: [
     #App.Shop.Product<
       category: #Ash.NotLoaded<:relationship>,
       __meta__: #Ecto.Schema.Metadata<:loaded>,
       id: "3ec1c834-70a8-403d-8814-3070c77b525e",
       name: "Orange",
       price: nil,
       category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
       aggregates: %{},
       calculations: %{},
       ...
     >,
     #App.Shop.Product<
       category: #Ash.NotLoaded<:relationship>,
       __meta__: #Ecto.Schema.Metadata<:loaded>,
       id: "460d8cfa-2dad-4da0-95db-45012aa33621",
       name: "Banana",
       price: nil,
       category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
       aggregates: %{},
       calculations: %{},
       ...
     >
   ],
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
   name: "Fruits",
   aggregates: %{},
   calculations: %{},
   ...
 >}
iex(7)> Category.by_name!("Fruits", load: [:products]) (4)
#App.Shop.Category<
  products: [
    #App.Shop.Product<
      category: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "3ec1c834-70a8-403d-8814-3070c77b525e",
      name: "Orange",
      price: nil,
      category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
      ...
    >,
    #App.Shop.Product<
      category: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "460d8cfa-2dad-4da0-95db-45012aa33621",
      name: "Banana",
      price: nil,
      category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
      ...
    >
  ],
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
  name: "Fruits",
  ...
>
1 We create a category for fruits.
2 We create two products and assign them to the fruits category.
3 We load the products for the fruits category.
4 We sideload all the products for the fruits category.

has_one

I do not know if I ever used has_one in a real world application. But for the sake of completeness, I pull out an example out of thin air for this.

has_one is similar to belongs_to except that the reference attribute is on the destination resource, instead of the source.

Let’s assume we run special promotions in our shop (so and so many percent rebate off). But each product can only have one promotion and each promotion can only be used for one product. I know! It is just an example for has_one.

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Api

  resources do
    resource App.Shop.Product
    resource App.Shop.Category
    resource App.Shop.Promotion
  end
end
lib/app/shop/resources/promotion.ex
defmodule App.Shop.Promotion do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :rebate, :integer
    attribute :product_id, :uuid
  end

  relationships do
    belongs_to :product, App.Shop.Product do
      attribute_writable? true
    end
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end
lib/app/shop/resources/product.ex
defmodule App.Shop.Product do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  relationships do
    belongs_to :category, App.Shop.Category do
      attribute_writable? true
    end

    has_one :promotion, App.Shop.Promotion
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end

Let’s use it in the iex console:

$ iex -S mix
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> alias App.Shop.Product
App.Shop.Product
iex(2)> alias App.Shop.Promotion
App.Shop.Promotion
iex(3)> orange = Product.create!(%{name: "Orange", price: 0.2})
#App.Shop.Product<
  promotion: #Ash.NotLoaded<:relationship>,
  category: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
  name: "Orange",
  price: Decimal.new("0.2"),
  category_id: nil,
  ...
>
iex(4)> {:ok, promotion} = Promotion.create(%{name: "15% off", rebate: 15, product_id: orange.id})
{:ok,
 #App.Shop.Promotion<
   product: #Ash.NotLoaded<:relationship>,
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "68901cef-f2c5-46bb-a737-d6c248d36347",
   name: "15% off",
   rebate: 15,
   product_id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
   ...
 >}
iex(5)> App.Shop.load(orange, :promotion) (1)
{:ok,
 #App.Shop.Product<
   promotion: #App.Shop.Promotion<
     product: #Ash.NotLoaded<:relationship>,
     __meta__: #Ecto.Schema.Metadata<:loaded>,
     id: "68901cef-f2c5-46bb-a737-d6c248d36347",
     name: "15% off",
     rebate: 15,
     product_id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
     ...
   >,
   category: #Ash.NotLoaded<:relationship>,
   __meta__: #Ecto.Schema.Metadata<:loaded>,
   id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
   name: "Orange",
   price: Decimal.new("0.2"),
   category_id: nil,
   ...
 >}
1 By default the promotion is not sideloaded. We have to load it manually.
2 This is the way to sideload the promotion relationship.

many_to_many

many_to_many is a special case of has_many where the relationship is symmetric. That means that the relationship is defined on both sides of the relationship. A good example is a Tag Resource that can be assigned to multiple Product Resources and a Product Resource can have multiple Tag Resources assigned to it.

For this to work we not just need a Tag resource but also a ProductTag which is the join table between Tag and Product.

lib/app/shop/resources/tag.ex
defmodule App.Shop.Tag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
  end

  relationships do
    many_to_many :products, App.Shop.Product do
      through App.Shop.ProductTag
      source_attribute_on_join_resource :tag_id
      destination_attribute_on_join_resource :product_id
    end
  end

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      primary? true
      argument :products, {:array, :map}
      change manage_relationship(:products, type: :append_and_remove, on_no_match: :create)
    end
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end
lib/app/shop/resources/product_tag.ex
defmodule App.Shop.ProductTag do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  actions do
    defaults [:create, :read, :update, :destroy]
  end

  relationships do
    belongs_to :product, App.Shop.Product do
      primary_key? true
      allow_nil? false
    end

    belongs_to :tag, App.Shop.Tag do
      primary_key? true
      allow_nil? false
    end
  end
end
lib/app/shop/resources/product.ex
defmodule App.Shop.Product do
  use Ash.Resource, data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :name, :string
    attribute :price, :decimal
  end

  relationships do
    many_to_many :tags, App.Shop.Tag do
      through App.Shop.ProductTag
      source_attribute_on_join_resource :product_id
      destination_attribute_on_join_resource :tag_id
    end
  end

  actions do
    defaults [:read, :update, :destroy]

    create :create do
      primary? true
      argument :tags, {:array, :map}
      change manage_relationship(:tags, type: :append_and_remove, on_no_match: :create)
    end
  end

  code_interface do
    define_for App.Shop
    define :create
    define :read
    define :by_id, get_by: [:id], action: :read
    define :by_name, get_by: [:name], action: :read
    define :update
    define :destroy
  end
end
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Api

  resources do
    resource App.Shop.Product
    resource App.Shop.ProductTag
    resource App.Shop.Tag
  end
end

Let’s use it in the iex console:

$ iex -S mix
Compiling 3 files (.ex)
Erlang/OTP 26 [...]

Interactive Elixir (1.15.5) [...]
iex(1)> good_deal_tag = App.Shop.Tag.create!(%{name: "Good deal"}) (1)
#App.Shop.Tag<
  products: #Ash.NotLoaded<:relationship>,
  products_join_assoc: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
  name: "Good deal",
  ...
>
iex(2)> yellow_tag = App.Shop.Tag.create!(%{name: "Yellow"}) (2)
#App.Shop.Tag<
  products: #Ash.NotLoaded<:relationship>,
  products_join_assoc: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
  name: "Yellow",
  ...
>
iex(3)> App.Shop.Product.create!(%{
            name: "Banana",
            tags: [good_deal_tag, yellow_tag]
            }) (3)
#App.Shop.Product<
  tags: [
    #App.Shop.Tag<
      products: #Ash.NotLoaded<:relationship>,
      products_join_assoc: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
      name: "Good deal",
      ...
    >,
    #App.Shop.Tag<
      products: #Ash.NotLoaded<:relationship>,
      products_join_assoc: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
      name: "Yellow",
      ...
    >
  ],
  tags_join_assoc: #Ash.NotLoaded<:relationship>,
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "d3551abf-cf43-4c7f-94f1-ea7228d87cf1",
  name: "Banana",
  price: nil,
  ...
>
iex(4)> App.Shop.Product.by_name!("Banana", load: [:tags]) (4)
#App.Shop.Product<
  tags: [
    #App.Shop.Tag<
      products: #Ash.NotLoaded<:relationship>,
      products_join_assoc: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "82b7e8af-69b9-4f35-b32a-0b6b2bed1d15",
      name: "Good deal",
      ...
    >,
    #App.Shop.Tag<
      products: #Ash.NotLoaded<:relationship>,
      products_join_assoc: #Ash.NotLoaded<:relationship>,
      __meta__: #Ecto.Schema.Metadata<:loaded>,
      id: "d04aa5ef-195e-4dd8-9c5a-5c73e6f44afe",
      name: "Yellow",
      ...
    >
  ],
  ...
  ],
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "d3551abf-cf43-4c7f-94f1-ea7228d87cf1",
  name: "Banana",
  price: nil,
  ...
>
1 Create a tag named "Good deal".
2 Create a tag named "Yellow".
3 Create a product named "Banana" and associate it with the two tags.
4 Retrieve the product by name and sideload its tags.