DEV Community

Cover image for Ash Framework: Знакомство
Artyom Molchanov
Artyom Molchanov

Posted on

Ash Framework: Знакомство

Когда новичок приходит в Elixir и Phoenix, ему почти всегда первым делом показывают Ecto. Создаём схемы, пишем changeset’ы, выносим запросы в контексты. Подход гибкий, но с ростом проекта начинает проявляться серьёзная проблема 😟

Бизнес-логика расползается по десяткам файлов. В итоге возникает путаница относительно того, куда именно поместить код обработки запросов и как разобраться в существующем беспорядке, а через год-два сложно понять, где находится «правильное» место для кода, а новые разработчики тратят недели на погружение в проект

Ash Framework создан именно для решения этой боли 💪

Важное уточнение: Ash — это не замена Phoenix и не новый веб-фреймворк. Это мощный декларативный слой, который организует бизнес-логику и данные поверх существующей экосистемы.




📖 Предисловие

Важно понимать, что цель этой статьи вовсе не состоит в том, чтобы уговорить вас использовать Ash повсеместно и постоянно!

Сам я люблю Ash именно из-за того, что он даёт возможность собрать всю необходимую логику в едином пространстве, где ей самое место. Этот инструмент значительно упрощает работу, уменьшая количество шаблонного кода и освобождая от утомительной реализации стандартных CRUD.

Если готовы ознакомиться с таким инструментом, но собираетесь прибегать к нему лишь тогда, когда это действительно оправдано — смело читайте дальше. Ash — это не универсальное лекарство от всех проблем, а всего лишь удобный набор полезных инструментов.




🚀 Что такое Ash?

Ash — это фреймворк для быстрой и эффективной разработки приложений на Elixir. Его цель заключается в облегчении запуска новых проектов и обеспечении удобства поддержки существующих решений, гарантируя отличную производительность и стабильность даже после длительного периода активной разработки.

Ключевая идея Ash звучит так:

Точно сформулируй структуру своей бизнес-логики, а всю остальную работу (миграция базы данных, создание API-интерфейсов, валидация данных, управление доступом, оформление форм) возьмет на себя Ash

Фреймворк прекрасно взаимодействует с такими популярными инструментами, как Phoenix, PostgreSQL, GraphQL и LiveView.




🔑 Ключевые абстракции Ash

Всё в Ash строится вокруг трёх основных понятий:

Давайте разберём каждую концепцию подробно!




🧩 Что такое Ресурс?

Resource — это один модуль, в котором полностью описана одна бизнес-сущность вашего приложения (Пользователь, Урок, Заказ, Пост, Платёж и т.д.).

В нём в одном месте собрано всё, что касается этой сущности:

  • Структура данных (поля)
  • Связи с другими сущностями
  • Операции, которые можно выполнять (CRUD + кастомные)
  • Правила валидации
  • Авторизация (кто и что может делать)
  • Вычисляемые поля
  • Индексы и ограничения
  • Настройки хранения в базе

Resource в Ash объединяет в одном модуле то, что в классическом Elixir/Phoenix обычно разносится по нескольким частям: Ecto Schema, Changesets, Context-функции, а также логика авторизации и вычисляемые поля.


Resource — сердце Ash

Ash построен вокруг идеи Resource-Oriented Design. Ты описываешь что представляет собой сущность и что с ней можно делать — а Ash сам генерирует остальное: SQL-запросы, API, формы, миграции, валидацию и т.д.


Полный пример современного Resource

defmodule CourseHub.Courses.Lesson do
  use Ash.Resource,
    otp_app: :course_hub,              # Привязка к приложению
    domain: CourseHub.Courses,         # В каком домене живёт
    data_layer: AshPostgres.DataLayer  # Где храним данные

  # ==================== ХРАНЕНИЕ ====================
  postgres do
    table "lessons"
    repo CourseHub.Repo
  end

  # ==================== ПОЛЯ (Attributes) ====================
  attributes do
    uuid_primary_key :id

    attribute :title, :string do
      public? true               # видно в API и формах
      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]

    # Кастомное чтение
    read :published do
      filter expr(status == :published)
      pagination offset?: true, countable: true
      prepare build(sort: [inserted_at: :desc])
    end

    # Кастомное создание с логикой
    create :publish do
      argument :reason, :string
      change set_attribute(:status, :published)
    end

    # Полностью кастомное действие
    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

  # ==================== ВЫЧИСЛЯЕМЫЕ ПОЛЯ ====================
  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

  # ==================== ИНДЕКСЫ ====================
  identities do
    identity :unique_title_per_author, [:title, :author_id]
  end

  # ==================== АВТОРИЗАЦИЯ ====================
  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


