The Ash Framework: A Practical Introduction
Domains, resources, actions, and policies - when Ash makes sense
The Ash Framework: A Practical Introduction
Every Elixir application hits the same wall eventually. Your Phoenix contexts1 grow fat with business logic; your Ecto schemas sprout validation functions that duplicate authorization checks scattered across controllers. You write the same CRUD operations for the fifteenth time. Each slightly different. Each accumulating its own quirks.
Ash Framework offers a different path. It's a declarative toolkit for modeling your domain — your resources, their relationships, their behaviors, and who can do what to whom. Describe what your domain looks like, and Ash generates the implementation.
That pitch sounds too good. It deserves scrutiny; the gap between "describe your domain" and "ship to production" is where frameworks live or die. So here's what Ash actually is, when it earns its place in your stack, and when plain Phoenix and Ecto remain the better call.
What Ash Actually Is
Ash is not a replacement for Phoenix or Ecto. It sits above them, orchestrating them — a domain modeling layer that compiles down to the primitives you already know2.
The inversion is what matters. Instead of writing imperative code that manipulates data, you declare resources — their attributes, relationships, actions, and policies. Ash reads those declarations and generates the machinery to execute them. This is not code generation in the way you're probably thinking; you don't run a generator and edit the output. The declarations are the source of truth. Change the declaration, and the behavior changes.
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :body, :string, allow_nil?: false
attribute :status, :atom, constraints: [one_of: [:draft, :published, :archived]]
timestamps()
end
actions do
defaults [:read, :destroy]
create :create do
accept [:title, :body]
change set_attribute(:status, :draft)
end
update :publish do
change set_attribute(:status, :published)
end
end
end
That's a complete, functional resource. Database schema, validation, default actions, a custom publish action. No controller code. No context module with boilerplate CRUD functions. Just a declaration.
The Moving Parts
Ash organizes around four concepts that interlock tightly. Worth understanding each before writing code — not because they're complicated individually, but because the power comes from how they compose.
Domains
A Domain groups related resources; it's the public API boundary for a slice of your application. If you know Phoenix contexts, Domains serve a similar architectural role. The difference: Domains have teeth1.
defmodule MyApp.Blog do
use Ash.Domain
resources do
resource MyApp.Blog.Post
resource MyApp.Blog.Comment
resource MyApp.Blog.Author
end
end
You interact with resources through their domain. MyApp.Blog.read!(MyApp.Blog.Post) reads posts. The domain enforces that you can't accidentally bypass authorization or skip validations by calling resources directly. That enforcement is the whole point.
Resources
Resources are the nouns. A Post. A User. An Order. Each declares its attributes (the data it holds), relationships (how it connects to other resources), and actions (what you can do with it).
Resources don't contain business logic in the way you're used to. They contain declarations that Ash interprets to produce behavior. The distinction matters more than it sounds.
Actions
Actions are the verbs. Every interaction with a resource flows through an action; Ash provides five types:
- create: Produces new records
- read: Queries existing records
- update: Modifies existing records
- destroy: Removes records
- action: Generic actions that don't map to CRUD — sending emails, triggering webhooks, computing derived values
Actions aren't methods you implement. They're configurations you declare. Ash provides the execution engine.
Policies
Policies answer a straightforward question: who can do what?
policies do
policy action_type(:read) do
authorize_if expr(status == :published)
authorize_if actor_attribute_equals(:role, :admin)
end
end
Allow reads if the post is published, OR if the actor is an admin. Policies compose; they can reference the actor, the data being accessed, and the context of the request. Nothing exotic. But having authorization live next to the resource definition — rather than scattered across six different controller functions — changes how you think about access control.
Defining Resources: Attributes and Relationships
A more complete example. A blog system with authors, posts, and comments — the kind of domain where you'd otherwise spend a day wiring up Ecto schemas and context functions.
defmodule MyApp.Blog.Author do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer
postgres do
table "authors"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false
attribute :email, :ci_string, allow_nil?: false
attribute :bio, :string
timestamps()
end
identities do
identity :unique_email, [:email]
end
relationships do
has_many :posts, MyApp.Blog.Post
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
end
defmodule 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, allow_nil?: false
attribute :slug, :string, allow_nil?: false
attribute :body, :string, allow_nil?: false
attribute :status, :atom do
constraints one_of: [:draft, :published, :archived]
default :draft
end
attribute :published_at, :utc_datetime_usec
timestamps()
end
identities do
identity :unique_slug, [:slug]
end
relationships do
belongs_to :author, MyApp.Blog.Author, allow_nil?: false
has_many :comments, MyApp.Blog.Comment
end
actions do
defaults [:read, :destroy]
create :create do
accept [:title, :body, :author_id]
change fn changeset, _context ->
title = Ash.Changeset.get_attribute(changeset, :title)
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
Ash.Changeset.change_attribute(changeset, :slug, slug)
end
end
update :update do
accept [:title, :body]
end
update :publish do
change set_attribute(:status, :published)
change set_attribute(:published_at, &DateTime.utc_now/0)
end
update :archive do
change set_attribute(:status, :archived)
end
end
end
Notice what's not here. No cast_assoc calls; no manual foreign key management. Ash generates the foreign keys, the preloading logic, the join queries3. The create action includes a change that derives the slug from the title — runs automatically on every create. No separate function to forget calling.
The publish action is a named update that wraps a business operation. Ash.update!(post, :publish) transitions the post to published status and records the timestamp. The caller doesn't need to know the implementation. Shouldn't, really.
Actions in Depth
Actions are where Ash's declarative model earns its keep.
Default Actions
The simplest case — standard CRUD with no ceremony:
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
The :* syntax means "accept all public attributes." You can also list specific attributes: create: [:name, :email].
Custom Create and Update Actions
Most applications need more than generic CRUD. Named actions let you model specific operations:
actions do
create :register do
accept [:email, :name, :password]
argument :password_confirmation, :string, allow_nil?: false
validate confirm(:password, :password_confirmation)
change fn changeset, _context ->
password = Ash.Changeset.get_argument(changeset, :password)
hashed = Bcrypt.hash_pwd_salt(password)
Ash.Changeset.change_attribute(changeset, :hashed_password, hashed)
end
end
update :change_password do
accept []
argument :current_password, :string, allow_nil?: false
argument :new_password, :string, allow_nil?: false
argument :new_password_confirmation, :string, allow_nil?: false
validate confirm(:new_password, :new_password_confirmation)
change fn changeset, context ->
# Verify current password, set new one
record = changeset.data
current = Ash.Changeset.get_argument(changeset, :current_password)
if Bcrypt.verify_pass(current, record.hashed_password) do
new_password = Ash.Changeset.get_argument(changeset, :new_password)
hashed = Bcrypt.hash_pwd_salt(new_password)
Ash.Changeset.change_attribute(changeset, :hashed_password, hashed)
else
Ash.Changeset.add_error(changeset, field: :current_password, message: "is incorrect")
end
end
end
end
Actions accept arguments — ephemeral input that's not persisted — run validations, and apply changes. The changes can be inline functions or reusable modules; I tend to start inline and extract when the same logic appears in a second action.
Read Actions with Filters
Read actions support real querying, not just "fetch all and filter in Elixir":
actions do
read :read do
primary? true
end
read :published do
filter expr(status == :published)
end
read :by_author do
argument :author_id, :uuid, allow_nil?: false
filter expr(author_id == ^arg(:author_id))
end
read :search do
argument :query, :string, allow_nil?: false
filter expr(contains(title, ^arg(:query)) or contains(body, ^arg(:query)))
end
end
Each read action is a named query. Ash.read!(MyApp.Blog.Post, :published) returns only published posts; the filters compile to SQL4. No loading everything into memory. No N+1 surprises hiding behind a friendly function name.
Generic Actions
Sometimes you need operations that don't fit CRUD:
actions do
action :send_welcome_email, :boolean do
argument :user_id, :uuid, allow_nil?: false
run fn input, _context ->
user = Ash.get!(MyApp.Accounts.User, input.arguments.user_id)
MyApp.Mailer.deliver_welcome(user)
{:ok, true}
end
end
end
Generic actions can return any type. They participate in authorization, transaction handling, and the rest of Ash's machinery — which means your side-effect-heavy operations still go through the same policy checks as everything else.
Policies: Declarative Authorization
Authorization logic in most Phoenix applications ends up everywhere. Controller plugs, context function guards, ad-hoc if checks in LiveView handlers. You know the pattern; you've probably inherited a codebase where the same permission check exists in four slightly different implementations.
Ash centralizes this.
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
# ... attributes, relationships, actions ...
policies do
# Anyone can read published posts
policy action_type(:read) do
authorize_if expr(status == :published)
end
# Authors can read their own drafts
policy action_type(:read) do
authorize_if expr(author_id == ^actor(:id))
end
# Only the author can update their posts
policy action_type(:update) do
authorize_if expr(author_id == ^actor(:id))
end
# Only the author can delete their posts
policy action_type(:destroy) do
authorize_if expr(author_id == ^actor(:id))
end
# Only authenticated users can create posts
policy action_type(:create) do
authorize_if actor_present()
end
# Admins can do anything
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
end
end
Policies compose with OR semantics within a single policy and AND semantics between policies of the same type. The bypass block short-circuits everything5 — if the actor is an admin, no further checks run.
The expr() macro deserves attention. It compiles to database queries when possible; checking author_id == ^actor(:id) adds a WHERE clause rather than loading records and filtering in Elixir4. On a table with a million rows, that's the difference between a 2ms query and an OOM crash.
Passing the actor is straightforward:
# In a Phoenix controller or LiveView
current_user = get_current_user(conn)
# This will only return posts the user is authorized to see
posts = MyApp.Blog.read!(MyApp.Blog.Post, actor: current_user)
# This will raise if the user cannot update this post
Ash.update!(post, :publish, actor: current_user)
When Ash Makes Sense
Ash isn't for everything. Knowing when to reach for it — and when to stick with what you have — matters more than knowing how to use it.
Reach for Ash when:
Your domain has real complexity. Many resources, tangled relationships, authorization rules that change depending on who's asking and what they're looking at. At 5 resources, the overhead probably doesn't pay off; at 50 resources with layered authorization, Ash's consistency stops being a nice-to-have and starts being the thing that keeps your codebase from collapsing under its own weight.
Your authorization logic is complex. Role-based access, resource-level permissions, field-level visibility — Ash policies handle this cleanly. Doing it imperatively requires discipline, and discipline degrades over time. I've watched it happen.
You want multiple API formats from the same domain model. Ash can generate both GraphQL and JSON:API endpoints from your resource definitions6. If you need both, building them by hand means maintaining two parallel representations of the same domain. That's a maintenance burden that compounds.
You're building multi-tenant. Ash has first-class multitenancy support7; tenant isolation in queries, migrations, and authorization comes built in. Rolling your own multitenancy layer is one of those projects that seems simple for the first two weeks and then haunts you for the next two years.
Stick with plain Phoenix/Ecto when:
Your application is small and staying that way. Ash has a learning curve. For a simple CRUD app with three resources, that curve may never pay off.
You need maximum control over query performance. Ash generates efficient queries, but it's an abstraction; if you need hand-tuned SQL for specific hot paths, the framework can feel constraining.
Your team hasn't worked with declarative patterns. Ash requires a different mental model. If your team thinks imperatively and your deadline is next month, introducing a paradigm shift adds risk you don't need.
Your domain is genuinely unusual. Temporal data, event sourcing, exotic storage backends — Ash optimizes for common patterns. Fighting a framework always costs more than building something purpose-fit.
A Complete Feature: Comments with Moderation
A comment system with nested replies, moderation, and authorization — the kind of feature that sprawls across half a dozen files in a typical Phoenix app.
defmodule MyApp.Blog.Comment do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "comments"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :body, :string, allow_nil?: false
attribute :status, :atom do
constraints one_of: [:pending, :approved, :rejected, :spam]
default :pending
end
timestamps()
end
relationships do
belongs_to :post, MyApp.Blog.Post, allow_nil?: false
belongs_to :author, MyApp.Blog.Author, allow_nil?: false
belongs_to :parent, MyApp.Blog.Comment, allow_nil?: true
has_many :replies, MyApp.Blog.Comment, destination_attribute: :parent_id
end
actions do
defaults [:read, :destroy]
create :create do
accept [:body, :post_id, :parent_id]
change relate_actor(:author)
# Auto-approve comments from trusted authors
change fn changeset, context ->
actor = context.actor
if actor && actor.trusted do
Ash.Changeset.change_attribute(changeset, :status, :approved)
else
changeset
end
end
end
update :approve do
change set_attribute(:status, :approved)
end
update :reject do
change set_attribute(:status, :rejected)
end
update :mark_spam do
change set_attribute(:status, :spam)
end
read :approved do
filter expr(status == :approved)
end
read :pending_moderation do
filter expr(status == :pending)
end
read :for_post do
argument :post_id, :uuid, allow_nil?: false
filter expr(post_id == ^arg(:post_id) and is_nil(parent_id) and status == :approved)
prepare build(load: [:replies, :author])
end
end
policies do
# Anyone can read approved comments
policy action(:approved) do
authorize_if always()
end
policy action(:for_post) do
authorize_if always()
end
# Authenticated users can create comments
policy action(:create) do
authorize_if actor_present()
end
# Authors can edit/delete their own comments
policy action_type([:update, :destroy]) do
authorize_if expr(author_id == ^actor(:id))
end
# Moderators can see pending comments and moderate
policy action(:pending_moderation) do
authorize_if actor_attribute_equals(:role, :moderator)
end
policy action([:approve, :reject, :mark_spam]) do
authorize_if actor_attribute_equals(:role, :moderator)
end
# Post authors can moderate comments on their posts
policy action([:approve, :reject, :mark_spam]) do
authorize_if expr(post.author_id == ^actor(:id))
end
bypass actor_attribute_equals(:role, :admin) do
authorize_if always()
end
end
end
The relate_actor(:author) change8 is worth pausing on. It automatically sets the author relationship to whoever's performing the action. No passing author_id through params; no trusting the client to send the right one. One line eliminates an entire category of "who actually created this" bugs.
Using this in a LiveView:
defmodule MyAppWeb.PostLive.Show do
use MyAppWeb, :live_view
def mount(%{"id" => post_id}, _session, socket) do
post = Ash.get!(MyApp.Blog.Post, post_id, load: [:author])
comments = MyApp.Blog.read!(MyApp.Blog.Comment, :for_post,
args: %{post_id: post_id},
actor: socket.assigns.current_user
)
{:ok, assign(socket, post: post, comments: comments)}
end
def handle_event("submit_comment", %{"body" => body}, socket) do
case Ash.create(MyApp.Blog.Comment, :create,
params: %{body: body, post_id: socket.assigns.post.id},
actor: socket.assigns.current_user
) do
{:ok, comment} ->
{:noreply, update(socket, :comments, &[comment | &1])}
{:error, changeset} ->
{:noreply, assign(socket, error: format_errors(changeset))}
end
end
def handle_event("approve_comment", %{"id" => comment_id}, socket) do
comment = Ash.get!(MyApp.Blog.Comment, comment_id)
case Ash.update(comment, :approve, actor: socket.assigns.current_user) do
{:ok, _} ->
{:noreply, refresh_comments(socket)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Not authorized")}
end
end
end
The LiveView code is minimal. Call Ash actions, pass the actor, handle the result. Authorization happens automatically; validation errors surface through changesets. The LiveView doesn't know how comments get moderated. It doesn't need to.
The Tradeoffs
Ash is opinionated software, and opinionated software makes decisions for you. Some of those decisions won't match what you'd choose.
You give up flexibility. Ash's execution model assumes certain patterns; if your domain genuinely doesn't fit, you'll fight the framework. That fight is expensive and you won't win.
You gain consistency. Every resource works the same way; every action follows the same lifecycle. New team members can understand one resource and immediately understand all of them. I've onboarded developers onto Ash-based codebases in a fraction of the time it takes with hand-rolled Phoenix contexts9.
You add abstraction. Abstractions trade flexibility for power — if you need that power, the trade is worth it. If you don't, you're paying a complexity tax for nothing.
You commit to learning something substantial. Mastering Ash takes weeks. Not hours. If you're building a throwaway prototype, that investment won't pay off; if you're building something you'll maintain for years, it might be the best investment you make.
My honest take: evaluate Ash when your domain complexity exceeds what feels maintainable with plain Ecto schemas and Phoenix contexts. If you're writing the same authorization checks in multiple places, the same validation logic in multiple contexts, the same query patterns across different resources — that's the signal. That repetition isn't just tedious. It's where bugs hide.
Start small. One bounded context. Live with it for a few weeks. If it clicks, expand. If it doesn't, you've still learned something about how your domain is actually shaped — and that knowledge transfers regardless of what framework you end up using.