ash-guidelines
Comprehensive conventions for modeling domains and actions with the Ash framework
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- GitHub stars: 1
- Updated: 2025-11-21
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: ash-guidelines
description: Comprehensive conventions for modeling domains and actions with the Ash framework
---
<!--
SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs.contributors>
SPDX-License-Identifier: MIT
-->
# Rules for working with Ash
## Understanding Ash
Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation *before* attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions.
## Code Structure & Organization
- Organize code around domains and resources
- Each resource should be focused and well-named
- Create domain-specific actions rather than generic CRUD operations
- Put business logic inside actions rather than in external modules
- Use resources to model your domain entities
## Multi-Tenancy & Company Scoping
**⚠️ CRITICAL PROJECT REQUIREMENT**
This project implements multi-tenancy with company-based data isolation. ALL tenant-scoped resources MUST follow these patterns:
### Required for ALL New Tables
1. **Add `tenant_id` column** to every tenant-scoped table:
```elixir
postgres do
table "table_name"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :tenant_id, :uuid, allow_nil?: false
# ... other attributes
end
relationships do
belongs_to :company, MyApp.Authorization.Company
end
```
2. **Add database foreign key and index:**
```elixir
# In migration
references :tenant_id, "authz_tenants",
on_delete: :delete,
on_update: :update,
name: "table_name_tenant_id_fkey"
create index("table_name", [:tenant_id])
```
### Automatic Company Filtering
3. **Add row-level security policies** (ALL resources):
```elixir
policies do
# Automatic company filtering on reads
policy action_type(:read) do
authorize_if expr(tenant_id == ^actor(:current_tenant_id))
end
# Automatic tenant_id on creates
policy action_type(:create) do
authorize_if expr(tenant_id == ^actor(:current_tenant_id))
end
end
```
4. **Automatically set tenant_id on create:**
```elixir
actions do
create :create do
accept [:field1, :field2] # Do NOT include :tenant_id
change set_attribute(:tenant_id, actor(:current_tenant_id))
end
end
```
5. **tenant_id is IMMUTABLE** (cannot be changed after creation):
```elixir
actions do
update :update do
accept [:field1, :field2] # Do NOT include :tenant_id in updates
end
end
```
### Session Context Requirements
6. **All LiveView pages MUST have company context:**
```elixir
# In router.ex
live_session :authenticated_with_company,
on_mount: [
{MyAppWeb.LiveUserAuth, :ensure_authenticated},
{MyAppWeb.LiveAuthzAuth, :require_company_context}
] do
live "/forms", FormsLive.Index
end
```
7. **Actor struct must include company context:**
```elixir
# Socket assigns in LiveView
%{
current_authn_user: %User{...},
current_tenant_id: "uuid",
current_authz_user: %AuthzUser{tenant_id: "uuid", role: :admin}
}
```
### Which Resources Need tenant_id?
**YES - Add tenant_id:**
- Business data resources (Forms, Submissions, Contacts, Deals, etc.)
- User-generated content within a company
- Company-specific settings/configuration
- Any data that should be isolated per company
**NO - Skip tenant_id:**
- `authz_tenants` table itself (the tenant root)
- `authn_users` table (authentication identity, not scoped)
- System-wide lookup tables (countries, timezones, etc.)
### Reference Implementation
See existing multi-tenancy specs:
- `specs/01-domains/authorization/policies/row_level_security.md` - Complete row-level security patterns
- `specs/01-domains/authorization/features/multi_tenancy.feature.md` - BDD scenarios
- `lib/clientt_crm_app/authorization/*` - Working examples (Company, AuthzUser, Team)
**If you forget to add tenant_id:** Data will leak across companies, violating security and compliance requirements!
## Code Interfaces
Use code interfaces on domains to define the contract for calling into Ash resources. See the [Code interface guide for more](https://hexdocs.pm/ash/code-interfaces.html).
Define code interfaces on the domain, like this:
```elixir
resource ResourceName do
define :fun_name, action: :action_name
end
```
For more complex interfaces with custom transformations:
```elixir
define :custom_action do
action :action_name
args [:arg1, :arg2]
custom_input :arg1, MyType do
transform do
to :target_field
using &MyModule.transform_function/1
end
end
end
```
Prefer using the primary read action for "get" style code interfaces, and using `get_by` when the field you are looking up by is the primary key or has an `identity` on the resource.
```elixir
resource ResourceName do
define :get_thing, action: :read, get_by: [:id]
end
```
**Avoid direct Ash calls in web modules** - Don't use `Ash.get!/2` and `Ash.load!/2` directly in LiveViews/Controllers, similar to avoiding `Repo.get/2` outside context modules:
You can also pass additional inputs in to code interfaces before the options:
```elixir
resource ResourceName do
define :create, action: :action_name, args: [:field1]
end
```
```elixir
Domain.create!(field1_value, %{field2: field2_value}, actor: current_user)
```
You should generally prefer using this map of extra inputs over defining optional arguments.
```elixir
# BAD - in LiveView/Controller
group = MyApp.Resource |> Ash.get!(id) |> Ash.load!(rel: [:nested])
# GOOD - use code interface with get_by
resource DashboardGroup do
define :get_dashboard_group_by_id, action: :read, get_by: [:id]
end
# Then call:
MyApp.Domain.get_dashboard_group_by_id!(id, load: [rel: [:nested]])
```
**Code interface options** - Prefer passing options directly to code interface functions rather than building queries manually:
```elixir
# PREFERRED - Use the query option for filter, sort, limit, etc.
# the query option is passed to `Ash.Query.build/2`
posts = MyApp.Blog.list_posts!(
query: [
filter: [status: :published],
sort: [published_at: :desc],
limit: 10
],
load: [author: :profile, comments: [:author]]
)
# All query-related options go in the query parameter
users = MyApp.Accounts.list_users!(
query: [filter: [active: true], sort: [created_at: :desc]],
load: [:profile]
)
# AVOID - Verbose manual query building
query = MyApp.Post |> Ash.Query.filter(...) |> Ash.Query.load(...)
posts = Ash.read!(query)
```
Supported options: `load:`, `query:` (which accepts `filter:`, `sort:`, `limit:`, `offset:`, etc.), `page:`, `stream?:`
**Using Scopes in LiveViews** - When using `Ash.Scope`, the scope will typically be assigned to `scope` in LiveViews and used like so:
```elixir
# In your LiveView
MyApp.Blog.create_post!("new post", scope: socket.assigns.scope)
```
Inside action hooks and callbacks, use the provided `context` parameter as your scope instead:
```elixir
|> Ash.Changeset.before_transaction(fn changeset, context ->
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
changeset
end)
```
### Authorization Functions
For each action defined in a code interface, Ash automatically generates corresponding authorization check functions:
- `can_action_name?(actor, params \\ %{}, opts \\ [])` - Returns `true`/`false` for authorization checks
- `can_action_name(actor, params \\ %{}, opts \\ [])` - Returns `{:ok, true/false}` or `{:error, reason}`
Example usage:
```elixir
# Check if user can create a post
if MyApp.Blog.can_create_post?(current_user) do
# Show create button
end
# Check if user can update a specific post
if MyApp.Blog.can_update_post?(current_user, post) do
# Show edit button
end
# Check if user can destroy a specific comment
if MyApp.Blog.can_destroy_comment?(current_user, comment) do
# Show delete button
end
```
These functions are particularly useful for conditional rendering of UI elements based on user permissions.
## Actions
- Create specific, well-named actions rather than generic ones
- Put all business logic inside action definitions
- Use hooks like `Ash.Changeset.after_action/2`, `Ash.Changeset.before_action/2` to add additional logic
inside the same transaction.
- Use hooks like `Ash.Changeset.after_transaction/2`, `Ash.Changeset.before_transaction/2` to add additional logic
outside the transaction.
- Use action arguments for inputs that need validation
- Use preparations to modify queries before execution
- Preparations support `where` clauses for conditional execution
- Use `only_when_valid?` to skip preparations when the query is invalid
- Use changes to modify changesets before execution
- Use validations to validate changesets before execution
- Prefer domain code interfaces to call actions instead of directly building queries/changesets and calling functions in the `Ash` module
- A resource could be *only generic actions*. This can be useful when you are using a resource only to model behavior.
## Querying Data
Use `Ash.Query` to build queries for reading data from your resources. The query module provides a declarative way to filter, sort, and load data.
## Ash.Query.filter is a macro
**Important**: You must `require Ash.Query` if you want to use `Ash.Query.filter/2`, as it is a macro.
If you see errors like the following:
```
Ash.Query.filter(MyResource, id == ^id)
error: misplaced operator ^id
The pin operator ^ is supported only inside matches or inside custom macros...
```
```
iex(3)> Ash.Query.filter(MyResource, something == true)
error: undefined variable "something"
└─ iex:3
```
You are very likely missing a `require Ash.Query`
### Common Query Operations
- **Filter**: `Ash.Query.filter(query, field == value)`
- **Sort**: `Ash.Query.sort(query, field: :asc)`
- **Load relationships**: `Ash.Query.load(query, [:author, :comments])`
- **Limit**: `Ash.Query.limit(query, 10)`
- **Offset**: `Ash.Query.offset(query, 20)`
## Error Handling
Functions to call actions, like `Ash.create` and code interfaces like `MyApp.Accounts.register_user` all return ok/error tuples. All have `!` variations, like `Ash.create!` and `MyApp.Accounts.register_user!`. Use the `!` variations when you want to "let it crash", like if looking something up that should definitely exist, or calling an action that should always succeed. Always prefer the raising `!` variation over something like `{:ok, user} = MyApp.Accounts.register_user(...)`.
All Ash code returns errors in the form of `{:error, error_class}`. Ash categorizes errors into four main classes:
1. **Forbidden** (`Ash.Error.Forbidden`) - Occurs when a user attempts an action they don't have permission to perform
2. **Invalid** (`Ash.Error.Invalid`) - Occurs when input data doesn't meet validation requirements
3. **Framework** (`Ash.Error.Framework`) - Occurs when there's an issue with how Ash is being used
4. **Unknown** (`Ash.Error.Unknown`) - Occurs for unexpected errors that don't fit the other categories
These error classes help you catch and handle errors at an appropriate level of granularity. An error class will always be the "worst" (highest in the above list) error class from above. Each error class can contain multiple underlying errors, accessible via the `errors` field on the exception.
### Using Validations
Validations ensure that data meets your business requirements before it gets processed by an action. Unlike changes, validations cannot modify the changeset - they can only validate it or add errors.
Validations work on both changesets and queries. Built-in validations that support queries include:
- `action_is`, `argument_does_not_equal`, `argument_equals`, `argument_in`
- `compare`, `confirm`, `match`, `negate`, `one_of`, `present`, `string_length`
- Custom validations that implement the `supports/1` callback
Common validation patterns:
```elixir
# Built-in validations with custom messages
validate compare(:age, greater_than_or_equal_to: 18) do
message "You must be at least 18 years old"
end
validate match(:email, "@")
validate one_of(:status, [:active, :inactive, :pending])
# Conditional validations with where clauses
validate present(:phone_number) do
where present(:contact_method) and eq(:contact_method, "phone")
end
# only_when_valid? - skip validation if prior validations failed
validate expensive_validation() do
only_when_valid? true
end
# Action-specific vs global validations
actions do
create :sign_up do
validate present([:email, :password]) # Only for this action
end
read :search do
argument :email, :string
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) # Validates query arguments
end
end
validations do
validate present([:title, :body]), on: [:create, :update] # Multiple actions
end
```
- Create **custom validation modules** for complex validation logic:
```elixir
defmodule MyApp.Validations.UniqueUsername do
use Ash.Resource.Validation
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(changeset, _opts, _context) do
# Validation logic here
# Return :ok or {:error, message}
end
end
# Usage in resource:
validate {MyApp.Validations.UniqueUsername, []}
```
- Make validations **atomic** when possible to ensure they work correctly with direct database operations by implementing the `atomic/3` callback in custom validation modules.
### Using Preparations
Preparations modify queries before they're executed. They are used to add filters, sorts, or other query modifications based on the query context.
Common preparation patterns:
```elixir
# Built-in preparations
prepare build(sort: [created_at: :desc])
prepare build(filter: [active: true])
# Conditional preparations with where clauses
prepare build(filter: [visible: true]) do
where argument_equals(:include_hidden, false)
end
# only_when_valid? - skip preparation if prior validations failed
prepare expensive_preparation() do
only_when_valid? true
end
# Action-specific vs global preparations
actions do
read :recent do
prepare build(sort: [created_at: :desc], limit: 10)
end
end
preparations do
prepare build(filter: [deleted: false]), on: [:read, :update]
end
```
```elixir
defmodule MyApp.Validations.IsEven do
# transform and validate opts
use Ash.Resource.Validation
@impl true
def init(opts) do
if is_atom(opts[:attribute]) do
{:ok, opts}
else
{:error, "attribute must be an atom!"}
end
end
@impl true
# This is optional, but useful to have in addition to validation
# so you get early feedback for validations that can otherwise
# only run in the datalayer
def validate(changeset, opts, _context) do
value = Ash.Changeset.get_attribute(changeset, opts[:attribute])
if is_nil(value) || (is_number(value) && rem(value, 2) == 0) do
:ok
else
{:error, field: opts[:attribute], message: "must be an even number"}
end
end
@impl true
def atomic(changeset, opts, context) do
{:atomic,
# the list of attributes that are involved in the validation
[opts[:attribute]],
# the condition that should cause the error
# here we refer to the new value or the current value
expr(rem(^atomic_ref(opts[:attribute]), 2) != 0),
# the error expression
expr(
error(^InvalidAttribute, %{
field: ^opts[:attribute],
# the value that caused the error
value: ^atomic_ref(opts[:attribute]),
# the message to display
message: ^(context.message || "%{field} must be an even number"),
vars: %{field: ^opts[:attribute]}
})
)
}
end
end
```
- **Avoid redundant validations** - Don't add validations that duplicate attribute constraints:
```elixir
# WRONG - redundant validation
attribute :name, :string do
allow_nil? false
constraints min_length: 1
end
validate present(:name) do # Redundant! allow_nil? false already handles this
message "Name is required"
end
validate attribute_does_not_equal(:name, "") do # Redundant! min_length: 1 already handles this
message "Name cannot be empty"
end
# CORRECT - let attribute constraints handle basic validation
attribute :name, :string do
allow_nil? false
constraints min_length: 1
end
```
### Using Changes
Changes allow you to modify the changeset before it gets processed by an action. Unlike validations, changes can manipulate attribute values, add attributes, or perform other data transformations.
Common change patterns:
```elixir
# Built-in changes with conditions
change set_attribute(:status, "pending")
change relate_actor(:creator) do
where present(:actor)
end
change atomic_update(:counter, expr(^counter + 1))
# Action-specific vs global changes
actions do
create :sign_up do
change set_attribute(:joined_at, expr(now())) # Only for this action
end
end
changes do
change set_attribute(:updated_at, expr(now())), on: :update # Multiple actions
change manage_relationship(:items, type: :append), on: [:create, :update]
end
```
- Create **custom change modules** for reusable transformation logic:
```elixir
defmodule MyApp.Changes.SlugifyTitle do
use Ash.Resource.Change
def change(changeset, _opts, _context) do
title = Ash.Changeset.get_attribute(changeset, :title)
if title do
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
Ash.Changeset.change_attribute(changeset, :slug, slug)
else
changeset
end
end
end
# Usage in resource:
change {MyApp.Changes.SlugifyTitle, []}
```
- Create a **change module with lifecycle hooks** to handle complex multi-step operations:
```elixir
defmodule MyApp.Changes.ProcessOrder do
use Ash.Resource.Change
def change(changeset, _opts, context) do
changeset
|> Ash.Changeset.before_transaction(fn changeset ->
# Runs before the transaction starts
# Use for external API calls, logging, etc.
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
changeset
end)
|> Ash.Changeset.before_action(fn changeset ->
# Runs inside the transaction before the main action
# Use for related database changes in the same transaction
Ash.Changeset.change_attribute(changeset, :processed_at, DateTime.utc_now())
end)
|> Ash.Changeset.after_action(fn changeset, result ->
# Runs inside the transaction after the main action, only on success
# Use for related database changes that depend on the result
MyApp.Inventory.update_stock_levels(result, scope: context)
{changeset, result}
end)
|> Ash.Changeset.after_transaction(fn changeset,
{:ok, result} ->
# Runs after the transaction completes (success or failure)
# Use for notifications, external systems, etc.
MyApp.Mailer.send_order_confirmation(result, scope: context)
{changeset, result}
{:error, error} ->
# Runs after the transaction completes (success or failure)
# Use for notifications, external systems, etc.
MyApp.Mailer.send_order_issue_notice(result, scope: context)
{:error, error}
end)
end
end
# Usage in resource:
change {MyApp.Changes.ProcessOrder, []}
```
## Custom Modules vs. Anonymous Functions
Prefer to put code in its own module and refer to that in changes, preparations, validations etc.
For example, prefer this:
```elixir
defmodule MyApp.MyDomain.MyResource.Changes.SlugifyName do
use Ash.Resource.Change
def change(changeset, _, _) do
Ash.Changeset.before_action(changeset, fn changeset, _ ->
slug = MyApp.Slug.get()
Ash.Changeset.force_change_attribute(changeset, :slug, slug)