36m read
Tags: elixir, ash, framework, domain-driven-design

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.


What do you think of what I said?

Share with me your thoughts. You can tweet me at @allanmacgregor.