DEV Community

Cover image for Ash Framework: Introduction
Artyom Molchanov
Artyom Molchanov

Posted on

Ash Framework: Introduction

When a newcomer starts with Elixir and Phoenix, they are almost always introduced to Ecto first. You create schemas, write changesets, and move queries into contexts. This approach is flexible, but as the project grows, a serious problem begins to emerge 😟

Business logic starts to spread across dozens of files. Eventually, confusion arises about where to place request handling code and how to make sense of the existing mess. After a year or two, it becomes difficult to understand where the “right” place for code is, and new developers spend weeks just getting up to speed with the project.

Ash Framework was created precisely to solve this pain 💪

Important clarification: Ash is not a replacement for Phoenix and not a new web framework. It is a powerful declarative layer that organizes business logic and data on top of the existing ecosystem.




📖 Preface

It’s important to understand that the goal of this article is not to convince you to use Ash everywhere and all the time!

Personally, I love Ash because it allows you to gather all necessary logic in a single space, where it belongs. This tool significantly simplifies work, reducing boilerplate code and freeing you from the tedious implementation of standard CRUD.

If you’re ready to get acquainted with such a tool but plan to use it only when truly justified—read on without hesitation. Ash is not a universal cure-all, but rather a convenient set of useful tools.




🚀 What is Ash?

Ash is a framework for fast and efficient application development in Elixir. Its goal is to simplify launching new projects and make maintaining existing solutions convenient, ensuring excellent performance and stability even after long periods of active development.

The key idea of Ash is:

Precisely define the structure of your business logic, and let Ash handle all the rest (database migrations, API interface creation, data validation, access control, form generation).

The framework integrates beautifully with popular tools like Phoenix, PostgreSQL, GraphQL, and LiveView.




🔑 Key Abstractions in Ash

Everything in Ash is built around three main concepts:

  • Resource — description of a single business entity
  • Action — an operation that can be performed on a resource
  • Domain — logical grouping of resources within one business area

Let’s break down each concept in detail!




🧩 What is a Resource?

Resource is one module where a single business entity of your application (User, Lesson, Order, Post, Payment, etc.) is fully described.

Everything related to this entity is collected in one place:

  • Data structure (fields)
  • Relationships with other entities
  • Operations that can be performed (CRUD + custom)
  • Validation rules
  • Authorization (who can do what)
  • Computed fields
  • Indexes and constraints
  • Storage settings in the database

A Resource in Ash unifies in one module what in classic Elixir/Phoenix is usually spread across several parts: Ecto Schema, Changesets, Context functions, as well as authorization logic and computed fields.


Resource — The Heart of Ash

Ash is built around the idea of Resource-Oriented Design. You describe what an entity is and what can be done with it—and Ash generates the rest: SQL queries, APIs, forms, migrations, validation, etc.


Complete Example of a Modern Resource

defmodule CourseHub.Courses.Lesson do
  use Ash.Resource,
    otp_app: :course_hub,
    domain: CourseHub.Courses,
    data_layer: AshPostgres.DataLayer

  # ==================== STORAGE ====================
  postgres do
    table "lessons"
    repo CourseHub.Repo
  end

  # ==================== FIELDS (Attributes) ====================
  attributes do
    uuid_primary_key :id

    attribute :title, :string do
      public? true
      allow_nil? false
      constraints min_length: 3, max_length: 200
    end

    attribute :description, :string, public?: true
    attribute :content, :string, public?: true

    attribute :status, :atom do
      public? true
      default? :draft
      constraints one_of: [:draft, :published, :archived]
    end

    create_timestamp :inserted_at
    update_timestamp :updated_at
  end

  # ==================== RELATIONSHIPS ====================
  relationships do
    belongs_to :author, CourseHub.Accounts.User do
      public? true
      allow_nil? false
    end

    has_many :comments, CourseHub.Courses.Comment do
      public? true
    end

    many_to_many :tags, CourseHub.Courses.Tag do
      public? true
      through CourseHub.Courses.LessonTag
      source_attribute_on_join_resource :lesson_id
      destination_attribute_on_join_resource :tag_id
    end
  end

  # ==================== ACTIONS ====================
  actions do
    defaults [:create, :read, :update, :destroy]

    # Custom read
    read :published do
      filter expr(status == :published)
      pagination offset?: true, countable: true
      prepare build(sort: [inserted_at: :desc])
    end

    # Custom create with logic
    create :publish do
      argument :reason, :string
      change set_attribute(:status, :published)
    end

    # Fully custom action
    action :calculate_reading_time, :integer do
      argument :content, :string, allow_nil?: false
      run fn input, _context ->
        minutes = String.length(input.arguments.content) |> div(500) |> max(1)
        {:ok, minutes}
      end
    end
  end

  # ==================== COMPUTED FIELDS ====================
  calculations do
    calculate :reading_time_minutes, :integer do
      calculation fn records, _context ->
        Enum.map(records, fn lesson ->
          (String.length(lesson.content || "") / 500) |> Float.ceil() |> trunc()
        end)
      end
    end
  end

  # ==================== INDEXES ====================
  identities do
    identity :unique_title_per_author, [:title, :author_id]
  end

  # ==================== AUTHORIZATION ====================
  policies do
    policy action_type(:read) do
      authorize_if always()
    end

    policy action(:update) do
      authorize_if expr(author_id == ^actor(:id))
    end
  end
