Relationships

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

In Ash 3.0, relationships are defined within resources that are part of a domain. This allows Ash to effectively manage and navigate the connections between your data.

Setup

We discuss relationships in the context of a simple online shop. To get started, create a new application using Igniter:

$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash
$ cd app

Alternatively, you can follow the Ash Setup Guide for other setup options.

After setting up your application, create the following files for a Product resource:

config/config.exs
import Config

config :app, :ash_domains, [App.Shop]
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

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

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true (1)
    attribute :price, :decimal, public?: true
  end

  actions do
    default_accept [:name, :price] (2)
    defaults [:create, :read, :update, :destroy]
  end
end
1 Ash 3.x attributes are private by default; mark the ones you want writable or exposed as public?: true.
2 default_accept is the whitelist of attributes the default CRUD actions will accept as input.

Now let’s add code interface definitions in our domain to make working with the resource easier:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :read_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
  end
end

belongs_to

The belongs_to macro defines a relationship between two resources. In our shop example, a Product belongs to a Category.

+----------+      +-------------+
| Category |      | Product     |
+----------+      +-------------+
| id       |<-----| category_id |
| name     |      | id          |
|          |      | name        |
|          |      | price       |
+----------+      +-------------+

We need a new Category resource:

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

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true
  end

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

And we need to add code interface definitions to our domain:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :read_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end

    resource App.Shop.Category do
      define :create_category, action: :create
      define :read_categories, action: :read
      define :get_category_by_id, action: :read, get_by: :id
      define :get_category_by_name, action: :read, get_by: :name
      define :update_category, action: :update
      define :destroy_category, action: :destroy
    end
  end
end

To configure the belongs_to relationship to Category we add a relationships block to the Product resource:

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

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

  relationships do (1)
    belongs_to :category, App.Shop.Category do (2)
      allow_nil? false (3)
      public? true (4)
    end
  end

  actions do
    default_accept [:name, :price, :category_id] (5)
    defaults [:create, :read, :update, :destroy]
  end
end
1 The relationships macro defines relationships between resources.
2 By convention Ash creates a source attribute named <relationship>_id (category_id here) of type :uuid, matching the :id primary key on the destination. See https://hexdocs.pm/ash/relationships.html if you need to override those defaults.
3 By default category_id can be nil. Setting allow_nil? to false makes the relationship required.
4 In Ash 3.x relationships are also private by default. Make them public?: true when you want the outside world to see them (including API extensions such as AshJsonApi or AshGraphql).
5 The default create/update actions need to accept the foreign key for the relationship to be settable through the code interface.

Let’s test this in the iex:

