Validations

In Ash there are two areas where validations are used:

  • Attributes Those are used to validate the input data before it is saved to the database.

  • Actions Those are used to validate the input data before an action is executed.

Attributes Validations

Validations are used when constraints are not powerful enough.

Let me show you how to use validations with an online shop product example.

Setting Up a Fresh Ash App

The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.

$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app

For a Phoenix application with Ash and PostgreSQL support:

$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app

Igniter creates the App.Repo wrapper (when ash_postgres is included), the application’s supervision tree, a sensible .formatter.exs, and the Postgres config for every environment. You still need to add the config :app, :ash_domains, [App.Shop] line to config/config.exs once you define your first domain, which we do in each chapter that uses this include.

Interactive web installer

You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.

Manual setup alternative

If you want to see every file Igniter would create, walk through the Ash Setup Guide. For a purely manual route, mix new --sup app, add {:ash, "~> 3.0"} to mix.exs, run mix deps.get, and configure .formatter.exs with import_deps: [:ash]. Everything else in this chapter then matches the Igniter flow.

Please create the following files:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      allow_nil? false
      public? true
    end

    attribute :price, :decimal do
      allow_nil? false
      public? true
    end

    attribute :use_by_date, :date do
      allow_nil? false
      public? true
    end
  end

  actions do
    default_accept [:name, :price, :use_by_date]
    defaults [:create, :read, :update, :destroy]
  end
end
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :list_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
  end
end

Custom Validations

We only add products to our shop that are not expired. For that we add a custom validation for the use_by_date attribute. It must not be in the past. We need to write a custom validation for that.

lib/app/validations/in_the_future_or_today.ex
defmodule App.Validations.InTheFutureOrToday do
  use Ash.Resource.Validation

  def validate(changeset, opts, _context) do
    case Ash.Changeset.fetch_argument_or_change(changeset, opts[:field]) do
      :error ->
        # in this case, they aren't changing the field
        :ok

      {:ok, value} ->
        case Date.compare(Date.utc_today(), value) do
          :gt ->
            {:error, field: opts[:field], message: "must be in the future or today"}

          _ ->
            :ok
        end
    end
  end
end
Ash 3.x passes a third context argument (a Ash.Resource.Validation.Context) to the validate/3 callback. Old Ash 2.x tutorials show a two-argument version; adjust accordingly if you port code from there.

That validation can be used like this:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      allow_nil? false
      public? true
    end

    attribute :price, :decimal do
      allow_nil? false
      public? true
    end

    attribute :use_by_date, :date do
      allow_nil? false
      public? true
    end
  end

  validations do
    validate {App.Validations.InTheFutureOrToday, field: :use_by_date} (1)
  end

  actions do
    default_accept [:name, :price, :use_by_date]
    defaults [:create, :read, :update, :destroy]
  end
end
1 Here we are using the validation App.Validations.InTheFutureOrToday.

Let’s try it out:

$ iex -S mix
iex(1)> App.Shop.create_product!(%{
                                   name: "Apple",
                                   price: Decimal.new("0.1"),
                                   use_by_date: ~D[2008-11-10]
                                 })
** (Ash.Error.Invalid) Invalid Error

* Invalid value provided for use_by_date: must be in the future or today.

Value: nil

    (ash 3.24.3) lib/ash/code_interface.ex:...: Ash.CodeInterface.create_act!/4

With custom validations you can solve pretty much any validation problem.

Action Validations

Attribute validations run every time the attribute changes. Action validations are more selective: they run only for the action you attach them to. They are the right tool for cross-field checks ("sale price below regular price"), multi-field presence checks ("at least one of these must be filled in"), and any rule that should apply to, say, :create but not :update.

Ash ships with a handful of built-in validators you call declaratively inside the action, plus the same Ash.Resource.Validation behaviour you already saw in the attribute validations chapter.

Setting Up a Fresh Ash App

The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.

$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app

For a Phoenix application with Ash and PostgreSQL support:

$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app

Igniter creates the App.Repo wrapper (when ash_postgres is included), the application’s supervision tree, a sensible .formatter.exs, and the Postgres config for every environment. You still need to add the config :app, :ash_domains, [App.Shop] line to config/config.exs once you define your first domain, which we do in each chapter that uses this include.

Interactive web installer

You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.

Manual setup alternative

If you want to see every file Igniter would create, walk through the Ash Setup Guide. For a purely manual route, mix new --sup app, add {:ash, "~> 3.0"} to mix.exs, run mix deps.get, and configure .formatter.exs with import_deps: [:ash]. Everything else in this chapter then matches the Igniter flow.

Our playground is a Product resource with a regular :price and an optional :sale_price:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Ets,
    domain: App.Shop,
    otp_app: :app

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      allow_nil? false
      public? true
    end

    attribute :price, :decimal do
      allow_nil? false
      public? true
    end

    attribute :sale_price, :decimal, public?: true
    attribute :category, :atom, public?: true
  end

  actions do
    default_accept [:name, :price, :sale_price, :category]
    defaults [:read, :update, :destroy]

    create :create do
      primary? true
    end
  end
end
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :update_product, action: :update
    end
  end
end

Validate only on one action with validate compare/2

compare/2 takes one field and compares it to another field or a literal. We want the sale price, when it is present, to be strictly less than the regular price. Add the validation inside the create action:

lib/app/shop/product.ex
    create :create do
      primary? true

      validate compare(:sale_price, less_than: :price),
        where: [present(:sale_price)], (1)
        message: "must be less than price" (2)
    end