end
Enter fullscreen mode Exit fullscreen mode


Main Sections of Resource

Section Describes Analog in Classic Elixir/Phoenix
attributes Fields and their types + validation Ecto Schema + fields
relationships Relationships with other resources Ecto associations
actions All possible operations (CRUD + custom) Context functions + Changesets
calculations Computed fields (on the fly) Virtual fields / functions
aggregates Aggregates (count, sum, avg, etc.) Queries with group_by
identities Unique indexes Ecto unique constraints
policies Authorization rules Manual permission checks
postgres PostgreSQL storage settings Migrations


Key Features of Resource

  1. Everything in one file — a huge plus for understanding the entity.
  2. Introspection — Ash can read all information about a resource at runtime (fields, actions, policies). This is used for API generation, forms, admin panels.
  3. Declarativeness — you describe what should be there, not how to implement it.
  4. Extensibility — you can add your own changes, preparations, validations, notifiers.
  5. Embedded Resources — you can create resources stored not in a separate table but as JSON/Map within another resource.


When to Create a New Resource?

Almost always when a new business entity appears (a table or even a complex JSON object).




🛠️ More About Actions

Action is an explicitly declared operation that can be performed on a resource.
All data interaction in Ash goes only through Actions. You cannot simply take a record and change it manually—you always call an action.

This replaces:

  • Regular functions in Phoenix Contexts
  • Changesets in Ecto
  • Controllers and service layers


Five Types of Actions

Action Type What It Does? In Transaction? Returns?
create Creates a new record Yes New resource record
🔍 read Reads one record or a list No (can be enabled) Record or list of records
✏️ update Updates an existing record Yes Updated record
🗑️ destroy Deletes a record Yes Deleted record
action (Generic) Any custom business logic No (can be enabled) Any value


Complete Example of All Action Types

defmodule CourseHub.Courses.Lesson do
  use Ash.Resource, ...

  actions do
    # 1. Standard CRUD (defaults)
    defaults [:create, :read, :update, :destroy]

    # 2. Custom Read
    read :published do
      filter expr(status == :published)
      pagination offset?: true, countable: true, default_limit: 20
      prepare build(sort: [inserted_at: :desc], load: [:author, :comment_count])
    end

    read :for_user do
      argument :user_id, :uuid, allow_nil?: false
      filter expr(author_id == ^arg(:user_id))
    end

    # 3. Custom Create
    create :publish do
      argument :reason, :string, allow_nil?: true

      # Changes (changes)
      change set_attribute(:status, :published)
      change set_attribute(:published_at, &DateTime.utc_now/0)
      change {CourseHub.Changes.NotifySubscribers, topic: "new_lessons"}

      # Hooks
      after_action fn _changeset, lesson, _context ->
        {:ok, lesson}
      end
    end

    # 4. Custom Update
    update :archive do
      accept [:archive_reason]
      change set_attribute(:status, :archived)
      change set_attribute(:archived_at, &DateTime.utc_now/0)
    end

    # 5. Generic Action (fully custom)
    action :calculate_reading_time, :integer do
      argument :content, :string, allow_nil?: false
      argument :words_per_minute, :integer, default?: 200

      run fn input, _context ->
        minutes = String.length(input.arguments.content) 
                  |> div(input.arguments.words_per_minute) 
                  |> max(1)
        {:ok, minutes}
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode


How to Call Actions

# Through Domain (recommended way)
CourseHub.Courses.publish!(lesson, %{reason: "Готово"})
CourseHub.Courses.read!(CourseHub.Courses.Lesson, :published)
# Or with Ash.Query / Ash.Changeset for complex cases
query = Ash.Query.for_read(Lesson, :published)
Ash.read!(query)
Enter fullscreen mode Exit fullscreen mode


Main Capabilities of Actions

1. argument — Input Parameters

Allows passing data into an action and validating it.

argument :title, :string, allow_nil?: false, constraints: [min_length: 5]
argument :published_at, :datetime, default?: &DateTime.utc_now/0
argument :user_id, :uuid, allow_nil?: false
Enter fullscreen mode Exit fullscreen mode

