documentation/topics/actions/actions.md
In Ash, actions are the primary way to interact with your resources. There are five types of actions:
All actions can be run in a transaction. Create, update and destroy actions are run in a transaction by default, whereas read and generic actions require opting in with transaction? true in the action definition. Each action has its own set of options, ways of calling it, and ways of customizing it. See the relevant guide for specifics on each action type. This topic focuses on idiomatic ways to use actions, and concepts that cross all action types.
Primary actions are a way to inform the framework which actions should be used in certain "automated" circumstances, or in cases where an action has not been specified. If a primary action is attempted to be used but does not exist, you will get an error about it at runtime.
The place you typically need primary actions is when Managing Relationships. When using the defaults option to add default actions, they are marked as primary.
A simple example where a primary action would be used:
# No action is specified, so we look for a primary read.
Ash.get!(Resource, "8ba0ab56-c6e3-4ab0-9c9c-df70e9945281")
To mark an action as primary, add the option, i.e
read :action_name do
primary? true
end
Create and Update actions can accept attributes as input. There are two primary ways that you annotate this.
accept in specific actionsEach action can define what it accepts, for example:
create :create do
accept [:name, :description]
end
You could then pass in %{name: "a name", description: "a description"} to this action.
default_accept for all actionsThe resource can have a default_accept, declared in its actions block, which will be used as the accept list for create and update actions, if they don't define one.
actions do
default_accept [:name, :description]
create :create
update :update
update :special_update do
accept [:something_else]
end
end
In the example above, you can provide %{name: "a name", description: "a description"} to both the :create and :update actions, but only %{something_else: "some_value"} to :special_update.
You can also use module attributes to define the accept list. This is useful if you have a lot of attributes and different variations for different actions.
@accepts_special_update [:name, :description, :foo, :bar, :baz]
@accepts_super_special_update @accepts_special_update ++ [:something_else, :another_thing]
actions do
default_accept [:name, :description]
create :create
update :update
update :special_update do
accept @accepts_special_update
end
end
This is a simple example, but module attributes become particularly useful when you have many actions that share overlapping sets of accepted attributes. You can compose them using list concatenation (++), making it easy to define a base set of attributes and extend it for specific actions without repetition.
There are two kinds of contexts in Ash:
changeset.context (or equivalent),c:Ash.Resource.Change.change/3, which contains
the above context in it's source_context key, as well as additional information specific to the callback,
and/or commonly needed keys for callbacks (actor, tenant, etc.).Actions accept a free-form map of context, which can be used for whatever you like. Whenever context is set, it is deep merged. I.e if you do changeset |> Ash.Changeset.set_context(%{a: %{b: 1}}) |> Ash.Changeset.set_context(%{a: %{c: 2}}), the resulting context will be %{a: %{b: 1, c: 2}}. Structs are not merged.
There are some special keys in context to note:
:privateThe :private key is reserved for use by Ash itself. You shouldn't read from or write to it.
:sharedThe :shared key will be passed to all nested actions built by Ash, and should be passed by you to any actions you call within changes/preparations etc. Whenever :shared context
is set, it is also written to the outer context. For example set_context(%{shared: %{locale: "en"}}) is equivalent to set_context(%{shared: %{locale: "en"}, locale: "en"})
This will generally happen automatically if you use one of the two abstractions provided by Ash for threading options through to nested action calls.
Careful with shared {: .warning}
Shared context is passed to all nested actions, so don't pass massive values around, and also don't set context
:query_forThis is set on queries when they are being run for a "special" purpose. The values this can take are:
:bulk_update, if the query is being built to power a bulk update action:bulk_destroy, if the query is being built to power a bulk destroy action:load, if the query is being built to power an Ash.load callYou can use this to adjust the behavior of your query preparations as needed.
:bulk_create, :bulk_update, :bulk_destroyThis is set on changesets/queries/action inputs when they are being run in bulk. The value will be a map with the following keys (more may be added in the future):
:index -> The index of the changeset/query/action input in the bulk operation.
Ash.Scope.ToOptsAsh.Scope.ToOpts is newer and is the recommended way to do this. In action callbacks in Ash, you will be provided with a context, which can be passed down as a scope option when running nested actions or building nested changesets/queries. For example:
def change(changeset, opts, context) do
Ash.Changeset.after_action(changeset, fn changeset, result ->
# automatically passes the `shared` context to the nested action
MyApp.MyDomain.create_something_else(..., scope: context, other: :options)
end)
end
To get the opts for a given scope, you can use Ash.Scope.to_opts(scope), but this is typically not
necessary.
Ash.Context.to_opts/2Ash.Context.to_opts/2 is a helper function that converts a context map into a list of options that can be passed to nested actions. It automatically passes the shared context to the nested action as well.
def change(changeset, opts, context) do
Ash.Changeset.after_action(changeset, fn changeset, result ->
# automatically passes the `shared` context to the nested action
MyApp.MyDomain.create_something_else(..., Ash.Context.to_opts(context, other: :options))
end)
end
The intent behind Ash is not to have you building simple CRUD style applications. In a typical set up you may have a resource with four basic actions, there is even a shorthand to accomplish this:
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
But that is just a simple way to get started, or to create resources that really don't do anything beyond those four operations. You can have as many actions as you want. The best designed Ash applications will have numerous actions, named after the intent behind how they are used. They won't have all reads going through a single read action, and the same goes for the other action types. The richer the actions on the resource, the better interface you can have. With that said, many resources may only have those four basic actions, especially those that are "managed" through some parent resource. See the guide on Managing Relationships for more.
Ash provides utilities to modify queries, changesets, and action inputs outside of the actions on the resources. This is a very important tool in our tool belt, but it is very easy to abuse. The intent is that as much behavior as possible is put into the action. Here is the "wrong way" to do it. There is a lot going on here, so don't hesitate to check out other relevant guides if you see something you don't understand.
def top_tickets(user_id) do
Ticket
|> Ash.Query.for_read(:read)
|> Ash.Query.filter(priority in [:medium, :high])
|> Ash.Query.filter(representative_id == ^user_id)
|> Ash.Query.filter(status == :open)
|> Ash.Query.sort(opened_at: :desc)
|> Ash.Query.limit(10)
|> Helpdesk.Support.read!()
end
# in the resource
actions do
defaults [:read, ...]
end
And here is the "right way", where the rules about getting the top tickets have been moved into the resource as a nicely named action, and included in the code_interface of that resource. The reality of the situation is that top_tickets/1 is meant to be obsoleted by your Ash resource! Here is how it should be done.
# in the resource
code_interface do
define :top, args: [:user_id]
end
actions do
read :top do
argument :user_id, :uuid do
allow_nil? false
end
prepare build(limit: 10, sort: [opened_at: :desc])
filter expr(priority in [:medium, :high] and representative_id == ^arg(:user_id) and status == :open)
end
end
Now, whatever code I had that would have called top_tickets/1 can now call Helpdesk.Support.Ticket.top(user.id). By doing it this way, you get the primary benefit of getting a nice simple interface to call into, but you also have a way to modify how the action is invoked in any way necessary, by going back to the old way of building the query manually. For example, if I also only want to see top tickets that were opened in the last 10 minutes:
Ticket
|> Ash.Query.for_read(:top, %{user_id: user.id})
|> Ash.Query.filter(opened_at > ago(10, :minute))
|> Helpdesk.Support.read!()
That is the best of both worlds! These same lessons transfer to all action types (changeset-based, query-based, and generic actions) as well.
Pipelines let you define reusable groups of changes, validations, and preparations that can be shared across multiple actions. Instead of duplicating the same logic in every action, you define it once in a pipeline and reference it with pipe_through.
Pipelines are declared in a top-level pipelines block on the resource. Each pipeline can contain change, validate, and prepare declarations, just like an action.
pipelines do
pipeline :audited do
change set_attribute(:updated_by, actor(:id))
validate present(:updated_by)
end
pipeline :sorted_by_name do
prepare build(sort: [:name])
end
end
Reference pipelines from any action using pipe_through:
actions do
create :create do
accept [:name, :email]
pipe_through [:audited]
end
update :update do
accept [:name, :email]
pipe_through [:audited]
end
read :list do
pipe_through [:sorted_by_name]
end
end
The pipeline's entities are expanded inline at compile time — the action behaves exactly as if you had written the changes, validations, and preparations directly.
Pipelines can contain changes, validations, and preparations, but not all of these apply to every action type:
change and validate entities. Preparations are ignored.prepare and validate entities. Changes are ignored.This means you can define a pipeline with all three entity types and safely reference it from any action type — only the applicable entities will be included.
Pipeline entities are inserted at the position of the pipe_through declaration within the action. This gives you full control over ordering:
create :create do
accept [:name]
change set_attribute(:state, :before) # runs first
pipe_through [:my_pipeline] # pipeline entities run second
change set_attribute(:name, "after") # runs third
end
When referencing multiple pipelines in a single pipe_through, their entities are appended in the order listed:
pipe_through [:pipeline_a, :pipeline_b]
# pipeline_a entities first, then pipeline_b entities
You can also use multiple pipe_through declarations in the same action:
create :create do
pipe_through [:audit]
pipe_through [:guard], where: present(:name)
accept [:name]
end
whereYou can make a pipeline's entities conditional using the where option on pipe_through. The where conditions are prepended to each entity's existing where conditions:
create :create do
accept [:name]
pipe_through [:premium_features], where: attribute_equals(:role, :premium)
end
If a pipeline entity already has its own where condition, both conditions must pass for the entity to apply.
You can inspect pipelines at runtime using Ash.Resource.Info:
# List all pipelines on a resource
Ash.Resource.Info.pipelines(MyResource)
# Get a specific pipeline by name
Ash.Resource.Info.pipeline(MyResource, :audited)
The concept of a "private input" can be somewhat paradoxical, but it can be used by actions that require something provided by the "system",
as well as something provided by the caller. For example, you may want an ip_address input that can't be set by the user. For this,
you have two options.
create :create do
argument :ip_address, :string, allow_nil?: false, public?: false
...
end
Ash.Changeset.for_create(Resource, :create, %{}, private_arguments: %{ip_address: "<ip_address>"})
You can also provide things to the action via context. Context is a map that is a free form map provided to the action.
Context is occasionally used by callers to provide additional information that the action may or may not use.
Context is deep merged with any existing context, and also contains a private key that is reserved for use by Ash internals.
You should not remove or manipulate the private context key in any way.
create :create do
...
change fn changeset, _ ->
changeset.context # %{ip_address: "<ip_address>"}
end
end
Ash.Changeset.for_create(Resource, :create, %{}, context: %{ip_address: "<ip_address>"})
This section provides a comprehensive overview of the Ash resource action lifecycle, detailing when each phase executes in relation to database transactions.
Ash resource actions follow a well-defined lifecycle that ensures proper data validation, transformation, and persistence. The lifecycle is divided into three main phases:
around_action hooks do not complete their "end" phase if the action failsgraph TD
subgraph "Pre-Transaction Phase"
START["Action Invocation
(Ash.create, Ash.read, Ash.run_action, etc.)"] --> PREP["Changeset/Query/ActionInput Creation"]
PREP --> ACTION_PREP["Action Preparations/Validations/Changes
(In order of definition)"]
ACTION_PREP --> AROUND_START["around_transaction (start)"]
AROUND_START --> BEFORE_TRANS["before_transaction"]
end
subgraph "Transaction Phase"
TRANS_START["🔒 Transaction Begins"] --> AROUND_ACTION_START["around_action (start)"]
AROUND_ACTION_START --> BEFORE_ACTION["before_action"]
BEFORE_ACTION --> DATA_LAYER["💾 Data Layer Operation
(Database interaction)"]
DATA_LAYER --> SUCCESS{"Success?"}
SUCCESS -->|Yes| AFTER_ACTION["after_action
(Success only)"]
SUCCESS -->|No| ERROR_HANDLE["Error Handling"]
AFTER_ACTION --> AROUND_ACTION_END["around_action (end)
✅ Only on success"]
AFTER_ACTION -->|No| ERROR_HANDLE
ERROR_HANDLE --> TRANS_ROLLBACK["🔓 Transaction Rollback"]
AROUND_ACTION_END --> TRANS_COMMIT["🔓 Transaction Commit"]
end
subgraph "Post-Transaction Phase"
AFTER_TRANS["after_transaction
(Always runs - success/error)"] --> AROUND_END["around_transaction (end)"]
AROUND_END --> NOTIFICATIONS["Notifications
(If enabled)"]
NOTIFICATIONS --> RESULT["Return Result"]
end
%% Flow connections
BEFORE_TRANS --> TRANS_START
TRANS_COMMIT --> AFTER_TRANS
TRANS_ROLLBACK --> AFTER_TRANS
Ash.create/2, Ash.update/2, Ash.read/2, Ash.destroy/2The hooks execute in the following order (as of Ash 3.0+):
around_transaction (start)before_transactionaround_action (start)before_actionafter_action (success only) OR Error handlingaround_action (end) - Only on successafter_transaction (always runs - success/error)around_transaction (end)around_transaction, before_transaction, after_transactionaround_action, before_action, after_actionafter_action only runs on successful operationsaround_action (end) only runs on successful operationsafter_transaction always runs (success and error)after_transaction can change the final result - can transform errors into successes (useful for retries)after_transaction hooks cannot be added from within other lifecycle hooksbefore_transaction or after_transactiontransaction? true in the action definitionbefore_transaction for external API callsbefore_action for final data modificationsafter_action for transactional side effectsafter_transaction for external notificationsafter_transaction for retry mechanisms and result transformationaround_action cleanup won't run on failuresdefmodule MyApp.User do
use Ash.Resource
actions do
create :create do
accept [:name, :email]
argument :retries, :integer, default: 3, allow_nil?: false
change before_transaction(fn changeset, _context ->
# External API call before transaction
case ExternalService.validate_email(changeset.attributes.email) do
:ok -> changeset
{:error, reason} -> Ash.Changeset.add_error(changeset, reason)
end
end)
change before_action(fn changeset, _context ->
# Final modifications before database
Ash.Changeset.change_attribute(changeset, :created_at, DateTime.utc_now())
end)
change after_action(fn changeset, result, _context ->
# Success-only operations within transaction
Logger.info("User created: #{result.id}")
{:ok, result}
end)
change fn changeset, context ->
# Retry mechanism using after_transaction
if changeset.arguments[:retries] > 0 do
Ash.Changeset.after_transaction(changeset, fn
changeset, {:ok, result} ->
# Success case - send notification and return result
NotificationService.send_welcome_email(result)
{:ok, result}
changeset, {:error, _error} ->
# Error case - retry with decremented counter
__MODULE__
|> Ash.Changeset.for_create(
changeset.action.name,
Map.put(changeset.params, :retries, changeset.arguments.retries - 1),
scope: context
)
|> Ash.create()
end)
else
# No retries left - add final after_transaction for cleanup
Ash.Changeset.after_transaction(changeset, fn changeset, result ->
case result do
{:ok, user} ->
NotificationService.send_welcome_email(user)
result
error ->
Logger.error("User creation failed after all retries")
error
end
end)
end
end
end
end
end
after_transaction hook can transform a failed result into a new attemptafter_transaction hooks based on retry availabilityThis lifecycle ensures data consistency, proper error handling, and allows for complex business logic while maintaining transactional integrity.