AshDispatch
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
- π― Automatic Dispatch - Events are automatically triggered by resource actions
- π¬ Multi-Transport - Email, in-app, Discord, Slack, SMS, webhooks out of the box
- π¨ Priority Levels -
:urgent,:standard,:informationalβ consumers gate delivery timing by priority - β° Delayed Delivery - Schedule notifications for later delivery
- π€ User Preferences - Respect user notification preferences automatically
- π Delivery Tracking - Full audit trail with delivery receipts
- π Automatic Retries - Failed deliveries retry with exponential backoff
- π¨ Template Interpolation -
{{variable}}syntax for dynamic content - π Localization (i18n) - Multi-language templates with dynamic locale resolution from record fields
- π Real-Time Counters - Declarative counter DSL with automatic Phoenix Channel broadcasting
- β‘ Zero-Config Helpers -
ChannelState,CounterLoader,NotificationLoaderfor Phoenix integration - βοΈ Generated TypeScript SDK - Ready-to-use React hooks, NotificationProvider, and NotificationBell components
- π‘ Lightweight Broadcast -
:broadcasttransport for real-time PubSub events without receipt overhead - π Extensible - Add custom transports and event modules
- π Previews on the send path -
AshDispatch.Previewrenders any event exactly as it would be sent;AshDispatch.Test.assert_all_render/1makes "every mail renders" a one-line test - π Smart Deduplication - Optional
deduplicate_groupto prevent duplicate notifications when audiences overlap
Tutorials
- Getting Started with AshDispatch - Basic event setup with inline DSL
- Manual Dispatch and Event Modules - Standalone events, manual triggers, and the two-path pattern
Topics
- What is AshDispatch?
- Localization (i18n) - Multi-language templates with dynamic locale resolution
- Phoenix Channel Integration - Zero-config helpers for real-time updates
- Counter Broadcasting - Declarative counter DSL with auto-discovery
- TypeScript SDK - Generated React hooks, components, and Zustand store
- Priority Levels - Urgent, standard, informational β context-aware delivery gating
- User Preferences
- Recipient Resolution
- Configuration
- App Integration
- Code Generation
- Oban Configuration
- Previews and Testing - Render any event as sent, test that every mail renders, strict templates
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
Quick Start (Recommended)
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
]
:liveβ send. The default; a host that sets nothing is unaffected.:blockedβ never send. The receipt is:skippedand says it was held back.:dry_runβ render, create the receipt, enqueue the job, run the worker, send nothing. The receipt holds the letter that would have gone out.{:allow_list, [...]}β send only to these recipients; everyone else is treated as blocked. Entries match an exact address, a domain ("@example.test"), a phone prefix ("+4670"), an explicit prefix ("team-*") or"*".
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.
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:
-
Define events in DSL - Configure channels, content, and metadata directly in your resource
-
Run
mix ash.codegen- Generates event modules with templates for each event (unless you providemodule:override) -
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:
- DSL channels take precedence over module callbacks
- Converts various channel formats (DSL structs, maps, keyword lists) to
%Channel{}structs - Supports future
strategy: :mergeoption to combine both sources
# 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 event modules by ID
- Call callbacks with consistent error handling
- Build sample contexts for previews
# 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:
- β
Working today: the resource DSL, runtime dispatcher, delivery receipts, in-app / email / SMS / push / webhook / Discord / Slack / broadcast /
:oban/:custom_topictransports, Oban-backed async delivery and retries, real-time counters, localization, and the generated TypeScript SDK. - π§ͺ Still settling: the public API may change between minor versions before
1.0; the CHANGELOG says what changed and what to do. Some transports and helpers are less battle-tested than others. - πΊοΈ Toward 1.0: API stabilization, broader transport coverage, and more docs/examples.
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.