| name | apply-ecto-conventions |
| type | atomic |
| license | MIT |
| tags | ["atomic"] |
| description | Use when writing or reviewing Ecto database code in Elixir applications. Enforces consistent patterns for Repo queries, changeset composition, preloading strategies, context boundaries, Ecto.Multi transactions, and query composition. Covers non-bang vs bang functions, N+1 prevention, pagination, and migration safety. Trigger words: ecto conventions, repo pattern, changeset, context module,
preload, ecto query, database conventions, apply ecto patterns.
|
| metadata | {"version":"1.0.0","user-invocable":"true"} |
Apply Ecto Conventions
Use this skill when writing or reviewing Ecto database code to ensure consistent, idiomatic patterns.
Precondition: Invoke ecto-essentials before this skill for the full Ecto reference.
Canonical FP bar: docs/fcis-engineering-rules.md — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Build changesets/Multi in pure-ish functions; run Repo once at the context edge.
RULES — Follow these with no exceptions
1. Never call Repo from LiveViews or controllers — all database operations belong in context modules
2. Prefer non-bang functions in application logic (Repo.get/1, Repo.insert/1) — use bang only in tests
3. Parameterize all user input in queries — use ^ for interpolation, never string concatenation in fragment
4. Always preload associations outside loops to prevent N+1 queries
5. Add foreign_key_constraint and unique_constraint in changesets to match database constraints
6. Use Ecto.Multi for 2+ related operations — never chain multiple Repo calls in sequence without a transaction
7. Add indexes on foreign keys and frequently queried columns
8. Never combine schema changes and data backfill in the same migration
FCIS at this boundary
Ecto is the persistence edge. Prefer pure functions that build changesets/queries/Multi; execute with Repo once in the context shell.
❌ Bad: mix calculation with inserts
def apply_discount(order_id, pct) do
order = Repo.get!(Order, order_id)
total = Enum.reduce(order.lines, 0, &(&1.amount + &2))
order
|> Ecto.Changeset.change(total: div(total * (100 - pct), 100))
|> Repo.update!()
end
✅ Good: pure pricing + thin context
defmodule MyApp.Orders.Pricing do
def total(%{lines: lines}), do: Enum.reduce(lines, 0, &(&1.amount + &2))
def with_discount(total, pct) when pct in 0..100, do: div(total * (100 - pct), 100)
end
def apply_discount(order_id, pct) do
with {:ok, order} <- fetch_order(order_id) do # context helper: Repo.get → tagged tuple
total = order |> Pricing.total() |> Pricing.with_discount(pct)
order
|> Order.changeset(%{total: total})
|> Repo.update()
end
end
Use realistic names: Post.changeset/2, not placeholders like change_post/1.
Review Workflow
When reviewing existing Ecto code, follow these steps in order:
- Check Repo calls outside contexts — search
lib/ for Repo. in LiveViews, controllers, or non-context modules
- Search for bang functions — flag every
! function in application lib/ as a potential bug
- Check preloads in loops — look for association access inside
for, Enum.map, etc. without prior preloading
- Verify Multi usage — any 2+ sequential
Repo calls without Ecto.Multi is a missing transaction
- Inspect migrations — confirm reversibility, presence of indexes, and absence of mixed schema/data changes
Context Boundaries: Repo Lives in Contexts
❌ Bad — Repo called directly in LiveView:
def handle_event("load", _params, socket) do
users = MyApp.Repo.all(User)
{:noreply, assign(socket, :users, users)}
end
✅ Good — LiveView delegates to context:
def handle_event("load", _params, socket) do
users = Accounts.list_users()
{:noreply, assign(socket, :users, users)}
end
The context module owns Repo:
defmodule MyApp.Accounts do
alias MyApp.Repo
alias MyApp.Accounts.User
def list_users, do: Repo.all(User)
def get_user(id), do: Repo.get(User, id)
end
Non-Bang vs Bang Functions
❌ Bad — bang in application logic:
def show(conn, %{"id" => id}) do
user = Repo.get!(User, id)
render(conn, :show, user: user)
end
✅ Good — non-bang with pattern matching:
def show(conn, %{"id" => id}) do
case Accounts.get_user(id) do
{:ok, user} -> render(conn, :show, user: user)
{:error, :not_found} -> put_status(conn, :not_found) |> json(%{error: "Not found"})
end
end
The context returns tagged tuples:
def get_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
Checkpoint: Search for ! functions in application lib/ — every one is a potential bug.
Changeset Composition
❌ Bad — missing database constraints, no validation:
def create_user(attrs) do
%User{}
|> Repo.insert(attrs)
end
✅ Good — changeset validates and enforces constraints:
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name])
|> validate_required([:email, :name])
|> validate_length(:name, min: 1, max: 255)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
|> foreign_key_constraint(:organization_id)
end
Preloading: Prevent N+1
❌ Bad — N+1 queries inside loop:
users = Repo.all(User)
for user <- users do
user.posts
end
✅ Good — preload before iteration:
users = Repo.all(User) |> Repo.preload(:posts)
for user <- users do
user.posts
end
✅ Good — nested preloading:
Repo.all(from u in User, preload: [posts: :comments])
Checkpoint: Run Ecto query log observer in development to detect N+1 violations.
Ecto.Multi for Multi-Step Operations
❌ Bad — multiple Repo calls without transaction:
def create_user_with_profile(attrs) do
{:ok, user} = Repo.insert(User.changeset(%User{}, attrs))
{:ok, profile} = Repo.insert(Profile.changeset(%Profile{}, Map.put(attrs, :user_id, user.id)))
{:ok, %{user: user, profile: profile}}
end
✅ Good — Ecto.Multi wraps all operations in a transaction:
def create_user_with_profile(user_attrs, profile_attrs) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.changeset(%User{}, user_attrs))
|> Ecto.Multi.insert(:profile, fn %{user: user} ->
Profile.changeset(%Profile{}, Map.put(profile_attrs, :user_id, user.id))
end)
|> Repo.transaction()
end
On failure, the error tuple {:error, :user, changeset, _} names the failed step for targeted error handling.
Query Composition
❌ Bad — string interpolation in fragment:
from(u in User, where: fragment("lower(#{field}) = ?", ^value))
✅ Good — parameterized queries with ^:
from(u in User, where: fragment("lower(?) = ?", field(u, :status), ^value))
from(u in User, where: u.status == ^status and u.name == ^name)
Dynamic Filtering
✅ Good — Enum.reduce for dynamic where clauses:
def list_users(filters) do
Enum.reduce(filters, User, fn
{:status, status}, q -> where(q, [u], u.status == ^status)
{:search, term}, q -> where(q, [u], ilike(u.name, ^"%#{term}%"))
_, q -> q
end)
|> Repo.all()
end
Migrations
❌ Bad — irreversible migration, no index:
def up do
alter table(:users) do
remove :name
end
end
✅ Good — reversible change/0 with indexes:
def change do
create table(:images) do
add :title, :string, null: false
add :filename, :string, null: false
add :folder_id, references(:folders, on_delete: :nilify_all)
timestamps()
end
create index(:images, [:folder_id])
create index(:images, [:inserted_at])
end
Migration verification steps:
- Run
mix ecto.migrate in the test environment first to catch errors early
- Verify reversibility with
mix ecto.rollback — confirm the migration rolls back cleanly
- Apply to dev, then production — never skip the rollback check before promoting
Pagination
❌ Bad — no pagination on large tables:
def list_posts, do: Repo.all(Post)
✅ Good — offset/limit pagination with composite index:
def list_posts(page \\ 1, per_page \\ 20) do
page = max(page, 1)
per_page = per_page |> max(1) |> min(100)
offset = (page - 1) * per_page
Post
|> order_by(desc: :inserted_at)
|> offset(^offset)
|> limit(^per_page)
|> Repo.all()
end
Common Pitfalls
| ❌ Don't | ✅ Do |
|---|
Call Repo directly from a LiveView or controller | Put every query behind a context module function |
Use bang functions (Repo.get!) in application logic | Use non-bang functions and pattern match on {:ok, _} / nil |
Interpolate user input into a fragment string | Parameterize with ^ and field(u, :col) |
| Access associations inside a loop without preloading | Repo.preload/2 (or preload:) before iterating |
Chain 2+ related Repo writes without a transaction | Wrap them in Ecto.Multi and Repo.transaction/1 |
| Mix schema changes and data backfill in one migration | Keep structural and data migrations separate |
| Ship foreign keys without indexes | create index(...) on FKs and frequently queried columns |
Integration
| Predecessor | This Skill | Successor |
|---|
| ecto-essentials | apply-ecto-conventions | code-quality |
| ecto-changeset-patterns | apply-ecto-conventions | testing-essentials |
Companion skills:
ecto-essentials — full Ecto reference (schemas, queries, migrations)
ecto-changeset-patterns — advanced validations and changeset composition
ecto-nested-associations — cast_assoc, Ecto.Multi, cascade operations