Основные разделы Resource

Раздел Что описывает Аналог в обычном Elixir/Phoenix
attributes Поля и их типы + валидация Ecto Schema + поля
relationships Связи с другими ресурсами Ecto associations
actions Все возможные операции (CRUD + кастомные) Context-функции + Changesets
calculations Вычисляемые поля (на лету) Virtual fields / функции
aggregates Агрегаты (count, sum, avg и т.д.) Запросы с group_by
identities Уникальные индексы Ecto unique constraints
policies Правила авторизации Ручная проверка прав
postgres Настройки хранения в PostgreSQL Миграции


Ключевые особенности Resource

  1. Всё в одном файле — огромный плюс для понимания сущности.
  2. Introspection — Ash может в runtime читать всю информацию о ресурсе (поля, действия, политики). Это используется для генерации API, форм, админок.
  3. Декларативность — ты описываешь что должно быть, а не как это реализовать.
  4. Расширяемость — можно добавлять свои changes, preparations, validations, notifiers.
  5. Embedded Resources — можно создавать ресурсы, которые хранятся не в отдельной таблице, а как JSON/Map внутри другого ресурса.


Когда создавать новый Resource?

Практически всегда, когда появляется новая бизнес-сущность (таблица или даже сложный JSON-объект).




🛠️ Подробнее об Actions (Действиях)

Action (Действие) — это явно объявленная операция, которую можно выполнить над ресурсом.

Всё взаимодействие с данными в Ash идёт только через Actions. Нельзя просто взять запись и изменить её вручную — всегда вызываешь действие.

Это заменяет:

  • Обычные функции в Context’ах Phoenix
  • Changesets в Ecto
  • Контроллеры и сервисные слои


Пять типов Actions

Тип действия Что делает? В транзакции? Что возвращает?
create Создаёт новую запись Да Новую запись ресурса
🔍 read Читает одну запись или целый список Нет (можно включить) Запись или список записей
✏️ update Обновляет существующую запись Да Обновлённую запись
🗑️ destroy Удаляет запись Да Удалённую запись
action (Generic) Любая кастомная бизнес-логика Нет (можно включить) Любое значение (что угодно)


Полный пример всех типов Actions

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

  actions do
    # 1. Стандартные CRUD (defaults)
    defaults [:create, :read, :update, :destroy]

    # 2. Кастомное 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. Кастомное Create
    create :publish do
      argument :reason, :string, allow_nil?: true

      # Изменения (changes)
      change set_attribute(:status, :published)
      change set_attribute(:published_at, &DateTime.utc_now/0)
      change {CourseHub.Changes.NotifySubscribers, topic: "new_lessons"}

      # Хуки
      after_action fn _changeset, lesson, _context ->
        # side-effect после успешного создания
        {:ok, lesson}
      end
    end

    # 4. Кастомное 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 (полностью кастомное)
    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


Как вызывать Actions

# Через домен (рекомендуемый способ)
CourseHub.Courses.publish!(lesson, %{reason: "Готово"})

CourseHub.Courses.read!(CourseHub.Courses.Lesson, :published)

# Или с Ash.Query / Ash.Changeset для сложных случаев
query = Ash.Query.for_read(Lesson, :published)
Ash.read!(query)
Enter fullscreen mode Exit fullscreen mode


Основные возможности Actions

1. argument — входные параметры

Позволяет передавать данные в действие и валидировать их.

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 — какие атрибуты можно менять

Ограничивает список полей, которые разрешено изменять при create или update. Очень полезно для безопасности.

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 — трансформации данных

Самая мощная и часто используемая часть Actions. Здесь происходит основная бизнес-логика.

create :publish do
  argument :reason, :string

  # Встроенные изменения
  change set_attribute(:status, :published)
  change set_attribute(:published_at, &DateTime.utc_now/0)

  # Кастомный change-модуль
  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 — подготовка запроса (в основном для read)

Позволяет заранее настроить запрос: сортировку, предзагрузку связей, дополнительные фильтры и т.д.

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

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

5. Хуки (before/after)

Позволяют выполнить код до или после выполнения действия.

