Back to Ash

Actions

usage-rules/actions.md

3.24.614.1 KB
Original Source
<!-- SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs/contributors> SPDX-License-Identifier: MIT -->

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.
  • Instead of defining functions in the domain, you should be defining actions and exposing them through code interface calls in the domain. Use standard actions when they fit what you're doing and generic actions when you need arbitrary functionality.

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.

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

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, []}
    

Atomic Changes

Atomic changes execute directly in the database as part of the update query, without requiring the record to be loaded first. This provides better performance and correct behavior under concurrent updates.

Why atomic matters:

  • Avoids race conditions (e.g., incrementing a counter)
  • Better performance (no round-trip to load the record)
  • Required for bulk operations to work efficiently

Built-in atomic changes:

elixir
# Increment a counter atomically
change atomic_update(:view_count, expr(view_count + 1))

# Set a value using an expression
change set_attribute(:updated_at, expr(now()))

Making custom changes atomic: Implement the atomic/3 callback to support atomic execution:

elixir
defmodule MyApp.Changes.IncrementVersion do
  use Ash.Resource.Change

  @impl true
  def change(changeset, _opts, _context) do
    # Fallback for non-atomic execution
    current = Ash.Changeset.get_attribute(changeset, :version) || 0
    Ash.Changeset.change_attribute(changeset, :version, current + 1)
  end

  @impl true
  def atomic(_changeset, _opts, _context) do
    # Atomic implementation - runs in the database
    {:atomic, %{version: expr(coalesce(version, 0) + 1)}}
  end
end

Using require_atomic? false

By default, update and destroy actions require all changes and validations to support atomic execution. If they don't, the action will raise an error.

IMPORTANT: When you see require_atomic? false on an action, carefully consider whether it is truly necessary. This option should be used sparingly.

When require_atomic? false is needed:

  • The action has before_action or around_action hooks that need to read or modify the record
  • A change reads the current record state (e.g., Ash.Changeset.get_data/2) and cannot be rewritten atomically
  • Complex validations that cannot be expressed as database expressions

When require_atomic? false is NOT needed:

  • Simple attribute transformations (these can usually be made atomic)
  • Setting timestamps or default values (use expr(now()) instead)
  • Incrementing counters (use atomic_update/2)
  • After-action hooks (these don't prevent atomic execution)
  • After-transaction hooks (these don't prevent atomic execution)
elixir
actions do
  update :update do
    # AVOID unless truly necessary
    require_atomic? false
  end

  update :increment_views do
    # GOOD - fully atomic, no need to disable
    change atomic_update(:view_count, expr(view_count + 1))
  end
end

If you find yourself adding require_atomic? false, first check if your changes and validations can be rewritten with atomic/3 callbacks. Only disable atomic requirements when the action genuinely needs to read or manipulate the record in hooks.

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)
    end)
  end
end

change MyApp.MyDomain.MyResource.Changes.SlugifyName

Action Types

  • Read: For retrieving records
  • Create: For creating records
  • Update: For changing records
  • Destroy: For removing records
  • Generic: For custom operations that don't fit the other types