igmarin/elixir-phoenix-skills

apply-ecto-conventions

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.

First seen Jun 22, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill apply-ecto-conventions

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 10,330 B
  • docs SUMMARY.md 486 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 21 installs

SKILL.md

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](../../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 foreignkeyconstraint 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

Schema: Post.changeset/2. Context shell: Blog.change_post/2 (wraps the schema changeset). LiveViews call the context, not Post.changeset/2.

Review Workflow

When reviewing existing Ecto code, follow these steps in order:

  1. Check Repo calls outside contexts — search lib/ for Repo. in LiveViews, controllers, or non-context modules
  2. Search for bang functions — flag every ! function in application lib/ as a potential bug
  3. Check preloads in loops — look for association access inside for, Enum.map, etc. without prior preloading
  4. Verify Multi usage — any 2+ sequential Repo calls without Ecto.Multi is a missing transaction
  5. 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 — *_query/1 returns the query; the shell executes:**

def users_query(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)
end

def list_users(filters), do: Repo.all(users_query(filters))

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:

  1. Run mix ecto.migrate in the test environment first to catch errors early
  2. Verify reversibility with mix ecto.rollback — confirm the migration rolls back cleanly
  3. 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