create :publish do
  before_action fn changeset, _context ->
    # проверка перед выполнением
    validate_business_rules(changeset)
  end

  after_action fn _changeset, lesson, _context ->
    # side-effect после успешного создания
    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? — управление транзакцией

Определяет, нужно ли оборачивать действие в базу данных транзакцию.

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

  transaction? true   # будет выполнено в одной транзакции

  run fn input, _context ->
    # сложная операция с несколькими записями
    {:ok, true}
  end
end
Enter fullscreen mode Exit fullscreen mode

7. pipe_through — переиспользование логики

Позволяет создавать общие пайплайны и подключать их к разным действиям (очень полезно для DRY).

# В 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


Почему Actions — это круто?

  • Полная типизация и introspection — Ash знает всё о действии в runtime (используется для GraphQL, форм, админок)
  • Декларативность — код читается как документация бизнеса
  • Повторное использование — одно действие можно использовать в LiveView, API, Background Job, Tests
  • Безопасность — политики авторизации привязываются к конкретным действиям
  • Тестируемость — легко тестировать отдельные действия


Сравнение с классическим Elixir/Phoenix

Подход Где логика? Кол-во файлов Понятность
Ecto + Context Context + Changeset 4–8 Средняя
Ash Actions В одном Resource 1 Высокая

Actions — это то место, где живёт вся бизнес-логика твоего ресурса




🏢 Что такое Domain (Домены)?

Domain (Домен) — это логический контейнер, который объединяет группу связанных Resources (ресурсов).

Он похож на Phoenix Context, но гораздо мощнее. Если Resource — это описание одной сущности (как User, Lesson, Order), то Domain — это «модуль» или «bounded context» (в терминах DDD), который собирает вместе несколько сущностей одной бизнес-области.

Пример:

  • Accounts — домен для пользователей, профилей, авторизации
  • Courses — домен для курсов, уроков, тестов, enrollments
  • Billing — домен для платежей, подписок, инвойсов
  • Therapy — домен для терапии, разговоров, сообщений (из реальных примеров)


Три главные цели Domain

  1. Группировка ресурсов

    Организация проекта. Вместо того чтобы все ресурсы были разбросаны, они явно лежат в логических группах.

  2. Централизованный Code Interface

    Domain позволяет определять удобные функции-обёртки для вызова действий ресурсов.

  3. Общие настройки и cross-cutting concerns

    Здесь можно задать поведение, которое применяется ко всем ресурсам домена (таймауты, пагинация по умолчанию, авторизация и т.д.).


Как выглядит Domain на практике

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

  # 1. Группировка ресурсов
  resources do
    resource CourseHub.Courses.Course
    resource CourseHub.Courses.Lesson
    resource CourseHub.Courses.Quiz
    resource CourseHub.Courses.Enrollment
  end

  # 2. Code Interface — удобные функции
  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. Общие настройки
  execution do
    timeout :timer.seconds(30)        # глобальный таймаут
    default_page_size 20
  end

  # Включение AshAdmin (админка)
  admin do
    show? true
  end
end
Enter fullscreen mode Exit fullscreen mode


Зачем нужен Domain?

  • Организация и границы. Чётко разделяет разные части приложения (как bounded contexts в DDD). Accounts не знает про Billing без явного отношения

  • Удобный публичный API. Вместо Ash.read!(CourseHub.Courses.Lesson, ...) ты пишешь красиво:

  CourseHub.Courses.get_lesson!(lesson_id)
  CourseHub.Courses.list_published_courses!()
Enter fullscreen mode Exit fullscreen mode
  • Централизованное управление. Можно отключить авторизацию для всего домена в dev-режиме, задать общие политики, notifiers и т.д.

  • Introspection и расширения. Многие расширения Ash (GraphQL, JsonApi, Phoenix и т.д.) работают на уровне домена

  • Тестирование и изоляция. Легче тестировать одну бизнес-область целиком


Лучшие практики при работе с доменом

  • Один ресурс — один домен. Ресурс не может принадлежать нескольким доменам одновременно.

  • Сколько доменов делать?

    • Маленький проект → 1–3 домена.
    • Средний/большой → по одному на крупную бизнес-область (Accounts, Core, Billing, Notifications и т.д.). Это помогает в масштабировании и понимании архитектуры.
  • Code Interface — очень мощная фича. Ты можешь определять не только простые обёртки, но и с предустановленными фильтрами, load'ами и т.д.


