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.md
source 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

Ash 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

Implement

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

Forgetting

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