Intent in-ash-ecto-essentials
Ash Framework database access rules: domain code interfaces, migrations, actor authorization, atomic changes
install
source · Clone the upstream repo
git clone https://github.com/matthewsinclair/intent
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/matthewsinclair/intent "$T" && mkdir -p ~/.claude/skills && cp -r "$T/intent/plugins/claude/skills/in-ash-ecto-essentials" ~/.claude/skills/matthewsinclair-intent-in-ash-ecto-essentials && rm -rf "$T"
manifest:
intent/plugins/claude/skills/in-ash-ecto-essentials/SKILL.mdsource content
Ash/Ecto Essentials
Database access rules for Ash Framework projects. All database access goes through Ash -- never raw Ecto in application code. Authoritative reference:
deps/ash/usage-rules.md.
Rules
1. All database access through domain code interfaces
NEVER call
Ash.get!/2, Ash.read!/2, Ash.create!/2, or Ash.load!/2 directly in LiveViews, controllers, or other web modules. Always go through the domain's code interface.
# BAD -- direct Ash calls in LiveView def mount(%{"id" => id}, _session, socket) do post = MyApp.Content.Post |> Ash.get!(id) |> Ash.load!([:author]) {:ok, assign(socket, :post, post)} end # GOOD -- domain code interface def mount(%{"id" => id}, _session, socket) do post = MyApp.Content.get_post!(id, load: [:author], actor: socket.assigns.current_user) {:ok, assign(socket, :post, post)} end
2. mix ash.codegen
for migrations, never mix ecto.gen.migration
mix ash.codegenmix ecto.gen.migrationAsh reads your resource definitions and generates correct migrations. Never write Ecto migrations by hand.
# GOOD mix ash.codegen add_user_role mix ash.migrate # BAD mix ecto.gen.migration add_user_role mix ecto.migrate
3. Set actor on query/changeset, not on action call
The actor must be set when building the query or changeset, not when executing the action.
# BAD -- actor on action call Post |> Ash.Query.for_read(:read, %{}) |> Ash.read!(actor: current_user) # GOOD -- actor on query Post |> Ash.Query.for_read(:read, %{}, actor: current_user) |> Ash.read!() # GOOD -- code interfaces handle this correctly MyApp.Content.list_posts!(actor: current_user)
4. Prefer code interface options over manual Ash.Query pipelines
Use
query: [filter: ..., sort: ..., limit: ...] on code interface calls instead of building Ash.Query pipelines in web modules.
# BAD -- Ash.Query pipeline in LiveView require Ash.Query posts = MyApp.Content.Post |> Ash.Query.filter(status: :published) |> Ash.Query.sort(inserted_at: :desc) |> Ash.Query.limit(20) |> Ash.read!(actor: current_user) # GOOD -- code interface with query options posts = MyApp.Content.list_posts!( query: [filter: [status: :published], sort: [inserted_at: :desc], limit: 20], actor: current_user )
5. Custom change/validation modules, not anonymous functions
Put Ash change and validation logic in dedicated modules, not inline anonymous functions.
# BAD -- anonymous function actions do create :create do change fn changeset, _context -> title = Ash.Changeset.get_attribute(changeset, :title) Ash.Changeset.change_attribute(changeset, :slug, Slug.slugify(title)) end end end # GOOD -- dedicated module actions do create :create do change MyApp.Content.Changes.SlugifyTitle end end
6. Atomic changes preferred; require_atomic? false
only when necessary
require_atomic? falseImplement
atomic/3 callback for database-level operations. Only use require_atomic? false when the change genuinely cannot be expressed atomically.
# GOOD -- atomic change defmodule MyApp.Changes.IncrementCount do use Ash.Resource.Change @impl true def atomic(_changeset, _opts, _context) do {:atomic, %{view_count: expr(view_count + 1)}} end end # OK -- non-atomic only when truly necessary (external API call) defmodule MyApp.Changes.GeocodeAddress do use Ash.Resource.Change @impl true def change(changeset, _opts, _context) do # Must call external geocoding API -- cannot be atomic address = Ash.Changeset.get_attribute(changeset, :address) {:ok, coords} = GeocodingService.lookup(address) Ash.Changeset.change_attributes(changeset, %{lat: coords.lat, lng: coords.lng}) end end
7. Ash.Query.filter
is a macro -- always require Ash.Query
at module level
Ash.Query.filterrequire Ash.QueryForgetting
require causes a confusing compile error. The require must go at the top of the module (with other requires, in alphabetical order), never inside a function body.
# BAD -- no require, will fail to compile defmodule MyApp.Accounts do def list_active_users do MyApp.Accounts.User |> Ash.Query.filter(active: true) |> Ash.read!() end end # BAD -- require inside function body (fails Credo, bad practice) def list_active_users do require Ash.Query # ... end # GOOD -- require at module level defmodule MyApp.Accounts do require Ash.Query def list_active_users do MyApp.Accounts.User |> Ash.Query.filter(active: true) |> Ash.read!() end end