Help us improve
Share bugs, ideas, or general feedback.
Ecto patterns for Postgres-backed Elixir/Phoenix apps: schemas, changesets (per-operation, composition, validations), associations, cast_assoc/cast_embed, Ecto.Multi, transactions, migrations, and query performance (N+1, indexes).
npx claudepluginhub ariesclark/skills --plugin elixir-phoenixHow this skill is triggered — by the user, by Claude, or both
Slash command
/elixir-phoenix:ectoWhen to use
Use when writing or reviewing Ecto schemas, changesets, associations (`cast_assoc`/`cast_embed`), `Ecto.Multi` transactions, migrations, or `Repo`/query code, including preloads, N+1, and indexing.
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Pairs with `elixir-conventions`. Database errors a caller can act on (validation, conflict) are values; anything that "can't happen" should crash.
Runs automated patient safety test suites for healthcare deployments, blocking on CRITICAL failures in CDSS accuracy, PHI exposure, and data integrity.
Share bugs, ideas, or general feedback.
Pairs with elixir-conventions. Database errors a caller can act on (validation, conflict) are values; anything that "can't happen" should crash.
registration_changeset, profile_changeset, admin_changeset: each casts only its own fields. Don't toggle behavior with option flags.cast the fields you accept; never cast everything. The cast allowlist is your mass-assignment boundary.unsafe_validate_unique with a DB unique_constraint. The first gives a friendly form error; the second is the source of truth that catches the concurrent insert the validation can't see.cast_assoc/cast_embed require the association preloaded on the struct you're updating, and a changeset on the child that casts its own fields (including any required FKs). Set on_replace: explicitly.Ecto.Multi, not nested Repo calls. You get one transaction and a {:error, failed_step, value, changes_so_far} you can branch on.Repo.insert!/Repo.get! etc. when a failure means a bug, not a user-facing error (see elixir-conventions §7 and §8).# Don't: defensive option-juggling inside one changeset
def changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> then(fn cs -> if Keyword.get(opts, :validate_unique, true), do: unsafe_validate_unique(cs, :email, Repo), else: cs end)
end
# Do: one changeset per operation; the caller picks
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
|> validate_format(:email, ~r/@/)
|> unsafe_validate_unique(:email, Repo)
|> unique_constraint(:email)
end
Compose shared validation as plain changeset->changeset functions and pipe them; don't reach for with/else inside changesets.
order
|> Repo.preload(:line_items) # required before cast_assoc
|> Ecto.Changeset.cast(attrs, [:status])
|> Ecto.Changeset.cast_assoc(:line_items,
with: &LineItem.changeset/2,
on_replace: :delete) # be explicit: :delete | :nilify | :raise
cast_assoc when parent and children share a lifecycle and arrive in one payload. When they have independent lifecycles, manage them separately with Ecto.Multi instead.cast_embed for embedded schemas (no separate table); same preload/on_replace discipline.Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(%Order{}, attrs))
|> Ecto.Multi.insert_all(:items, LineItem, &build_items(&1.order, attrs))
|> Ecto.Multi.run(:charge, fn _repo, %{order: order} -> Billing.charge(order) end)
|> Repo.transaction()
|> case do
{:ok, %{order: order}} -> {:ok, order}
{:error, :charge, reason, _changes} -> {:error, reason} # branch only on steps you can act on
end
Don't write a generic catch-all else over every step. Match the steps whose failure the caller can handle; let genuinely unexpected failures raise.
create index(...) on FKs and filter/sort columns. Consider concurrently: true (with @disable_ddl_transaction true) for large tables.Repo.all(from u in User, where: ..., preload: [:posts]) over looping Repo.preload per row.select: to avoid loading whole rows when you need a few fields; Repo.aggregate/3 for counts/sums.Run mix format, mix compile --warnings-as-errors, and your migrations against a scratch DB (mix ecto.migrate / ecto.rollback) so reversibility is real, not assumed.