2. accept — Which Attributes Can Be Changed

Limits the list of fields that can be modified during create or update. Very useful for security.

create :register do
  accept [:email, :password, :name]
end

update :update_profile do
  accept [:name, :bio, :avatar_url]
end
Enter fullscreen mode Exit fullscreen mode

3. change — Data Transformations

The most powerful and frequently used part of Actions. Here the main business logic happens.

create :publish do
  argument :reason, :string

  change set_attribute(:status, :published)
  change set_attribute(:published_at, &DateTime.utc_now/0)

  change {CourseHub.Changes.NotifySubscribers, topic: "lessons"}

  change fn changeset, _context ->
    Ash.Changeset.change_attribute(changeset, :slug, generate_slug(changeset))
  end
end
Enter fullscreen mode Exit fullscreen mode

4. prepare — Query Preparation (mainly for read)

Allows pre-configuring the query: sorting, preloading relations, additional filters.

read :published do
  filter expr(status == :published)

  prepare build(
    sort: [inserted_at: :desc],
    load: [:author, :comments],
    limit: 20
  )
end
Enter fullscreen mode Exit fullscreen mode

5. Hooks (before/after)

Allows executing code before or after an action.

create :publish do
  before_action fn changeset, _context ->
    validate_business_rules(changeset)
  end

  after_action fn _changeset, lesson, _context ->
    LessonNotifier.publish_event(lesson)
    {:ok, lesson}
  end

  after_transaction fn
    {:ok, lesson}, _context -> broadcast_new_lesson(lesson)
    {:error, _}, _ -> :ok 
  end
end
Enter fullscreen mode Exit fullscreen mode

6. transaction? — Transaction Management

Determines whether the action should be wrapped in a database transaction.

action :transfer_money, :boolean do
  argument :from_account_id, :uuid
  argument :to_account_id, :uuid
  argument :amount, :decimal

  transaction? true

  run fn input, _context ->
    # complex operation with multiple records
    {:ok, true}
  end
end
Enter fullscreen mode Exit fullscreen mode

7. pipe_through — Reusing Logic

Allows creating common pipelines and attaching them to different actions (very useful for DRY).

# In Resource
pipelines do
  pipeline :ensure_admin do
    change ensure_actor_role(:admin)
    validate actor_role(:admin)
  end
end

create :create_admin_content do
  pipe_through :ensure_admin
  accept [:title, :content]
end

update :update_admin_content do
  pipe_through :ensure_admin
  accept [:title, :content]
end
Enter fullscreen mode Exit fullscreen mode


Why Actions Are Awesome?

  • Full typing and introspection — Ash knows everything about an action at runtime (used for GraphQL, forms, admin panels).
  • Declarativeness — code reads like business documentation.
  • Reusability — one action can be used in LiveView, API, Background Job, Tests.
  • Security — authorization policies are tied to specific actions.
  • Testability — easy to test individual actions.


Comparison with Classic Elixir/Phoenix

Approach Where is logic? Number of files Clarity
Ecto + Context Context + Changeset 4–8 Medium
Ash Actions In one Resource 1 High

Actions are where all your business logic lives.




🏢 What is a Domain?

Domain is a logical container that groups related Resources.
It is similar to Phoenix Context, but much more powerful. If Resource describes one entity (like User, Lesson, Order), then Domain is a "module" or "bounded context" (in DDD terms) that brings together several entities of one business area.

Example:

  • Accounts — domain for users, profiles, authorization.
  • Courses — domain for courses, lessons, tests, enrollments.
  • Billing — domain for payments, subscriptions, invoices.
  • Therapy — domain for therapy sessions, conversations, messages.


Three Main Goals of Domain

  1. Grouping Resources Organizing the project. Instead of all resources being scattered, they are explicitly grouped into logical units.
  2. Centralized Code Interface Domain allows defining convenient wrapper functions for calling resource actions.
  3. Shared Settings and Cross-Cutting Concerns Here you can set behavior that applies to all resources in the domain (timeouts, default pagination, authorization, etc.).


How Domain Looks in Practice

defmodule CourseHub.Courses do
  use Ash.Domain, otp_app: :course_hub

  # 1. Grouping resources
  resources do
    resource CourseHub.Courses.Course
    resource CourseHub.Courses.Lesson
    resource CourseHub.Courses.Quiz
    resource CourseHub.Courses.Enrollment
  end

  # 2. Code Interface — convenient functions
  define :get_lesson, action: :read, args: [:id], get?: true
  define :list_published_courses, action: :published
  define :enroll_user, action: :create, resource: CourseHub.Courses.Enrollment

  # 3. Shared settings
  execution do
    timeout :timer.seconds(30)
    default_page_size 20
  end

  # Enabling AshAdmin (admin panel)
  admin do
    show? true
  end