1 where: [present(:sale_price)] skips the check when the user did not supply a sale price. Without it, every create without a sale price would fail because nil < price is not true.
2 Custom message; without it, Ash’s default is "must be less than %{less_than}".

Try a valid and an invalid create:

$ iex -S mix
iex(1)> App.Shop.create_product!(%{
  name: "Apple",
  price: Decimal.new("1.00"),
  sale_price: Decimal.new("0.80"),
  category: :food
})
%App.Shop.Product{
  id: "35757c5a-c7be-4ab9-91b8-8318c1e1d084",
  name: "Apple",
  price: Decimal.new("1.00"),
  sale_price: Decimal.new("0.80"),
  category: :food,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}
iex(2)> App.Shop.create_product(%{
  name: "Bad",
  price: Decimal.new("1.00"),
  sale_price: Decimal.new("2.00"), (1)
  category: :food
})
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidAttribute{
       field: :sale_price,
       message: "must be less than price",
       value: Decimal.new("2.00"),
       ...
     }
   ]
 }}
1 sale_price greater than price triggers the rejection.

compare/2 accepts greater_than, less_than, greater_than_or_equal_to, less_than_or_equal_to, equal_to, not_equal_to. Each can take a literal value or another field name.

one_of for whitelists

Use one_of when an attribute’s valid values are a fixed set and you don’t want to model them as a separate resource:

lib/app/shop/product.ex
    create :create do
      primary? true

      validate compare(:sale_price, less_than: :price),
        where: [present(:sale_price)],
        message: "must be less than price"

      validate one_of(:category, [:food, :toy, :tool]), (1)
        where: [present(:category)]
    end
1 If a :category is supplied, it must be one of those three atoms. Atoms and strings are not interchangeable here: "food" would fail.

Rejected input:

iex(3)> App.Shop.create_product(%{
  name: "Bad",
  price: Decimal.new("1.00"),
  category: :clothing
})
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidAttribute{
       field: :category,
       message: "expected one of %\{values}",
       value: :clothing,
       vars: [values: "food, toy, tool"],
       ...
     }
   ]
 }}

present with at_least

present/2 counts how many of a list of fields have a value. The at_least / at_most / exactly options let you require "at least one contact method" or "exactly one of these" without writing custom code:

lib/app/shop/product.ex
  validations do (1)
    validate present([:price, :sale_price], at_least: 1),
      on: [:create, :update] (2)
  end
1 validations do … end is the resource-wide block. Anything in here runs for every action listed in on:.
2 on: [:create, :update] limits the scope. Without it, the validation runs on :destroy too, which is usually pointless.

Trigger it with a create that has neither price:

iex(4)> App.Shop.create_product(%{name: "Nothing"})
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.Required{field: :price, ...}, (1)
     %Ash.Error.Changes.InvalidChanges{
       fields: [:price, :sale_price],
       message: "at least %\{at_least} of %\{keys} must be present",
       vars: [
         fields: [:price, :sale_price],
         keys: "price,sale_price",
         attributes: [:price, :sale_price],
         at_least: 1
       ],
       ...
     }
   ]
 }}
1 :price is allow_nil? false on the attribute, so you also get the Required error. The two are complementary: allow_nil? fails as soon as the single field is missing, present([…​], at_least:) reports the group-level rule.

Built-ins worth knowing

The full list is at https://hexdocs.pm/ash/Ash.Resource.Validation.Builtins.html. The ones you will reach for repeatedly:

  • compare/2 — cross-field numeric / date comparisons.

  • one_of/2 — whitelist.

  • present/2 — presence with at_least / at_most / exactly.

  • absent/2 — the opposite: "none of these may be set".

  • match/2 — regex.

  • string_length/2 — min/max length.

  • attribute_equals/2 and attribute_does_not_equal/2 — combined with where: these are handy guards ("only allow :update if :locked is false").

  • confirm/2 — two fields must match (classic for password confirmations).

Every built-in accepts where:, message:, and on: the same way.

Custom validations on an action

Reach for a full Ash.Resource.Validation module when the rule needs Elixir logic the built-ins can’t express. The shape is the same as for attribute validations, but you attach it to the action:

lib/app/validations/not_on_weekend.ex
defmodule App.Validations.NotOnWeekend do
  use Ash.Resource.Validation

  @impl true
  def init(opts), do: {:ok, opts}

  @impl true
  def validate(_changeset, _opts, _context) do
    case Date.day_of_week(Date.utc_today()) do
      d when d >= 6 -> {:error, message: "not on weekends"}
      _ -> :ok
    end
  end
end
lib/app/shop/product.ex
    create :create do
      primary? true
      validate {App.Validations.NotOnWeekend, []} (1)
      # …other validations…
    end
1 The [] is the options list that init/1 receives. An empty list is fine when the validation has no configuration to read.

Run it on a Saturday:

iex(5)> App.Shop.create_product(%{name: "Apple", price: Decimal.new("1.00")})
{:error,
 %Ash.Error.Invalid{
   errors: [
     %Ash.Error.Changes.InvalidChanges{
       fields: [],
       message: "not on weekends",
       vars: [],
       ...
     }
   ]
 }}
Ash.Resource.Validation’s `validate/3 callback is the one you implement. The third argument is an Ash.Resource.Validation.Context struct (Ash 3.x signature). Old Ash 2.x tutorials show a validate/2 signature; that will no longer compile.

Action-level vs attribute-level, in one sentence

Put the check on the attribute when it is always true for that field, regardless of action; put it on the action when the rule is "only when someone tries to create/update in this specific way".