AshDispatch

Hex.pm Hexdocs License: MIT

Pre-1.0. AshDispatch runs notifications in production, but its public API is still settling on the way to 1.0 and may change between minor versions β€” every change, and what to do about it, is in the CHANGELOG. Pin the minor version you use (~> 0.x.y, not ~> 0.x) and please open issues.


AshDispatch is an event-driven notification and messaging system for Ash Framework. It provides a declarative DSL for defining events in your resources and automatically dispatching them across multiple transports (email, in-app notifications, broadcast/PubSub, Discord, Slack, webhooks, etc.).

Why AshDispatch?

Declarative Event Definitions

Define events directly in your resources using familiar Ash DSL patterns:

defmodule MyApp.Orders.ProductOrder do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  actions do
    create :create_from_cart do
      accept [:user_id]
      # Your action logic...
    end
  end

  dispatch do
    event :created,
      trigger_on: :create_from_cart,
      priority: :standard,
      channels: [
        [transport: :in_app, audience: :user],
        [transport: :email, audience: :user, delay: 300]
      ],
      content: [
        subject: "Order #{{order_number}} created",
        notification_title: "Your order was created",
        notification_message: "Order #{{order_number}} is being processed"
      ],
      metadata: [
        notification_type: :success
      ]
  end
end

Key Features

Tutorials

Topics

Reference

Architecture Overview

graph TB
    A[Resource Action] -->|triggers| B[Event]
    B -->|creates| C[DeliveryReceipt]
    C -->|dispatches to| D{Transport}
    D -->|in_app| E[Notification]
    D -->|email| F[Oban Job]
    D -->|discord| G[Webhook]
    F -->|sends| H[Email Service]
    E -->|updates| I[User UI]
    G -->|posts| J[Discord Channel]

Installation

mix igniter.install ash_dispatch

This creates all required resources, domains, and configuration automatically. Then run migrations:

mix ash.codegen add_ash_dispatch
mix ash.migrate

Manual Installation

def deps do
  [
    {:ash_dispatch, "~> 0.9.1"}
  ]
end

See Getting Started for complete manual setup instructions.

Production Configuration (Required for Releases)

Important: For production releases, you must enable template compilation. Without this, email templates won't load in production because lib/ source files aren't included in releases.

# config/prod.exs - REQUIRED for releases
config :ash_dispatch,
  compile_templates: true

This copies templates to priv/ash_dispatch/templates/ during compilation, ensuring they're available at runtime. See Code Generation for details.

Also required: the retry cron. Failed deliveries are only ever retried by AshDispatch.Workers.RetryFailedDeliveries β€” Oban's own job retries no-op once a receipt is :failed. Schedule it (and give it its :default queue):

config :my_app, Oban,
  queues: [emails: 10, default: 5],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [{"*/15 * * * *", AshDispatch.Workers.RetryFailedDeliveries}]}
  ]

Since 0.9.3 mix ash_dispatch.install writes this for you β€” queues and both scheduled workers β€” adding only what is missing and never changing a value you had set. To run the retries in the queue your mail runs in, so pausing that queue pauses them too: config :ash_dispatch, retry_queue: :emails.

Optional hardening, both new in 0.5.4: AshDispatch.WebhookHandlers.Resend.verify/3 verifies the Svix signature on Resend webhooks before you process them, and AshDispatch.Workers.ScrubSensitiveContent (cron) blanks stored bodies of events declaring metadata: [sensitive_content: true] β€” for OTP codes and reset links that should not live in the database forever. See Delivery Receipts.

Whether deliveries really go out

One setting answers that for every transport β€” email, SMS, push, in-app and webhooks alike (new in 0.9.3; see AshDispatch.DeliveryMode):

# config/dev.exs β€” render and record everything, send nothing
config :ash_dispatch, delivery_mode: :dry_run

# config/staging.exs β€” the team can be mailed, nobody else
config :ash_dispatch,
  delivery_mode: [
    default: :blocked,
    email: {:allow_list, ["ops@example.com", "@example.test"]},
    in_app: :live
  ]

A held-back delivery is :skipped, never :failed and never reported as sent, and the reason is one of "delivery_blocked", "delivery_dry_run" or "delivery_not_allowed" so it can be counted.

config :ash_dispatch, skip_email_delivery: true still works and still writes the reason it always wrote; it now means delivery_mode: [email: :blocked].

