Curated library of 38 atomic skills, 7 personas, and 1 orchestrator for Elixir and Phoenix development. Organized by category: fundamentals, phoenix, database, testing, auth, infrastructure, quality, security, integrations, tooling, frameworks, personas, and orchestration. Covers core Elixir patterns, Phoenix LiveView, Ecto, OTP, Oban, testing, security, deployment, real-time, and modern tooling (Req, Swoosh, Cachex, Broadway, Ash).
73
91%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
use Ash.Resource for domain resources — never manually implement protocolsdefaults [:read, :create] without understanding what they exposeAsh.Changeset.for_create/3 and Ash.Changeset.for_update/3 — not bare struct manipulationmix ash_postgres.generate_migrations before manual migration — let Ash generate the schemamix compile and confirm no Spark.Error.DslError before proceedingFollow this sequence when starting a new Ash project:
{:ash, "~> 3.0"} and {:ash_postgres, "~> 2.0"} to mix.exsuse Ecto.Repo to use AshPostgres.Repo, otp_app: :my_appuse Ash.Domain and resources do ... enduse Ash.Resource, domain: MyApp.Domain, data_layer: AshPostgres.DataLayertable and repo in the postgres do blockuuid_primary_key, attribute, timestamps() in the attributes do blockbelongs_to, has_many, many_to_many in relationships do blockactions do with defaults, create, update, read blockspolicies do block with authorize_if or forbid_if rulesmix ash_postgres.generate_migrations then mix ash_postgres.migrateDomain.create!(resource, attributes) to verify the resource worksdefmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :title, :string do
allow_nil? false
constraints [max_length: 255]
end
attribute :body, :string do
allow_nil? false
end
attribute :status, :atom do
constraints [one_of: [:draft, :published, :archived]]
default :draft
end
timestamps()
end
relationships do
belongs_to :author, MyApp.Accounts.User do
allow_nil? false
end
has_many :comments, MyApp.Blog.Comment
end
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:title, :body, :status, :author_id]
end
update :publish do
accept []
change set_attribute(:status, :published)
end
read :published do
filter expr(status == :published)
end
end
end# Create a post
post =
MyApp.Blog.Post
|> Ash.Changeset.for_create(:create, %{
title: "Hello World",
body: "This is my first post",
author_id: user.id
})
|> MyApp.Blog.create!()
# Read posts
posts =
MyApp.Blog.Post
|> Ash.Query.for_read(:published)
|> Ash.Query.filter(author_id == ^user.id)
|> MyApp.Blog.read!()
# Update post
post
|> Ash.Changeset.for_update(:publish)
|> MyApp.Blog.update!()policies do
policy action_type(:read) do
authorize_if relates_to_actor_via(:author)
authorize_if expr(status == :published)
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:author)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:author)
end
endDebugging authorization failures: If a call raises Ash.Error.Forbidden, enable policy breakdown logging:
# config/dev.exs
config :ash, :policies, log_policy_breakdowns: :errorAdd {:ash_phoenix, "~> 2.0"} to deps.
# mount — build form from changeset
form =
post
|> Ash.Changeset.for_update(:update, %{})
|> AshPhoenix.Form.for_update()
|> to_form()
# handle_event "save"
case Blog.update(Ash.Changeset.for_update(post, :update, params)) do
{:ok, post} -> {:noreply, put_flash(socket, :info, "Saved.") |> assign(post: post)}
{:error, cs} -> {:noreply, assign(socket, form: cs |> AshPhoenix.Form.for_update() |> to_form())}
endSee the AshPhoenix docs for full LiveView and form component examples.
Add {:ash_json_api, "~> 1.0"} to deps.
# In your resource
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource]
json_api do
type "post"
routes do
base "/posts"
get :read
index :published
post :create
patch :publish
end
end# router.ex
scope "/api/json" do
pipe_through :api
forward "/", AshJsonApi.Router, domains: [MyApp.Blog]
endSee the AshJsonApi docs for pagination, includes, and error serialization.
# Add a count aggregate to a resource
aggregates do
count :comment_count, :comments
count :published_comment_count, :comments do
filter expr(status == :published)
end
end
# Use in queries
MyApp.Blog.Post
|> Ash.Query.filter(comment_count > 0)
|> MyApp.Blog.read!()create :create do
accept [:title, :body, :author_id]
validate str_length(:title, min: 1, max: 255) do
message "Title must be between 1 and 255 characters"
end
endFor multi-field or conditional validation logic, implement a custom Ash.Resource.Validation module:
defmodule MyApp.Validations.TitleNotBlank do
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
case Ash.Changeset.get_attribute(changeset, :title) do
nil -> {:error, field: :title, message: "can't be blank"}
"" -> {:error, field: :title, message: "can't be blank"}
_ -> :ok
end
end
end^ for safe interpolation, never string interpolation# NEVER: Ash.Query.filter("status == '#{params["status"]}'"}) -- injection risk
MyApp.Blog.Post
|> Ash.Query.filter(status == ^status and author_id == ^current_user.id)
|> Ash.Query.sort([inserted_at: :desc])Ash.Error.Query.NotFound explicitlycase MyApp.Blog.Post |> Ash.get(id) do
{:ok, post} -> {:ok, post}
{:error, %Ash.Error.Query.NotFound{}} -> {:error, :not_found}
{:error, error} -> {:error, error}
endcase MyApp.Blog.Post
|> Ash.Changeset.for_create(params)
|> MyApp.Blog.create() do
{:ok, post} ->
{:ok, post}
{:error, %Ash.Error.InvalidInput{fields: fields}} ->
{:error, :validation, fields}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :unauthorized}
{:error, %Ash.Error.Changeset{errors: errors}} ->
{:error, :invalid_changeset, errors}
{:error, error} ->
Logger.error("Unexpected error: #{inspect(error)}")
{:error, :internal_error}
endMyApp.Blog.Post
|> Ash.Query.page(limit: 20, after: last_inserted_at)
|> MyApp.Blog.read!()Always create the Ash resource first, then let Ash generate migrations — never alter the DB schema before defining the resource.
# Step 1: Create Ash resource matching existing schema
defmodule MyApp.Blog.Post do
use Ash.Resource, domain: MyApp.Blog, data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo MyApp.Repo
end
end
# Step 2: Generate and run migration
# mix ash_postgres.generate_migrations
# mix ash_postgres.migrate
# Step 3: Update context to delegate to Ash
def get_post!(id) do
MyApp.Blog.Post |> Ash.get!(id)
end