Когда новичок приходит в 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 (Ресурс) — описание одной бизнес-сущности
- Action (Действие) — операция, которую можно выполнить над ресурсом
- Domain (Домен) — логическая группа ресурсов одной бизнес-области
Давайте разберём каждую концепцию подробно!
ㅤ
ㅤ
🧩 Что такое Ресурс?
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
ㅤ
Основные разделы 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
- Всё в одном файле — огромный плюс для понимания сущности.
- Introspection — Ash может в runtime читать всю информацию о ресурсе (поля, действия, политики). Это используется для генерации API, форм, админок.
- Декларативность — ты описываешь что должно быть, а не как это реализовать.
-
Расширяемость — можно добавлять свои
changes,preparations,validations,notifiers. - 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
ㅤ
Как вызывать 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)
ㅤ
Основные возможности 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
2. accept — какие атрибуты можно менять
Ограничивает список полей, которые разрешено изменять при create или update. Очень полезно для безопасности.
create :register do
accept [:email, :password, :name] # только эти поля можно передать
end
update :update_profile do
accept [:name, :bio, :avatar_url]
end
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
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
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
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
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
ㅤ
Почему 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
Группировка ресурсов
Организация проекта. Вместо того чтобы все ресурсы были разбросаны, они явно лежат в логических группах.Централизованный Code Interface
Domain позволяет определять удобные функции-обёртки для вызова действий ресурсов.Общие настройки и 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
ㅤ
Зачем нужен Domain?
Организация и границы. Чётко разделяет разные части приложения (как bounded contexts в DDD).
Accountsне знает проBillingбез явного отношенияУдобный публичный API. Вместо
Ash.read!(CourseHub.Courses.Lesson, ...)ты пишешь красиво:
CourseHub.Courses.get_lesson!(lesson_id)
CourseHub.Courses.list_published_courses!()
Централизованное управление. Можно отключить авторизацию для всего домена в 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)