Сравнение: Domain vs Resource

Аспект Resource Domain
Что описывает Одну сущность Группу сущностей
Где лежит логика Атрибуты, действия, политики Группировка + общие настройки
Вызов кода Через домен Публичный интерфейс приложения
Аналог в Phoenix Schema + Context (частично) Phoenix Context
Количество на проект Много (по одной на таблицу) Мало (по бизнес-областям)


Почему Domain особенно полезен?

  • Проект растёт > 10–15 ресурсов
  • Нужна сложная авторизация (policies)
  • Делаешь GraphQL / JSON:API / Admin-панель
  • Командная разработка — всем нужно понимать границы




⚔️ Ash + Phoenix vs Ecto + Phoenix

Аспект Phoenix + Ecto (классика) 😐 Phoenix + Ash 🔥 Кто выигрывает?
Организация бизнес-логики Разбросана по Context, Changeset, отдельным модулям Всё в одном Resource — атрибуты, действия, политики, связи Ash (всё на виду)
CRUD-операции Пишешь вручную: create, read, update, delete + boilerplate defaults [:create, :read, :update, :destroy] — и готово ✨ Ash (1 строка)
Авторизация Ручные проверки / Bodyguard / scopes Декларативные Policies (secure by default) ⚡ Ash (автоматика)
Вычисляемые поля Virtual fields + функции в Context Calculations — автоматически подгружаются и кэшируются Ash
Количество файлов на сущность 3–6+ файлов (Schema + Context + Changeset + Policy) 1 файл — Resource Ash
Генерация API Absinthe / GraphQL вручную + много кода AshGraphql или AshJsonApi — добавил расширение и готово Ash
LiveView формы Phoenix.Component + ручная валидация AshPhoenix.Form — формы сами знают всё Ash
Multi-tenancy Пишешь сам (или используешь схемы) Встроено в Policies (attribute-based или context-based) Ash
Поддерживаемость через 2–3 года Средняя (логика расползается) Высокая (всё декларативно и в одном месте) Ash
Скорость разработки Нормальная на старте Значительно быстрее после кривой обучения 🚀 Ash (после обучения)
Гибкость Максимальная (полный контроль) Очень высокая + можно escape в чистый Ecto когда нужно Ничья
Кривая обучения Легче для новичков Круче, но окупается в большом проекте Ecto

Что стоит выбрать в 2026 году?

Плюсы Ash 🔥

  • Декларативность → «Опиши домен — остальное Ash выведет сам»
  • Сокращение шаблонного кода (часто встречаются отзывы о Ash вроде: «я создаю программы, используя в 3–5 раз меньше строк»)
  • Автоматическая генерация GraphQL, JSON:API, Admin-панели, аутентификации
  • Политики авторизации и multi-tenancy из коробки
  • Отличная поддерживаемость на длинной дистанции (именно то, о чём мечтают в продакшене)

Плюсы классического Phoenix + Ecto 😐

  • Полный контроль над каждым байтом кода
  • Проще для совсем маленьких проектов и пет-проектов
  • Нет дополнительной абстракции (если ты любишь «голый» Elixir)
  • Легче для новичков в Elixir

Когда выбирать что?

  • Маленький пет-проект или эксперимент → бери чистый Phoenix + Ecto
  • Средний/крупный проект, SaaS, долгоживущее приложение → Phoenix + Ash (экономия времени и нервов огромная)
  • Хочешь и то, и другое → можно спокойно миксовать в одном проекте (Ash и обычный Ecto отлично уживаются)


😴 В заключение

Ash — это не просто библиотека, а изменение подхода к разработке на Elixir. Он особенно хорошо заходит в проектах среднего и крупного размера, где важны чистота архитектуры, скорость разработки и долгосрочная поддерживаемость.

Конечно, у него есть кривая обучения, и в совсем простых пет-проектах он может быть избыточен. Но если вы устали от расползающейся логики и хотите чёткой, декларативной структуры — Ash дарит очень приятное ощущение контроля над кодом.




🥴 От автора

Спасибо большое за интерес к этой статье! Надеюсь, она помогла разобраться, что представляет собой Ash и зачем он используется.

Если эта статья пришлась вам по душе и хочется ещё материалов такого плана, присоединяйтесь ко мне в моём телеграм-канале, где выкладываю обзоры книг, публикации по Elixir, переводы технической литературы и интересные новости!

Top comments (0)