Whether a recipient may be reached at all

A preference is what a person wants, and a mandatory event (metadata: [user_configurable: false]) overrides it on purpose. A closed account is a different question, and until 0.9.3 the library had no way to ask it. Now it does (AshDispatch.EligibleRecipient):

config :ash_dispatch, eligible_recipient: MyApp.Notifications.Eligibility

defmodule MyApp.Notifications.Eligibility do
  @behaviour AshDispatch.EligibleRecipient

  @impl true
  def eligible?(_recipient, opts) do
    case MyApp.Accounts.get_user(opts[:user_id]) do
      {:ok, %{deactivated_at: nil, blocked: false}} -> true
      {:ok, _user} -> {false, "account closed"}
      _ -> true
    end
  end
end

Asked for every event, mandatory ones included; asked when the recipients are resolved and again at the moment of sending, because an account can be closed while a delayed delivery waits in the queue. An ineligible recipient gets a :skipped receipt saying "recipient_ineligible: account closed" β€” never a silent drop. Without the setting, nothing is asked and nothing changes.

One schema per tenant

A host with Ash multitenancy strategy :context can put the library's own rows in the tenant's schema (new in 0.9.4; see the Tenancy guide):

use AshDispatch.Resources.DeliveryReceipt.Base,
  repo: MyApp.Repo,
  domain: MyApp.Deliveries,
  notification_resource: MyApp.Notifications.Notification,
  multitenancy: :context

config :ash_dispatch, tenants: MyApp.Tenants

The tenant is then carried through dispatch β†’ the Oban job's args β†’ the resolver β†’ the send β†’ the receipt β†’ the provider webhook β†’ the cron (one pass per tenant) β†’ previews. A dispatch without a tenant is refused rather than written to public, a tenant that arrives from outside is checked against your AshDispatch.Tenants before it reaches a query, and job_args: :minimal keeps rendered bodies out of oban_jobs, which is one table for every tenant.

For a host that keeps receipts in one schema but wants to know which tenant each was for, tenant_column: true adds a nullable tenant column instead.

A host that does nothing sees nothing of this: a resource without multitenancy: is never handed a tenant, which the suite asserts rather than claims.

A gate on the event

An event can declare a condition that decides whether it goes out at all (AshDispatch.Gate):

dispatch do
  event :reminder,
    trigger_on: :manual,
    gate: {MyApp.Bookings, :still_booked?},
    channels: [
      [transport: :email, audience: :user, time: {:in, 86_400}],
      [transport: :in_app, audience: :user]
    ]
end

Asked at dispatch, once per channel, and again at the moment of sending β€” which is what makes it work for a reminder queued for tomorrow morning and a booking cancelled tonight. When the gate is closed, a :skipped receipt with the reason exists for every channel that would have been used, so "we never sent it because the booking had been cancelled" is a question the receipts can answer. Only true and :ok let the event out; a gate that raises does not send and still leaves the record. An event module can implement gate/1 instead of using the DSL option.

Quick Example

# 1. Add extension to resource
defmodule MyApp.Tickets.Ticket do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  # 2. Define events
  dispatch do
    # Inline-complete event (no module needed!)
    # Works out of the box with just DSL configuration
    event :created,
      trigger_on: :create,
      channels: [
        [transport: :in_app, audience: :user]
      ],
      content: [
        notification_title: "Ticket Created",
        notification_message: "{{user_name}} created ticket #{{id}}"
      ],
      metadata: [
        notification_type: :success
      ]

    # Event with email requires templates
    # Run `mix ash.codegen` to generate template stubs
    event :assigned,
      trigger_on: :assign,
      channels: [
        [transport: :in_app, audience: :user],
        [transport: :email, audience: :user]
      ],
      content: [
        subject: "Ticket #{{id}} assigned to you",
        notification_title: "Ticket Assigned"
      ]

    # Complex event with custom module (your override)
    event :escalated,
      trigger_on: :escalate,
      module: MyApp.Events.Tickets.Escalated
  end
end

# 3. That's it! Events dispatch automatically when actions run
Ticket
|> Ash.Changeset.for_create(:create, %{title: "Bug report"})
|> Ash.create!()
# -> Automatically dispatches :created event
# -> Creates in-app notification for user (no templates needed!)

Hybrid Architecture

