Every app starts out with nice, tidy actions. Placing an order creates an order. Then the
loyalty program wants to award points when an order is placed. The warehouse wants a shipment. Someone has
to email a receipt, and finance wants the sale in their reports. Every one of those is a reasonable
request, and every one of them ends up as another line in your :place action. Fast forward a
year, and placing an order knows about half of your application, and you can't change how orders are
placed without reading all of it.
In practice, you don't want to adopt a complex system from the very beginning just to manage one or two side
effects. In Ash, we do our best to provide "progressive disclosure of complexity". What this means is that
simple apps can use simple things, and as your application starts to demand more, you have more rich and complex
tools to reach for to solve those problems. Ash.Signals is one of those features.
It's small, but it has a big impact.
Following the arrows
Here's a shop. Each arrow goes from one part of the app to a part it depends on (i.e. calls into). Scroll along, and watch what happens to it as it grows.
One action
Placing an order creates an order. The shop doesn't depend on anything else, so it's easy to understand, easy to test, and easy to change.
create :place do
accept [:customer_id, :total]
end
Everyone wants to know
Then the requests start coming in. Each team adds a call to the order's action, and every one of those calls is a dependency. To understand what placing an order actually does, you now have to read six domains, and to test it, you have to set up six.
change after_action(fn _changeset, order, _context ->
Loyalty.award_points!(order)
Inventory.reserve_stock!(order)
Fulfillment.open_shipment!(order)
Notifications.send_receipt!(order)
Reporting.record_sale!(order)
{:ok, order}
end)
And they call back
Loyalty figures out a customer's tier from their orders, and fulfillment marks orders as shipped, so they both call the shop too. Now the arrows go both ways, and you can't understand, test or move any of these without dragging the others along.
# in Loyalty
Shop.list_orders!(customer_id)
# in Fulfillment
Shop.mark_shipped!(shipment.order_id)
A contract in the middle
With signals, those five calls turn into one signal, and the shop only depends on its signal module.
emit MyApp.Shop.Signals, :order_placed,
fields: [order_id: :id]
Loyalty and fulfillment still call the shop, and that's fine. Calling another resource directly is the right tool when you need it to do something for you. What was hurting us was the arrows going both ways, and now they only go one way.
The arrows turn around
Each listener depends on the signal module too. So the arrows that used to point out from the shop now point in, at the contract, and the shop doesn't know about any of them.
signals_in do
from MyApp.Shop.Signals do
on :order_placed, :earn_points
end
end
At runtime
When an order is placed, the signal travels along those same arrows, just backwards: out of the shop, through the contract, and off to every listener.
MyApp.Shop.place_order!(customer, total)
%Order{customer_id: ..., total: ..., ...}
What it looks like in code
It's three files. The order emits the signal, the loyalty program listens for it, and both of them depend on the signal module in the middle, rather than on each other.
create :place do
accept [:customer_id, :total]
emit MyApp.Shop.Signals, :order_placed, fields: [order_id: :id]
end
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
end
signals_in do
from MyApp.Shop.Signals do
on :order_placed, :earn_points
end
end
actions do
create :earn_points do
accept [:customer_id, :order_id]
argument :total, :money, allow_nil?: false
change MyApp.Loyalty.Changes.CalculatePoints
end
end
The signal module is the contract. Each signal gets a struct (MyApp.Shop.Signals.OrderPlaced
here), and that's what listeners get handed. emit fills it in from the order:
customer_id and total come from the attributes with the same names, and
fields maps the rest. All of this is checked at compile time. If the signal doesn't exist, or
you set a field it doesn't have, or you forget a required one, you'll hear about it before anything runs.
And an action can only emit the signals it declares, so you can always read an action and see everything
it emits.
On the other side, the listener is just one of the loyalty program's own actions. It's a create action here, so it creates a record for each signal, taking its inputs from the signal's fields with the same names. The shop doesn't mention loyalty anywhere, and loyalty only knows about the shop's signals.
Where it really pays off
Say that a year from now, someone wants to reward whoever referred a customer, every time that customer places an order. Here's what that pull request looks like, both ways:
Reward referrers when an order is placed
3 files changed, in Shop and Referrals
lib/my_app/shop/order.exShop
create :place do
accept [:customer_id, :total]
change after_action(fn _changeset, order, _context ->
Loyalty.award_points!(order)
Inventory.reserve_stock!(order)
Fulfillment.open_shipment!(order)
Notifications.send_receipt!(order)
Reporting.record_sale!(order)
+ Referrals.reward_referrer!(order)
{:ok, order}
end)
end
lib/my_app/referrals.exReferrals
resources do
resource MyApp.Referrals.Reward do
+ define :reward_referrer, args: [:order]
end
end
lib/my_app/referrals/reward.exReferrals
actions do
+ action :reward_referrer do
+ argument :order, :struct, constraints: [instance_of: MyApp.Shop.Order]
+ run MyApp.Referrals.RewardReferrer
+ end
end
1 file changed, in Referrals
lib/my_app/referrals/reward.exReferrals
+signals_in do
+ from MyApp.Shop.Signals do
+ on :order_placed, :reward_referrer
+ end
+end
+
actions do
+ action :reward_referrer do
+ argument :signal, MyApp.Shop.Signals.OrderPlaced, allow_nil?: false
+ run MyApp.Referrals.RewardReferrer
+ end
end
With a hook, adding a reaction to orders is a change to orders. Someone who owns the shop has to review it, and Referrals has to give the shop a function to call, which is one more thing the shop depends on.
With a signal, the whole change lives with the code that wanted it. Nobody working on orders has to review it, or even know it happened. And if the referral program is removed later, deleting it is just as easy: its listener goes with it, and nothing in the shop has to change.
Async signals with Oban
By default, listeners run inline. That means right away, as a nested action inside the order's transaction. If one fails, the order fails, and everything gets rolled back. That's exactly what you want for things that have to happen with the order or not at all, like reserving stock.
It's not what you want for sending email, though. So every listener gets a delivery, which
decides how signals reach it. It lives on the listener because the listener is the one that knows what it
does. The notifications team knows their listener sends email, and the order shouldn't have to know that.
If you use AshOban, the AshOban.Signals delivery runs a listener in an Oban job instead.
Go ahead and play with it. Make Reporting fail, and the order gets rolled back, but the receipt has already gone out, and an email can't be rolled back. Then move Notifications into a job, and try again.
The listeners
The jobs are inserted with Oban's repo, so if the order uses the same repo, they're inserted inside the order's transaction. They only get committed if the order does, they only run once it has, and if they fail, they get retried. Placing an order doesn't wait on them, and if your email provider is down, people can still check out.
signals_in do
from MyApp.Shop.Signals do
on :order_placed, :send_receipt,
delivery: {AshOban.Signals, queue: :mailers}
end
end
The order didn't change, and neither did any of the other listeners. You can start with everything inline, and move a listener into a job when it needs to be.
Listeners are just actions
A listener is one of the listening resource's own actions, so everything you already know about actions applies, like policies, validations, changes and bulk actions. What happens when a signal shows up depends on what kind of action it is:
signals_in do
from MyApp.Shop.Signals do
# A generic action receives the signal struct, as its `signal` argument
on :order_placed, :send_receipt
# A create action creates a record from each signal
on :order_placed, :record_sale, action: :create
# An update or destroy action runs on the records it selects for each signal
on :order_placed, :mark_ordered, get_by: [id: :customer_id]
# With `batch?: true`, a generic action gets every signal emitted together
on :order_placed, :notify_warehouse, batch?: true
end
end
Listeners run as the actor that emitted the signal, with the same tenant, so their policies get checked against whoever placed the order. And bulk actions emit their signals together, once per batch, so create, update and destroy listeners get to handle them in bulk too.
So when should I use one?
Signals don't replace the other ways that parts of your app talk to each other. Here's how I think about it:
- Call the other resource directly when you need something back from it. Signals are fire and forget. The emitter gets nothing back from its listeners, which is exactly why it doesn't depend on them.
- Use a notifier for "at most once" side effects after the transaction commits, like publishing to PubSub. Nothing a notifier does can roll the action back.
- Emit a signal when other parts of your app need to react to an action, and the action shouldn't have to know about them.
What about tests?
Each side gets to test its own side. The order's tests can check what it promises (that it emits
:order_placed) without caring what anyone else does with it:
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
end
Listeners still run when you place an order in a test, since signals change how your app is organized, but not what it does. And since a listener is just an action, you test it like any other action.
Try it out
Signals are in Ash 3.36, and the AshOban.Signals delivery is in AshOban 0.10. Check out the
signals guide for the rest (emitting from your own
changes, generic actions, telemetry and more), and AshOban's guide for running listeners in jobs.
This is a brand new feature, so I'd love to hear how it goes for you. Come say hi in the Discord!