Ash integration
Reference: usage-rules/ash.md.
Resource template
defmodule MyApp.User do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [GuardedStruct.AshResource]
guardedstruct do
auto_wire true # injects GuardedStruct.AshResource.Change automatically
field :email, :string, derives: "sanitize(trim, downcase) validate(email_r)"
end
attributes do
uuid_primary_key :id
attribute :email, :string, allow_nil?: false, public?: true
end
actions do
defaults [:read, :destroy, :create]
update :update, accept: [:email]
end
end
auto_wire: false (default) requires a manual changes do change GuardedStruct.AshResource.Change end block.
Atomic mode — three behaviours
Change.atomic/3 inspects every key in changeset.attributes and
changeset.atomics. For each owned field (in __guarded_field_name_set__/0):
| Input | Result |
|---|---|
Plain literal in attributes or atomics |
Sanitize via the pipeline, return {:atomic, sanitized_map}. UPDATE stays atomic. |
Ash.Expr in atomics (e.g. expr(counter + 1)) |
Return {:not_atomic, reason}. Ash falls back to imperative change/3 if the action has require_atomic? false; otherwise Ash raises Ash.Error.Framework.MustBeAtomic. |
Non-owned keys are passed through untouched — expr(now()) on a plain Ash
attribute (not in guardedstruct) stays atomic.
Three options when an atomic expression is needed
- Accept the fallback — declare a companion action with
require_atomic? false.atomic/3bails, Ash runschange/3imperatively, update still succeeds. - Pass a plain value — compute in Elixir, pass via
attributes. Stays atomic, pipeline validates. - Move the field outside
guardedstruct—atomic_update(_, expr(... + 1))works without going through our pipeline.
Bulk
Ash.bulk_create/3→ usesbatch_change/3(per-row pipeline).Ash.bulk_update/3withstrategy: :atomic(default) → usesatomic/3.Ash.bulk_update/3withstrategy: :stream→ useschange/3.
All three produce identical sanitized results.
Auto-map cascade
__guarded_change__/1 returns plain maps at every depth for nested
sub_fields — never structs. This matches Ash's :map attribute type so output
drops directly into changeset.attributes without conversion. Set via a
process-local flag at the top of validate/3; concurrency-safe.
Don'ts
- Don't add
require_atomic? falseto every update action — most updates are atomic-safe by default with this extension. Only add it on actions that need to acceptAsh.Exprvalues for owned fields. - Don't write
defstructorbuilder/1in the resource module — Ash provides both. The extension only adds__guarded_change__/1and metadata. - Don't rely on
function_exported?(MyResource, :__guarded_field_name_set__, 0)— it's always defined on resources using this extension.
Companion modules
GuardedStruct.AshResource.Info— Ash-flavoured introspection (fields/1,field/2,validate/2, etc.).GuardedStruct.AshResource.Change— the bridge change module.