AshDispatch uses a hybrid approach where DSL configuration takes precedence, and generated modules provide fallbacks:

  1. Define events in DSL - Configure channels, content, and metadata directly in your resource

  2. Run mix ash.codegen - Generates event modules with templates for each event (unless you provide module: override)

  3. Hybrid dispatch - At runtime, DSL content is used first; module callbacks fill in gaps (templates, recipients, etc.)

Override with custom modules: Set module: to provide your own implementation for complex events (custom recipients, conditional sending, etc.)

Real-Time Counter Broadcasting

AshDispatch also provides automatic real-time counter updates with zero boilerplate:

# 1. Define counters in your resource
defmodule MyApp.Orders.ProductOrder do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  counters do
    # User sees their own pending orders
    counter :pending_orders,
      trigger_on: [:create, :complete, :cancel],
      counter_name: :pending_orders,
      query_filter: [status: :pending],
      audience: :user,
      invalidates: ["orders"]

    # Admins see ALL pending orders
    counter :admin_pending_orders,
      trigger_on: [:create, :complete, :cancel],
      counter_name: :admin_pending_orders,
      query_filter: [status: :pending],
      audience: :admin,
      invalidates: ["orders", "analytics"]
  end
end

# 2. Configure broadcasting (one line!)
# config/config.exs
config :ash_dispatch,
  counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}

# 3. Use helper in Phoenix Channel (one line!)
defmodule MyAppWeb.UserChannel do
  alias AshDispatch.Helpers.ChannelState

  def handle_info(:after_join, socket) do
    # Loads ALL counters automatically - no manual queries!
    initial_state = ChannelState.build(socket.assigns.user_id)
    # => %{"counters" => %{"pending_orders" => 5}, "notifications" => [...]}

    push(socket, "initial_state", initial_state)
    {:noreply, socket}
  end
end

# 4. That's it! Counters update in real-time automatically
Order.create!(%{status: :pending})
# -> Automatically broadcasts counter update to Phoenix Channel
# -> Frontend receives "counter_updated" event with new value

Zero configuration, automatic discovery, real-time updates!

See Counter Broadcasting and Phoenix Integration for complete guides.

Internal Architecture

AshDispatch uses centralized resolver modules to ensure consistent behavior across all dispatch pathways:

ChannelResolver

AshDispatch.ChannelResolver handles all channel resolution with consistent priority logic:

# Used internally by Dispatcher, ManualTrigger, preview generators, etc.
channels = AshDispatch.ChannelResolver.resolve(event_id, event_module, context,
  dsl_channels: event_config.channels
)

EventResolver

AshDispatch.EventResolver centralizes event module discovery and safe callback execution:

# Find an event module
{:ok, module} = AshDispatch.EventResolver.find_module("orders.created")

# Safe callback execution with defaults
subject = AshDispatch.EventResolver.subject(module, context, channel, default: "No subject")

Naming

AshDispatch.Naming handles consistent filename and label generation for multi-audience templates:

# Generate audience-specific filenames
AshDispatch.Naming.filename("email", :admin, "summary", "html")
# => "email.admin.summary.html"

# Generate display labels
AshDispatch.Naming.label(:email, :admin, "summary")
# => "email (admin, summary)"

Design Principles

1. Resource-Centric

Events are defined in resources, just like actions, attributes, and relationships.

2. Progressive Complexity

Start with simple inline events. Upgrade to callback modules when you need custom logic.

3. Receipt-First Pattern

All deliveries create a receipt record before dispatch, enabling full audit trails and reliable retries.

4. Fail-Safe Defaults

User preferences, rate limiting, and delivery policies protect users from notification fatigue.

5. Framework Integration

Deep integration with Ash actions, Oban jobs, and the Ash ecosystem.

6. Centralized Logic

Channel resolution, event discovery, and naming all use centralized modules to ensure consistency and maintainability.

Project Status

Pre-1.0. AshDispatch is published on Hex and runs in production:

Contributing

AshDispatch is open source and contributions are welcome. Issues and PRs go to github.com/Vulcora/ash_dispatch. Since the API is still evolving, please open an issue to discuss larger changes before submitting a PR.

License

MIT License - see LICENSE file for details.

Acknowledgments

Built on the excellent Ash Framework by Zach Daniel and the Ash community.

Inspired by patterns from AshStateMachine, AshAuthentication, and years of building notification systems.