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
ㅤ
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
- Everything in one file — a huge plus for understanding the entity.
- Introspection — Ash can read all information about a resource at runtime (fields, actions, policies). This is used for API generation, forms, admin panels.
- Declarativeness — you describe what should be there, not how to implement it.
-
Extensibility — you can add your own
changes,preparations,validations,notifiers. - 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
ㅤ
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)
ㅤ
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
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
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
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
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
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
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
ㅤ
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
- Grouping Resources Organizing the project. Instead of all resources being scattered, they are explicitly grouped into logical units.
- Centralized Code Interface Domain allows defining convenient wrapper functions for calling resource actions.
- 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
ㅤ
Why Do You Need Domain?
-
Organization and boundaries. Clearly separates different parts of the application (like bounded contexts in DDD).
Accountsdoesn't know aboutBillingwithout 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!()
- 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)