Introducing

Signals

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.

lib/my_app/shop/order.ex

create :place do
  accept [:customer_id, :total]

  emit MyApp.Shop.Signals, :order_placed, fields: [order_id: :id]
end
lib/my_app/shop/signals.ex

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
lib/my_app/loyalty/points_entry.ex

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

lib/my_app/notifications/receipt.ex

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:

Four kinds of listener

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:

test/my_app/shop/order_test.exs

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!