Signals let one resource react to what happens in another, without either depending on the
other. A resource emits a typed signal, like :order_placed, and any resource can listen to it
with one of its own actions.
Signals are useful when one part of your application needs to react to another part, but the
part that acts shouldn't have to know about the reactions. For example, when an order is placed,
a loyalty program might award points, a ticketing system might open a task, and a reporting
system might record the sale. With signals, the order resource emits :order_placed, and each of
those resources listens to it. The order resource doesn't change when a new reaction is added.
Signals vs. calling another resource
If a resource needs something from another resource to finish its action, call that resource directly, for example with its code interface. Signals are fire and forget: the emitter gets nothing back from its listeners, so it doesn't depend on what they do, or on whether anything listens at all. Needing a value back from a listener is a sign that a signal is the wrong tool.
Signals vs. notifiers
Notifiers run after the transaction commits, and are for "at most once" side effects like publishing to PubSub. With the default delivery, listeners of a signal run as nested actions, during the phase the signal declares, usually inside the emitter's transaction. If a listener fails, the emitting action fails, and its changes are rolled back. A listener can also use another delivery, for example to run in a job. See Delivery.
Declaring signals
Signals are declared in a signal module, which is the contract between the resources that emit them and the resources that listen to them. Both depend on it, and not on each other.
defmodule MyApp.Shop.Signals do
use Ash.Signals
signals do
signal :order_placed do
argument :order_id, :uuid, allow_nil?: false
argument :customer_id, :uuid, allow_nil?: false
argument :total, :money
end
end
endEach signal defines a struct for its arguments, MyApp.Shop.Signals.OrderPlaced, which is what
listeners receive. Every signal struct also has a signal_id, a UUIDv7 generated when the signal
is emitted, so signal_id can't be used as an argument name.
A signal's phase is when it is emitted: :before_action, :after_action (the default), or
:after_transaction. See the Signals DSL documentation
for every option.
signal :order_shipped do
phase :after_transaction
argument :order_id, :uuid, allow_nil?: false
endEmitting signals
Actions emit signals with emit, giving the signal module and the signal:
actions do
create :place do
accept [:customer_id, :total]
emit MyApp.Shop.Signals, :order_placed, fields: [order_id: :id]
end
endAn action can only emit the signals it declares with emit, so you can always see what an action
emits by reading it.
A signal emitted in a phase other than :after_action must be given its phase, so that the action
shows when it emits. It must be the phase the signal declares, which is checked at compile time:
emit MyApp.Shop.Signals, :order_shipped, phase: :after_transactionSignal fields are taken from the record's attributes with the same name, so customer_id and
total above come from the order. Use these options for the others:
fields- signal fields to take from other attributes of the record, likeorder_id: :id.values- signal fields to set to a value, which may be a template likeactor(:id)orarg(:reason). In update and destroy actions,previous(:field)is the value of a field before the action.where- validations that must pass for the signal to be emitted, likechanging(:status).
update :change_status do
accept [:status]
require_atomic? false
emit MyApp.Shop.Signals, :order_status_changed,
fields: [order_id: :id],
values: [previous_status: previous(:status), changed_by: actor(:id)],
where: [changing(:status)]
endemit is checked at compile time: the signal must exist, every field it sets must be a field of
the signal, every field it reads must be an attribute of the resource, and every required field of
the signal must be set. It runs in its place among the action's changes, so changes before it have
already run when it is emitted.
Generic actions
In generic actions, signal fields are taken from the action's arguments with the same name, and
fields takes them from the action's result:
action :send_invoice do
argument :invoice_id, :uuid, allow_nil?: false
emit MyApp.Billing.Signals, :invoice_sent
run MyApp.Billing.SendInvoice
endBulk actions
Bulk actions emit their signals together, once per batch, so listeners can handle them in bulk
too. Signals emitted :after_transaction are the exception, and are emitted once per record.
Atomic actions
Signals emitted :after_action work with atomic actions. Signals emitted :before_action or
:after_transaction, and previous/1, need things atomic actions don't have, so an action using
them can't be atomic. Set require_atomic? false on update and destroy actions that use them.
Emitting from your own code
When building the signal needs more than fields and values can express, like a calculation,
related data, or anything else that needs loading, emit it from your own code. Declare it on the
action with dynamic?: true, and call Ash.Signals.emit/5 during the signal's phase.
Ash.Signals.emit_many/5 emits a signal for each of a list of payloads, for example from a
change's after_batch/3 callback.
create :place do
accept [:customer_id, :total]
emit MyApp.Shop.Signals, :order_placed, dynamic?: true
change MyApp.Shop.Changes.EmitOrderPlaced
enddefmodule MyApp.Shop.Changes.EmitOrderPlaced do
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, order ->
order = Ash.load!(order, :customer_email)
with :ok <-
Ash.Signals.emit(changeset, MyApp.Shop.Signals, :order_placed, %{
order_id: order.id,
customer_email: order.customer_email
}) do
{:ok, order}
end
end)
end
endEmitting a signal the action doesn't declare raises an error.
Descriptions can help document dynamic signals.
update :record_payment do
argument :amount, :money, allow_nil?: false
emit MyApp.Shop.Signals, :order_paid,
dynamic?: true,
description: "When the payment covers the rest of the order's total."
change MyApp.Shop.Changes.RecordPayment
endListeners run as the emitting action's actor, with its tenant and authorize?. Pass :actor,
:tenant, :authorize?, :context, or a :scope to emit/5 to change them. Include a
signal_id in the payload to set it yourself, for example to emit the same signal again.
Listening to signals
Resources listen to signals in their signals_in section. Each listener has a name, and runs the
action with that name, unless it sets action:
signals_in do
from MyApp.Shop.Signals do
on :order_placed, :award_points
on :order_placed, :notify_sales, action: :notify
end
endListener names are unique within a resource, so the same action can listen to a signal more than
once, for example with different inputs:
from MyApp.Shop.Signals do
on :order_placed, :record_buyer_entry,
action: :record_entry,
inputs: [account_id: :customer_id]
on :order_placed, :record_seller_entry,
action: :record_entry,
inputs: [account_id: :seller_id]
endWhat happens depends on the type of the action.
Generic actions
A generic action receives the signal struct as its signal argument:
signals_in do
from MyApp.Shop.Signals do
on :order_placed, :notify_warehouse
end
end
actions do
action :notify_warehouse do
argument :signal, MyApp.Shop.Signals.OrderPlaced, allow_nil?: false
run MyApp.Warehouse.Notify
end
endWith batch?: true, it is called once with every signal emitted together, as its signals
argument:
on :order_placed, :notify_warehouse, batch?: true
action :notify_warehouse do
argument :signals, {:array, MyApp.Shop.Signals.OrderPlaced}, allow_nil?: false
run MyApp.Warehouse.NotifyAll
endCreate actions
A create action creates a record for each signal, with its inputs taken from the signal fields of
the same name, except signal_id. Use inputs to map them, as input: :signal_field:
from MyApp.Shop.Signals do
# sets `order_id` and `customer_id` from the signal
on :order_placed, :record_order, action: :create
# sets `reference` from the signal's `order_id`
on :order_placed, :open_task, inputs: [reference: :order_id]
# records which signal created the record
on :order_placed, :log, inputs: [:order_id, source_signal_id: :signal_id]
endUpdate and destroy actions
An update or destroy action runs on the records selected for each signal by a read action, the
primary read action by default. Use args to set the read action's arguments from the signal,
and get_by to select records whose fields equal fields of the signal. They can be used
together:
from MyApp.Shop.Signals do
on :order_placed, :mark_ordered, read_action: :active, get_by: [id: :customer_id]
on :order_placed, :clear, read_action: :for_customer, args: [:customer_id]
endIf the read action selects no records for a signal, the signal is handled, and nothing happens. Filter in the read action to only react to some signals.
Bulk
Create, update and destroy listeners always handle the signals emitted together as bulk actions.
Update and destroy listeners run one bulk action for each distinct set of args and inputs.
Signal path and policies
Listeners have the signals that led to them in the signal_path key of their context, starting
with the one they received, followed by the signals that led to it. Each entry has the signal's
signal_module and signal name, and the resource and action that emitted it. A listener
whose action emits a signal of its own adds it to the front of the path for that signal's
listeners:
# the context of a listener of `:points_earned`, which `:earn_points` emits when it handles
# `:order_placed`
%{
signal_path: [
%{
signal_module: MyApp.Loyalty.Signals,
signal: :points_earned,
resource: MyApp.Loyalty.PointsEntry,
action: :earn_points
},
%{
signal_module: MyApp.Shop.Signals,
signal: :order_placed,
resource: MyApp.Shop.Order,
action: :place
}
]
}The signals themselves aren't in the path, because a listener can receive many of them at once.
Listeners run as the actor that emitted the signal, but that actor often isn't allowed to run the
listener's action directly: a customer can place an order, but shouldn't be able to award
themselves points. The signal_in/2 check is true when the action is run by a listener of the
given signal, so the listener's policies can allow it:
policies do
policy action(:earn_points) do
authorize_if signal_in(MyApp.Shop.Signals, :order_placed)
end
endsignal_in/2 only matches the signal the listener received, which is the first one in its path.
For update and destroy listeners, it is also true for the read action that selects the records
they run on.
Delivery
A listener's delivery decides how signals reach it. Because it's part of the listener, each
listener decides for itself, and the emitter doesn't need to know: a listener that does slow work,
or calls an external service, can run in a job, while another listener of the same signal runs in
the emitter's transaction.
The default, Ash.Signals.Delivery.Inline, runs listeners immediately, as nested actions, with
each listener handling every signal before the next listener runs. During :before_action and
:after_action, they run inside the emitter's transaction: if a listener fails, the emitting
action fails and is rolled back.
Signals emitted :after_transaction run after the emitter's transaction has committed. If a
listener fails, the emitting action returns its error, but its changes are not rolled back. If
the emitting action is itself running inside another transaction, its after_transaction hooks
run while that transaction is still open, and so do the listeners.
Other deliveries are provided by extensions, and implement Ash.Signals.Delivery. For example, a
delivery could enqueue a job for the listener, so that it runs after the emitter's transaction
commits, and is retried when it fails:
signals_in do
from MyApp.Shop.Signals do
# runs in the emitter's transaction
on :order_placed, :record_sale
# runs in a job, after the emitter's transaction commits
on :order_placed, :send_receipt, delivery: {MyApp.JobDelivery, queue: :mailers}
end
endThe emitter doesn't change: it emits the same signal whatever its listeners' deliveries are. If a delivery fails, for example because it can't enqueue a job, the emit fails.
A delivery that runs listeners later must store the signal_path of the
Ash.Signals.Delivery.Context it is given, and pass it back in the signal_path key of the
listener's context.
Setup
Add the :ash_signals compiler to your project:
def project do
[
compilers: [:ash_signals] ++ Mix.compilers(),
...
]
endLike protocol consolidation, it finds the listeners of each signal once per build, and compiles them into a module, so that emitting a signal doesn't have to search for them. Without it, listeners are searched for every time a signal is emitted, which is much slower.
If you use the Phoenix code reloader, add it to your endpoint's reloadable compilers too, so that listeners added during development are picked up:
config :my_app, MyAppWeb.Endpoint,
reloadable_compilers: [:ash_signals, :phoenix_live_view, :gettext, :elixir, :app]Listeners defined after compilation, like resources defined inside tests, aren't part of the consolidated modules. To use them, disable consolidation in that environment:
def project do
[
ash: [consolidate_signals: Mix.env() != :test],
...
]
endMapping signals
mix ash.signals prints each signal, the actions that emit it, and the listeners that react to
it, with their descriptions. Pass signal modules to only print their signals, and --format mermaid for a Mermaid
flowchart instead:
mix ash.signals
mix ash.signals MyApp.Shop.Signals --format mermaid --output signals.mmd
Listeners are found wherever they are defined. Emitters are found in the resources of the domains
in your :ash_domains config, and in resources that listen to signals. See Mix.Tasks.Ash.Signals
for every option.
Testing
Ash.Signals.Test has helpers for asserting on emitted signals. assert_emits_signal/4 and
refute_emits_signal/4 check the signals emitted by a function:
import Ash.Signals.Test
test "placing an order emits order_placed" do
order =
assert_emits_signal MyApp.Shop.Signals, :order_placed, %{order_id: order_id}, fn ->
MyApp.Shop.place_order!(customer, 100)
end
assert order.id == order_id
endassert_signal_emitted/4 and refute_signal_emitted/4 check the signals emitted so far, after
calling capture_signals/1, for example with setup :capture_signals.
To test listeners without the action that emits the signal, react_to_signal/4 delivers a signal
to them as if it had been emitted:
test "placing an order awards points" do
:ok =
react_to_signal(
MyApp.Shop.Signals,
:order_placed,
%{order_id: order.id, customer_id: customer.id},
only: MyApp.Loyalty.Account
)
assert MyApp.Loyalty.points!(customer) == 100
endEach listener receives the signal through its own delivery, so a listener that runs in a job only
enqueues it. Pass inline?: true to run every listener immediately. only limits the listeners to
some resources, or to {resource, listener} pairs, and react_to_signal/4 takes the same
:actor, :tenant and :authorize? options as Ash.Signals.emit/5.
Telemetry
Each emitted signal fires an [:ash, :signal, :emitted] telemetry event, before it is
delivered. Its metadata has the :signal_module, the signal's :name, the :signal struct, the
emitting :resource and :action, and the :actor and :tenant the listeners run with.