end
Enter fullscreen mode Exit fullscreen mode


Why Do You Need Domain?

  • Organization and boundaries. Clearly separates different parts of the application (like bounded contexts in DDD). Accounts doesn't know about Billing without explicit relations.
  • Convenient public API. Instead of Ash.read!(CourseHub.Courses.Lesson, ...), you write beautifully:
CourseHub.Courses.get_lesson!(lesson_id)
CourseHub.Courses.list_published_courses!()
Enter fullscreen mode Exit fullscreen mode
  • Centralized management. You can disable authorization for the entire domain in dev mode, set common policies, notifiers, etc.
  • Introspection and extensions. Many Ash extensions (GraphQL, JsonApi, Phoenix) work at the domain level.
  • Testing and isolation. Easier to test one business area as a whole.


Best Practices When Working with Domains

  • One resource — one domain. A resource cannot belong to multiple domains simultaneously.
  • How many domains to make?
    • Small project → 1–3 domains.
    • Medium/large → one per major business area (Accounts, Core, Billing, Notifications, etc.). This helps with scaling and understanding architecture.
  • Code Interface — a very powerful feature. You can define not only simple wrappers but also those with preset filters, loads, etc.


Comparison: Domain vs Resource

Aspect Resource Domain
What it describes One entity Group of entities
Where logic lies Attributes, actions, policies Grouping + shared settings
Code call Through domain Public app interface
Analog in Phoenix Schema + Context (partially) Phoenix Context
Quantity per project Many (one per table) Few (per business area)


Why Domain Is Especially Useful?

  • Project grows >10–15 resources.
  • Complex authorization (policies) needed.
  • Doing GraphQL / JSON:API / Admin panel.
  • Team development — everyone needs to understand boundaries.




⚔️ Ash + Phoenix vs Ecto + Phoenix

Aspect Phoenix + Ecto (classic) 😐 Phoenix + Ash 🔥 Who wins?
Business logic organization Scattered across Context, Changeset, separate modules Everything in one Resource Ash
CRUD operations Write manually: create, read, update, delete + boilerplate defaults [:create, :read, :update, :destroy] — done ✨ Ash
Authorization Manual checks / Bodyguard / scopes Declarative Policies (secure by default) ⚡ Ash
Computed fields Virtual fields + functions in Context Calculations — auto-loaded and cached Ash
Number of files per entity 3–6+ files (Schema + Context + Changeset + Policy) One file — Resource Ash
API generation Absinthe / GraphQL manually + lots of code AshGraphql or AshJsonApi — add extension and done Ash
LiveView forms Phoenix.Component + manual validation AshPhoenix.Form — forms know everything themselves Ash
Multi-tenancy Write yourself (or use schemas) Built into Policies (attribute-based or context-based) Ash
Maintainability after 2–3 years Medium (logic spreads) High (everything declarative and in one place) Ash
Development speed Normal at start Much faster after learning curve 🚀 Ash
Flexibility Maximum (full control) Very high + can escape to pure Ecto when needed Draw
Learning curve Easier for beginners Steeper but pays off in large projects Ecto


What to Choose in 2026?

Pros of Ash 🔥:

  • Declarativeness → "Describe the domain — Ash does the rest."
  • Boilerplate reduction by orders of magnitude ("I write code 3–5 times faster").
  • Automatic generation of GraphQL, JSON:API, Admin panel, authentication.
  • Authorization policies and multi-tenancy out of the box.
  • Excellent maintainability in the long run (exactly what production dreams of). Pros of classic Phoenix + Ecto 😐:
  • Full control over every byte of code.
  • Simpler for very small projects and pet projects.
  • No extra abstraction (if you love "bare" Elixir).
  • Easier for Elixir beginners. When to choose what?
  • Small pet project or experiment → pure Phoenix + Ecto.
  • Medium/large project, SaaS, long-lived app → Phoenix + Ash (huge time and nerve savings).
  • Want both → you can safely mix in one project (Ash and regular Ecto coexist perfectly).




😴 Conclusion

Ash is not just a library but a shift in approach to Elixir development. It especially shines in medium and large projects where clean architecture, development speed, and long-term maintainability are important.
Of course, it has a learning curve and may be excessive for very simple pet projects. But if you're tired of spreading logic and want a clear declarative structure—Ash gives a very pleasant sense of control over your code.




🙏 From the Author

Thank you very much for your interest in this article! I hope it helped you understand what Ash is and why it's used.
If you liked this article and want more materials like this—join me on my Telegram channel, where I post book reviews, Elixir publications, technical literature, and interesting news!

Top comments (0)