$ iex -S mix
iex(1)> # Create a new category
iex(2)> {:ok, fruits} = App.Shop.create_category(%{name: "Fruits"})
{:ok,
 %App.Shop.Category{
   id: "cea38a50-2178-42c0-aad9-2624eec49609",
   name: "Fruits",
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}
iex(3)> # Create a new product in the "Fruits" category
iex(4)> {:ok, orange} = App.Shop.create_product(%{
                  name: "Orange",
                  price: Decimal.new("0.15"),
                  category_id: fruits.id
                })
{:ok,
 %App.Shop.Product{
   id: "fc36e094-a28d-4f78-860d-0fd1e345c1ce",
   name: "Orange",
   price: Decimal.new("0.15"),
   category_id: "cea38a50-2178-42c0-aad9-2624eec49609",
   category: #Ash.NotLoaded<:relationship, field: :category>,
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}
iex(5)> # Load the category relationship for the orange product
iex(6)> {:ok, orange_with_category} = Ash.load(orange, :category)
{:ok,
 %App.Shop.Product{
   id: "fc36e094-a28d-4f78-860d-0fd1e345c1ce",
   name: "Orange",
   price: Decimal.new("0.15"),
   category_id: "cea38a50-2178-42c0-aad9-2624eec49609",
   category: %App.Shop.Category{
     id: "cea38a50-2178-42c0-aad9-2624eec49609",
     name: "Fruits",
     __meta__: #Ecto.Schema.Metadata<:loaded>
   },
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}
iex(7)> # Fetch a product with its category pre-loaded
iex(8)> {:ok, orange2} = App.Shop.get_product_by_name("Orange", load: [:category])
{:ok,
 %App.Shop.Product{
   id: "fc36e094-a28d-4f78-860d-0fd1e345c1ce",
   name: "Orange",
   price: Decimal.new("0.15"),
   category_id: "cea38a50-2178-42c0-aad9-2624eec49609",
   category: %App.Shop.Category{
     id: "cea38a50-2178-42c0-aad9-2624eec49609",
     name: "Fruits",
     __meta__: #Ecto.Schema.Metadata<:loaded>
   },
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}
iex(9)> orange2.category.name
"Fruits"
Ash 3.24+ inspects resources as plain %Module{} structs (the full set of attributes is visible). Older Ash tutorials may still show the opaque #Module<…​> form produced by an earlier custom Inspect implementation.

Sideload a belongs_to Relationship by Default

In case you always want to sideload the Category of the Product without adding load: [:category] to every call, you can customize the read action:

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

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

  relationships do
    belongs_to :category, App.Shop.Category do
      allow_nil? false
      public? true
    end
  end

  actions do
    default_accept [:name, :price, :category_id]
    defaults [:create, :update, :destroy] (1)

    read :read do
      primary? true (2)
      prepare build(load: [:category]) (3)
    end
  end
end
1 Don’t include :read in the defaults when you add a custom read action.
2 This marks this action as the primary read action for the resource.
3 The prepare step always sideloads the Category when fetching a Product.

Let’s test it in the iex:

iex(10)> {:ok, orange} = App.Shop.get_product_by_name("Orange")
{:ok,
 %App.Shop.Product{
   id: "24348935-6148-4c75-9bf1-55f74ac9397a",
   name: "Orange",
   price: Decimal.new("0.15"),
   category_id: "22ab0824-18ac-4daa-9a13-defd0b8bcd73",
   category: %App.Shop.Category{
     id: "22ab0824-18ac-4daa-9a13-defd0b8bcd73",
     name: "Fruits",
     __meta__: #Ecto.Schema.Metadata<:loaded>
   },
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}

Note how the category is automatically loaded even though we didn’t specify load: [:category].

has_many

has_many is the mirror image of belongs_to. In our shop example a Category has many Products.

+----------+      +-------------+
| Category |      | Product     |
+----------+      +-------------+
| id       |----->| category_id |
| name     |      | id          |
|          |      | name        |
|          |      | price       |
+----------+      +-------------+

Continuing the belongs_to setup, add a has_many to the Category resource:

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

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true
  end

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

  actions do
    default_accept [:name]
    defaults [:create, :read, :update, :destroy]
  end
end
1 By default Ash uses the source attribute :id on this side, and category_id on the destination. To override those, see https://hexdocs.pm/ash/relationships.html.

Let’s play with the relationship:

$ iex -S mix
Compiling 1 file (.ex)
Erlang/OTP 28 [erts-16.2.2] [...]

Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> {:ok, fruits} = App.Shop.create_category(%{name: "Fruits"}) (1)
{:ok,
 %App.Shop.Category{
   id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
   name: "Fruits",
   products: #Ash.NotLoaded<:relationship>,
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}
iex(2)> App.Shop.create_product!(%{name: "Orange", category_id: fruits.id}) (2)
%App.Shop.Product{
  id: "3ec1c834-70a8-403d-8814-3070c77b525e",
  name: "Orange",
  price: nil,
  category_id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
  __meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(3)> App.Shop.create_product!(%{name: "Banana", category_id: fruits.id})
%App.Shop.Product{
  id: "460d8cfa-2dad-4da0-95db-45012aa33621",
  name: "Banana",
  ...
}
iex(4)> Ash.load(fruits, :products) (3)
{:ok,
 %App.Shop.Category{
   id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
   name: "Fruits",
   products: [
     %App.Shop.Product{name: "Orange", ...},
     %App.Shop.Product{name: "Banana", ...}
   ],
   __meta__: #Ecto.Schema.Metadata<:loaded>
 }}
iex(5)> App.Shop.get_category_by_name!("Fruits", load: [:products]) (4)
%App.Shop.Category{
  id: "c77919cf-0a28-4394-96f1-28f70f1d748a",
  name: "Fruits",
  products: [
    %App.Shop.Product{name: "Orange", ...},
    %App.Shop.Product{name: "Banana", ...}
  ],
  __meta__: #Ecto.Schema.Metadata<:loaded>
}
1 Create a category for fruits.
2 Create two products in that category.
3 Load the products for an already-fetched category with Ash.load/2.
4 Or, equivalently, pass load: [:products] to the read interface so the association comes back pre-loaded.

Sideload the products by default

If you always want products loaded whenever you fetch a category, customize the read action:

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

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true
  end

  relationships do
    has_many :products, App.Shop.Product do
      public? true
    end
  end

  actions do
    default_accept [:name]
    defaults [:create, :update, :destroy] (1)

    read :read do
      primary? true (2)
      prepare build(load: [:products]) (3)
    end
  end
end
1 Drop :read from the defaults list when you add a custom :read action.
2 Mark the action as the primary read so it is used when no other action is specified.
3 Always preload products whenever we fetch a Category.

Let’s test it in the iex:

iex(17)> App.Shop.get_category_by_name!("Fruits").products |> Enum.map(& &1.name)
["Orange", "Banana"]

many_to_many

many_to_many is a symmetric relationship: each record on one side can be linked to many records on the other, and vice versa. Classic example: a Tag resource can be attached to many Product resources, and each Product can carry many Tag resources.

Under the hood Ash uses a join resource (sometimes called a join table) that holds one row per link.

+---------+     +------------+     +--------+
| Product |     | ProductTag |     | Tag    |
+---------+     +------------+     +--------+
| id      |<--->| product_id |     | name   |
| name    |     | tag_id     |<--->| id     |
| price   |     |            |     |        |
+---------+     +------------+     +--------+

Setup

Setting Up a Fresh Ash App

The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.

$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app

For a Phoenix application with Ash and PostgreSQL support:

$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app

Igniter creates the App.Repo wrapper (when ash_postgres is included), the application’s supervision tree, a sensible .formatter.exs, and the Postgres config for every environment. You still need to add the config :app, :ash_domains, [App.Shop] line to config/config.exs once you define your first domain, which we do in each chapter that uses this include.

Interactive web installer

You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.

Manual setup alternative

If you want to see every file Igniter would create, walk through the Ash Setup Guide. For a purely manual route, mix new --sup app, add {:ash, "~> 3.0"} to mix.exs, run mix deps.get, and configure .formatter.exs with import_deps: [:ash]. Everything else in this chapter then matches the Igniter flow.

We need three resources:

  • Tag

  • Product

  • ProductTag (the join between the two)

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

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true
  end

  relationships do
    many_to_many :products, App.Shop.Product do
      through App.Shop.ProductTag (1)
      source_attribute_on_join_resource :tag_id
      destination_attribute_on_join_resource :product_id
      public? true
    end
  end

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

    create :create do
      primary? true
      accept [:name]
      argument :products, {:array, :map} (2)

      change manage_relationship(:products,
               type: :append_and_remove,
               on_no_match: :create
             ) (3)
    end
  end
end
1 Tell Ash which resource stores the join rows.
2 Adding a products argument to the create action lets callers pass existing products when creating a new tag: App.Shop.create_tag!(%{name: "Sweet", products: [apple, cherry]}).
3 manage_relationship/2 translates that argument into add/remove operations on the ProductTag join resource.
lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

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

  relationships do
    many_to_many :tags, App.Shop.Tag do (1)
      through App.Shop.ProductTag
      source_attribute_on_join_resource :product_id
      destination_attribute_on_join_resource :tag_id
      public? true
    end
  end

  actions do
    default_accept [:name, :price]
    defaults [:read, :update, :destroy]

    create :create do
      primary? true
      accept [:name, :price]
      argument :tags, {:array, :map} (2)

      change manage_relationship(:tags,
               type: :append_and_remove,
               on_no_match: :create
             )
    end
  end
end
1 Mirror of the many_to_many from the Tag resource.
2 tags: [sweet, tropical] on create will attach those tags.
lib/app/shop/product_tag.ex
defmodule App.Shop.ProductTag do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  actions do
    defaults [:create, :read, :destroy] (1)
  end

  relationships do (2)
    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
1 Join rows are effectively immutable: you delete and recreate them instead of updating, so no :update action.
2 Two belongs_to relationships form the composite primary key.

Finally register all three resources with the domain:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :list_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end

    resource App.Shop.Tag do
      define :create_tag, action: :create
      define :list_tags, action: :read
      define :get_tag_by_id, action: :read, get_by: :id
      define :get_tag_by_name, action: :read, get_by: :name
      define :update_tag, action: :update
      define :destroy_tag, action: :destroy
    end

    resource App.Shop.ProductTag
  end
end

Example in iex

Let us work with a small catalogue.

Ash uses UUIDs for primary keys. The integers in the diagram below are just for readability.
Product:              Tag:
+----+--------+       +----+----------+
| id | name   |       | id | Name     |
+----+--------+       +----+----------+
| 1  | Apple  |       | 1  | Sweet    |
| 2  | Banana |       | 2  | Tropical |
| 3  | Cherry |       | 3  | Red      |
+----+--------+       +----+----------+

ProductTag:
+-----------+-------+
| product_id| tag_id|
+-----------+-------+
| 1         | 1     |  (Apple is Sweet)
| 1         | 3     |  (Apple is Red)
| 2         | 1     |  (Banana is Sweet)
| 2         | 2     |  (Banana is Tropical)
| 3         | 3     |  (Cherry is Red)
+-----------+-------+

Create the catalogue in iex:

$ iex -S mix
iex(1)> sweet = App.Shop.create_tag!(%{name: "Sweet"})
iex(2)> tropical = App.Shop.create_tag!(%{name: "Tropical"})
iex(3)> red = App.Shop.create_tag!(%{name: "Red"})
iex(4)> App.Shop.create_product!(%{name: "Apple", tags: [sweet, red]})
iex(5)> App.Shop.create_product!(%{name: "Banana", tags: [sweet, tropical]})
iex(6)> App.Shop.create_product!(%{name: "Cherry", tags: [red]})

Now fetch products with their tags, and tags with their products:

iex(7)> App.Shop.list_products!(load: [:tags])
...     |> Enum.map(fn p -> %{product: p.name, tags: Enum.map(p.tags, & &1.name)} end)
[
  %{product: "Apple", tags: ["Sweet", "Red"]},
  %{product: "Banana", tags: ["Sweet", "Tropical"]},
  %{product: "Cherry", tags: ["Red"]}
]

iex(8)> App.Shop.list_tags!(load: [:products])
...     |> Enum.map(fn t -> %{tag: t.name, products: Enum.map(t.products, & &1.name)} end)
[
  %{tag: "Sweet", products: ["Apple", "Banana"]},
  %{tag: "Tropical", products: ["Banana"]},
  %{tag: "Red", products: ["Apple", "Cherry"]}
]

Sideload the join by default

By default Ash does not load the related records. Make the read action pre-load them if you always need them:

lib/app/shop/product.ex
  actions do
    default_accept [:name, :price]
    defaults [:update, :destroy] (1)

    read :read do
      primary? true
      prepare build(load: [:tags]) (2)
    end

    create :create do
      primary? true
      accept [:name, :price]
      argument :tags, {:array, :map}

      change manage_relationship(:tags,
               type: :append_and_remove,
               on_no_match: :create
             )
    end
  end
1 Remove :read from the defaults when you define a custom read.
2 Always load :tags on read.

Do the equivalent on App.Shop.Tag to auto-load products. After that, the same iex session works without the explicit load: option:

iex(9)> App.Shop.list_products!()
...     |> Enum.map(fn p -> %{product: p.name, tags: Enum.map(p.tags, & &1.name)} end)
[
  %{product: "Apple", tags: ["Sweet", "Red"]},
  %{product: "Banana", tags: ["Sweet", "Tropical"]},
  %{product: "Cherry", tags: ["Red"]}
]

Updating many_to_many relationships

The natural place to reassign tags is on the product’s :update action. Duplicate the managed-relationship change from the create:

lib/app/shop/product.ex
  actions do
    default_accept [:name, :price]
    defaults [:read, :destroy]

    create :create do
      primary? true
      accept [:name, :price]
      argument :tags, {:array, :map}

      change manage_relationship(:tags,
               type: :append_and_remove,
               on_no_match: :create
             )
    end

    update :update do
      primary? true
      accept [:name, :price]
      argument :tags, {:array, :map}

      change manage_relationship(:tags,
               type: :append_and_remove,
               on_no_match: :create
             )
    end
  end

iex session:

$ iex -S mix
iex(1)> good_deal = App.Shop.create_tag!(%{name: "Good deal"})
iex(2)> yellow = App.Shop.create_tag!(%{name: "Yellow"})
iex(3)> App.Shop.create_product!(%{name: "Banana", tags: [yellow, good_deal]}) (1)
iex(4)> App.Shop.get_product_by_name!("Banana", load: [:tags]).tags |> Enum.map(& &1.name) (2)
["Yellow", "Good deal"]
iex(5)> banana = App.Shop.get_product_by_name!("Banana") (3)
iex(6)> App.Shop.update_product!(banana, %{tags: [yellow]}) (4)
iex(7)> App.Shop.get_product_by_name!("Banana", load: [:tags]).tags |> Enum.map(& &1.name) (5)
["Yellow"]
1 Create a Banana with two tags.
2 Verify the tags were attached.
3 Hold the banana in a variable for the update.
4 Reduce the tag list to just yellow.
5 The update removed the "Good deal" link; the join resource gets the stale row deleted automatically.

Unique tag names

Right now you can create several tags with the same name:

iex(1)> App.Shop.create_tag!(%{name: "Yellow"}).id
"d206b758-..."
iex(2)> App.Shop.create_tag!(%{name: "Yellow"}).id
"5d66386c-..."

Fix that with an identities block:

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

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true
  end

  identities do
    # identity :unique_name, [:name] (1)

    identity :name, [:name] do (2)
      pre_check_with App.Shop (3)
    end
  end

  # rest of resource unchanged
end
1 If you use AshPostgres, this one line is enough; the unique constraint is enforced by the database. Run mix ash.codegen to create the matching migration.
2 The ETS data layer has no unique indexes, so we spell out the identity and tell Ash how to check it.
3 pre_check_with tells Ash to run a lookup via App.Shop before writing, so duplicates fail with a clean changeset error instead of a runtime crash.

Now duplicates are rejected with a friendly error:

iex(1)> App.Shop.create_tag!(%{name: "Yellow"})
%App.Shop.Tag{id: "f03e163f-...", name: "Yellow", ...}
iex(2)> App.Shop.create_tag!(%{name: "Yellow"})
** (Ash.Error.Invalid) Input Invalid

* name: has already been taken

add_tag action

Sometimes you want to create a product and a brand-new tag in a single call:

lib/app/shop/product.ex
  actions do
    default_accept [:name, :price]
    defaults [:read, :destroy, :update]

    create :create do
      primary? true
      accept [:name, :price]
      argument :tags, {:array, :map}

      argument :add_tag, :map do
        allow_nil? true
      end

      change manage_relationship(:tags,
               type: :append_and_remove,
               on_no_match: :create
             )

      change manage_relationship(
               :add_tag,
               :tags,
               type: :create
             )
    end
  end

Try it:

iex(1)> App.Shop.create_product!(%{name: "Banana", add_tag: %{name: "Yellow"}})
%App.Shop.Product{
  id: "52049582-...",
  name: "Banana",
  price: nil,
  tags: [
    %App.Shop.Tag{id: "9b95f8cf-...", name: "Yellow", ...}
  ],
  ...
}

has_one

has_one is similar to belongs_to, with one twist: the foreign-key column lives on the destination resource instead of the source, and uniqueness on that column means the relationship is always 1:1.

Imagine our shop has occasional promotions: each product can have at most one active promotion, and each promotion applies to exactly one product.

+---------+      +------------+
| Product |      | Promotion  |
+---------+      +------------+
| id      |----->| product_id |
| name    |      | id         |
| price   |      | rebate     |
+---------+      +------------+

Update the domain to register the new resource:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :get_product_by_name, action: :read, get_by: :name
    end

    resource App.Shop.Category
    resource App.Shop.Promotion do
      define :create_promotion, action: :create
    end
  end
end

Define the Promotion resource with a belongs_to :product:

lib/app/shop/promotion.ex
defmodule App.Shop.Promotion do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true
    attribute :rebate, :integer, public?: true
  end

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

  actions do
    default_accept [:name, :rebate, :product_id]
    defaults [:create, :read, :update, :destroy]
  end
end

And add has_one :promotion on the Product resource:

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

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

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

    has_one :promotion, App.Shop.Promotion do (1)
      public? true
    end
  end

  actions do
    default_accept [:name, :price, :category_id]
    defaults [:create, :read, :update, :destroy]
  end
end
1 has_one uses the same <relationship>_id convention as belongs_to, but the column lives on Promotion, not on Product.

Let’s use it in iex:

$ iex -S mix
Erlang/OTP 28 [erts-16.2.2] [...]

Interactive Elixir (1.20.0-rc.4) [...]
iex(1)> orange = App.Shop.create_product!(%{name: "Orange", price: Decimal.new("0.2")})
%App.Shop.Product{
  id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
  name: "Orange",
  price: Decimal.new("0.2"),
  category_id: nil,
  category: #Ash.NotLoaded<:relationship, field: :category>,
  promotion: #Ash.NotLoaded<:relationship, field: :promotion>,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)> {:ok, _promo} =
...(2)>   App.Shop.create_promotion(%{
...(2)>     name: "15% off",
...(2)>     rebate: 15,
...(2)>     product_id: orange.id
...(2)>   })
{:ok, %App.Shop.Promotion{...rebate: 15, product_id: "c9e9b4ba-..."}}
iex(3)> Ash.load(orange, :promotion) (1)
{:ok,
 %App.Shop.Product{
   id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
   name: "Orange",
   promotion: %App.Shop.Promotion{
     id: "68901cef-...",
     name: "15% off",
     rebate: 15,
     product_id: "c9e9b4ba-408f-4c42-b1e0-e8b3799d5b1f",
     ...
   },
   ...
 }}
1 Associations are lazy; call Ash.load/2 (or pass load: […​] to a read) when you need the related record.

has_one is surprisingly rare in practice. Many relationships that look like 1:1 at first (user and profile, product and default warehouse) are cleaner modelled by putting the attributes directly on the parent resource, or by using belongs_to from the side that actually references the other. Reach for has_one only when the destination truly wants its own identity and lifecycle.