我們先來了解 Content 這個 Domain
以及裡面的 Post resource
lib/demo_cms/content.ex:
defmodule DemoCms.Content do
use Ash.Domain, otp_app: :demo_cms
resources do
resource DemoCms.Content.Post
end
end
lib/demo_cms/content/post.ex:
defmodule DemoCms.Content.Post do
use Ash.Resource,
otp_app: :demo_cms,
domain: DemoCms.Content,
data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo DemoCms.Repo
end
actions do
defaults [:read, create: [:title, :body]]
end
attributes do
uuid_primary_key :id
attribute :title, :string do
allow_nil? false
public? true
end
attribute :body, :string do
public? true
end
timestamps()
end
end
還有 config/config.exs 裡的 ash_domains: [DemoCms.Content, DemoCms.Accounts]。
domain 是一群有名字的 resource 集合, 對應 Phoenix 的 context。每個 resource 剛好屬於一個 domain, 沒有被列在某個 domain 的 resources 區塊裡的 resource 不會編譯過。ash_domains 那行 config 是 Ash 的工具 (codegen、admin、API extension) 找 domain 的方式; 它不會去掃 lib/。
之後 domain 也會是這部分 app 對外函式定義的地方。現在它只是一份清單。
useresource 是一個普通的 Elixir module。use Ash.Resource 的選項講的是它屬於誰、開了什麼:
domain: 擁有它的 domaindata_layer: 決定這個 data 要存的地方。這裡是 Postgres 所以使用 AshPostgres.DataLayerAsh.DataLayer.EtsAsh.DataLayer.Simple 等等每一個開起來的東西都可以在檔案裡加自己的區塊postgres do ... end
這個是使用 postgres data layer 才有的, 用來指定哪張表、哪個 repo
之後我們加上別的套件如權限也會有對應的工具可以使用
attributes do
uuid_primary_key :id
attribute :title, :string do
allow_nil? false
public? true
end
attribute :body, :string do
public? true
end
timestamps()
end
attribute 有名字、型別、選項。Ash 內建的型別有 :string、:ci_string、:integer、:boolean、:atom、:utc_datetime、:date、:uuid、:map, 還有 {:array, 型別}; 每種有自己的 constraint。
uuid_primary_key :id 是「一個 :uuid 的 attribute, 當主鍵, create 時由 Ash 產生, 永遠不會是 nil」的簡寫。timestamps() 是 inserted_at 跟 updated_at 的簡寫, 型別 :utc_datetime_usec, 由 Ash 設定, 不是資料庫。
兩個選項承載了大部分的意義:
allow_nil? false 是必填。每次 create 跟 update 都會檢查, 在 migration 裡變成了 null: false。public? true 是外界可以看也可以設。attribute 預設是 private 的, 跟 Ecto schema 相反。private 的 attribute 還是可以被 resource 自己的程式碼設定, 但永遠不能從 action 的 input、表單或 API 進來。id 是 public 的, 因為 uuid_primary_key 就是這樣設的。timestamps 是 private 的: 由 Ash 管理, 外面不應該去設它。用內省看宣告了什麼, 預設值也一起填上:
iex> alias DemoCms.Content.Post
iex> Ash.Resource.Info.attributes(Post) |> Enum.map(&{&1.name, &1.type, &1.allow_nil?, &1.public?})
[
{:id, Ash.Type.UUID, false, true},
{:title, Ash.Type.String, false, true},
{:body, Ash.Type.String, true, true},
{:inserted_at, Ash.Type.UtcDatetimeUsec, false, false},
{:updated_at, Ash.Type.UtcDatetimeUsec, false, false}
]
actions do
defaults [:read, create: [:title, :body]]
end
對 resource 的每一個操作都是 action。沒有別的入口。action 有名字、型別、一串 input, 還有規則。五種型別:
create、update、destroy: 改一筆 record, 在資料庫 transaction 裡read: 回傳一串 recordaction: 泛用 action, 回傳值自訂, 給不屬於上面四種的事情用defaults 產生陽春版。:read 變成一個叫 :read、回傳全部的 action。create: [:title, :body] 變成一個叫 :create 的 action, 它的 accept 清單就是這兩個 attribute; 其他的都不能透過它設定, 不管是不是 public。手寫出來會是:
read :read do
primary? true
end
create :create do
primary? true
accept [:title, :body]
end
primary? true 標記沒有指名 action 時 Ash 會退回去用的那一個。Ash.read(Post) 用 primary read; Ash.create(Post, attrs) 用 primary create。每種型別最多一個。
iex> Ash.Resource.Info.actions(Post) |> Enum.map(&{&1.name, &1.type, &1.primary?})
[{:create, :create, true}, {:read, :read, true}]
iex> Ash.Resource.Info.action(Post, :create).accept
[:title, :body]
:create 跟 :read 目前還沒有用到
之後我們會加上 :publish、:archive 這種 action
而並把對應的商業邏輯寫進去
如發布的規則就會在 :publish 裡面
Ash.create/2 吃 resource 跟 input 的 map, 跑 primary create action:
iex> {:ok, post} = Ash.create(Post, %{title: "Hello, Ash", body: "First post."})
{:ok,
%DemoCms.Content.Post{
id: "882bbad6-760d-4728-9c58-ec34e23100c8",
title: "Hello, Ash",
body: "First post.",
inserted_at: ~U[2026-09-15 16:46:38.945600Z],
updated_at: ~U[2026-09-15 16:46:38.945600Z]
}}
這類函式都有驚嘆號版本, 有錯誤的話會直接 raise 而不是回傳 tuple:
iex> Ash.create!(Post, %{body: "no title"})
** (Ash.Error.Invalid)
* attribute title is required
action 收到不合格的 input 會有錯誤, 不會默默丟掉:
iex> Ash.create!(Post, %{title: "x", status: :published})
** (Ash.Error.Invalid)
* No such input `status` for action DemoCms.Content.Post.create
Valid Inputs:
* title
* body
讀取 Post:
iex> Ash.read!(Post) |> Enum.map(& &1.title)
["Hello, Ash"]
iex> Ash.get!(Post, post.id).title
"Hello, Ash"
Ash.create/2 替我們:
使用 Ash.Changeset.for_create/3 幫某個指名的 action 建 changeset: 把每個 input 照 attribute 型別轉型, 拒絕不認識的 input, 套預設值, 跑這個 action 的規則
在用 Ash.create/1 執行:
iex> cs = Ash.Changeset.for_create(Post, :create, %{title: "Via changeset"})
iex> {cs.valid?, cs.attributes}
{true, %{title: "Via changeset"}}
iex> Ash.create!(cs).title
"Via changeset"
跟 Ecto 一樣的形狀, 先建再套用, 差一件事: 這個 changeset 是從 action 的定義建出來的, 不是從我們寫的函式。等 :create 變成有自己規則的 :publish, for_create 跟 for_update 會跑那些規則, 在資料庫介入之前就回報 valid?: false。
iex> DemoCms.Repo.query!("select id, title from posts").rows
[["882bbad6-760d-4728-9c58-ec34e23100c8", "Hello, Ash"]]
資料表是從 attribute 經過 mix ash.codegen 來的。那一列是普通的 Postgres 資料列, DemoCms.Repo 是普通的 Ecto repo。
我們會盡量使用 Ash 提供的工具,有特殊情況再使用 Ecto 原生的功能