ecto-patterns
SKILL.md
Ecto Patterns for Phoenix/Elixir
Ecto is the data layer for Phoenix applications: schemas, changesets, queries, migrations, and transactions. Good Ecto practice keeps domain logic in contexts, enforces constraints in the database, and uses transactions for multi-step workflows.
Schemas and Changesets
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :hashed_password, :string
field :confirmed_at, :naive_datetime
has_many :memberships, MyApp.Orgs.Membership
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
|> validate_format(:email, ~r/@/)
|> validate_length(:password, min: 12)
|> unique_constraint(:email)
|> hash_password()
end
defp hash_password(%{valid?: true} = cs),
do: put_change(cs, :hashed_password, Argon2.hash_pwd_salt(get_change(cs, :password)))
defp hash_password(cs), do: cs
end
Guidelines
- Keep casting/validation in changesets; keep business logic in contexts.
- Always pair validation with DB constraints (
unique_constraint,foreign_key_constraint). - Use
changeset/2for updates; avoid mass assigning without casting.
Migrations
def change do
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
end
Safe migration tips
- Prefer additive changes: add columns nullable, backfill, then enforce null: false.
- For large tables: use
concurrently: truefor indexes; disable inchangeand wrap inup/downfor Postgres. - Data migrations belong in separate modules called from
mix ecto.migrateviaexecute/1or in distinct scripts; ensure idempotence. - Coordinate locks: avoid long transactions; break migrations into small steps.
Queries and Preloads
import Ecto.Query
def list_users(opts \\ %{}) do
base =
from u in MyApp.Accounts.User,
preload: [:memberships],
order_by: [desc: u.inserted_at]
Repo.all(apply_pagination(base, opts))
end
defp apply_pagination(query, %{limit: limit, offset: offset}),
do: query |> limit(^limit) |> offset(^offset)
defp apply_pagination(query, _), do: query
Patterns
- Use
preloadrather than calling Repo in loops; preferRepo.preload/2after fetching. - Use
selectto avoid loading large blobs. - For concurrency, use
Repo.transactionwithlock: "FOR UPDATE"in queries that need row-level locks.
Transactions and Ecto.Multi
alias Ecto.Multi
def onboard_user(attrs) do
Multi.new()
|> Multi.insert(:user, User.registration_changeset(%User{}, attrs))
|> Multi.insert(:org, fn %{user: user} ->
Org.changeset(%Org{}, %{owner_id: user.id, name: attrs["org_name"]})
end)
|> Multi.run(:welcome, fn _repo, %{user: user} ->
MyApp.Mailer.deliver_welcome(user)
{:ok, :sent}
end)
|> Repo.transaction()
end
Guidelines
- Prefer
Multi.run/3for side effects that can fail; return{:ok, value}or{:error, reason}. - Use
Multi.update_allfor batch updates; includewhereguards to prevent unbounded writes. - Propagate errors upward; translate them in controllers/LiveViews.
Associations and Constraints
- Use
on_replace: :delete/:nilifyto control nested changes. - Define
foreign_key_constraint/3andunique_constraint/3in changesets to surface DB errors cleanly. - For many-to-many, prefer join schema (
has_many :memberships) instead of automaticmany_to_manywhen you need metadata.
Pagination and Filtering
- Offset/limit for small datasets; cursor-based for large lists (
Scrivener,Flop,Paginator). - Normalize filters in contexts; avoid letting controllers build queries directly.
- Add composite indexes to match filter columns; verify with
EXPLAIN ANALYZE.
Multi-Tenancy Patterns
- Prefix-based: Postgres schemas per tenant (
put_source/2withprefix:) — good isolation, needs per-tenant migrations. - Row-based:
tenant_idcolumn + row filters — simpler migrations; add partial indexes per tenant when large. - Always scope queries by tenant in contexts; consider using policies/guards to enforce.
Performance and Ops
- Use
Repo.streamfor large exports; wrap inRepo.transaction. - Cache hot reads with ETS/Cachex; invalidate on writes.
- Watch query counts in LiveView/Channels; preload before rendering to avoid N+1.
- Telemetry:
OpentelemetryEctoexports query timings; add DB connection pool metrics.
Testing
use MyApp.DataCase, async: true
test "registration changeset validates email" do
changeset = User.registration_changeset(%User{}, %{email: "bad", password: "secretsecret"})
refute changeset.valid?
assert %{email: ["has invalid format"]} = errors_on(changeset)
end
DataCasesets up sandboxed DB; keep tests async unless transactions conflict.- Use factories/fixtures in
test/supportto build valid structs quickly. - For migrations, add regression tests for constraints (unique/index-backed constraints).
Common Pitfalls
- Running risky DDL in a single migration step (avoid locks; break apart).
- Skipping DB constraints and relying only on changesets.
- Querying associations in loops instead of preloading.
- Missing transactions for multi-step writes (partial state on failure).
- Forgetting tenant scoping on read/write in multi-tenant setups.
Weekly Installs
36
Repository
bobmatnyc/claude-mpm-skillsFirst Seen
Jan 23, 2026
Security Audits
Installed on
claude-code30
gemini-cli23
antigravity22
opencode22
codex21
windsurf19