Getting Started with AshDispatch
View SourceThis tutorial will walk you through adding event-driven notifications to an Ash resource using inline events (defined directly in the dispatch DSL).
Looking for standalone event modules, manual triggers, or preview functionality? See Manual Dispatch and Event Modules for the complete guide on event modules, the two-path pattern, and admin-triggered events.
Prerequisites
This guide assumes you're familiar with:
- Ash Framework basics
- Ash resources and actions
- Basic Elixir
Quick Start with Igniter (Recommended)
The fastest way to get started is using the Igniter installer:
mix igniter.install ash_dispatch
This will automatically:
- Create
NotificationandDeliveryReceiptresources - Create
NotificationsandDeliveriesdomains - Add domains to your
:ash_domainsconfiguration - Create a
RecipientResolvermodule for declarative audience resolution - Set up email layout templates
- Configure Phoenix channels for real-time updates (if Phoenix detected)
- Configure Oban for async delivery
- Generate TypeScript SDK (if any domain uses
AshTypescript.Rpcextension)
After installation, run migrations and you're ready to add events:
mix ash.codegen add_ash_dispatch
mix ash.migrate
Then skip to Add Your First Event.
Installer Options
# Skip Phoenix channel setup
mix igniter.install ash_dispatch --no-phoenix
# Skip email backend configuration
mix igniter.install ash_dispatch --no-email
# Skip TypeScript SDK generation (runs automatically if any domain uses AshTypescript.Rpc)
mix igniter.install ash_dispatch --no-typescript
Manual Installation
If you prefer manual setup or need more control, follow the steps below.
1. Add dependency
# mix.exs
def deps do
[
{:ash_dispatch, "~> 0.1.0"},
{:oban, "~> 2.17"}, # Required for async delivery
{:swoosh, "~> 1.16"} # Required for email transport
]
endNote: For complete configuration options including standalone event modules, user resources, and custom notification resources, see Configuration Guide.
2. Configure Oban
AshDispatch uses Oban for asynchronous email delivery. See Oban Configuration for complete setup.
# config/config.exs
config :my_app, Oban,
engine: Oban.Engines.Basic,
notifier: Oban.Notifiers.Postgres,
repo: MyApp.Repo,
queues: [
emails: 10 # AshDispatch uses :emails queue
]Then add Oban to your supervision tree:
# lib/my_app/application.ex
def start(_type, _args) do
children = [
MyApp.Repo,
{Oban, Application.fetch_env!(:my_app, Oban)},
# ... other children
]
Supervisor.start_link(children, opts: [strategy: :one_for_one])
end3. Configure email transport (optional)
AshDispatch uses Swoosh for sending emails. Configure your Swoosh mailer:
# config/config.exs
config :my_app, MyApp.Mailer,
adapter: Swoosh.Adapters.Resend,
api_key: System.get_env("RESEND_API_KEY")Then configure AshDispatch to use your Swoosh mailer:
# config/config.exs
config :ash_dispatch,
otp_app: :my_app, # Required for template layouts
email_backend: AshDispatch.EmailBackend.Swoosh,
swoosh_mailer: MyApp.MailerAvailable Swoosh adapters:
- Resend -
Resend.Swoosh.Adapter(recommended) - SendGrid -
Swoosh.Adapters.Sendgrid - Postmark -
Swoosh.Adapters.Postmark - Mailgun -
Swoosh.Adapters.Mailgun - SMTP -
Swoosh.Adapters.SMTP - Local (dev/test) -
Swoosh.Adapters.Local
For tests, use the test adapter:
# config/test.exs
config :my_app, MyApp.Mailer,
adapter: Swoosh.Adapters.Test
config :ash_dispatch,
email_backend: AshDispatch.EmailBackend.Swoosh,
swoosh_mailer: MyApp.MailerIf you don't configure an email backend, AshDispatch will log emails instead of sending them (useful for development).
4. Ensure domains are configured
Important: AshDispatch discovers events and counters by introspecting resources in your configured Ash domains. If you use AshDispatch.Notification or AshDispatch.DeliveryReceipt base resources, their domains must be in your :ash_domains config.
# config/config.exs
config :my_app, :ash_domains, [
# Your existing domains
MyApp.Tickets,
MyApp.Accounts,
# ADD these if using AshDispatch's built-in resources:
MyApp.Notifications, # Contains your Notification resource
MyApp.Deliveries, # Contains your DeliveryReceipt resource
]Why this matters:
- The TypeScript SDK generator reads events/counters from domains
- Without proper domain config,
mix ash_dispatch.genfinds 0 events - The generator will warn you if no events/counters are found
See Code Generation - Troubleshooting for more details.
5. Configure URL Builder (optional)
For automatic source URL generation on delivery receipts (linking receipts back to their source resources), configure a URL builder module:
# config/config.exs
config :ash_dispatch,
url_builder: MyApp.UrlBuilderThe URL builder should implement two functions:
defmodule MyApp.UrlBuilder do
@moduledoc """
URL builder for AshDispatch source URLs and labels.
Builds audience-specific URLs for resources and provides human-readable labels.
"""
# Load paths at compile time
@app_paths Application.compile_env(:my_app, :app_paths, %{})
@doc """
Returns a human-readable label for a resource type.
Used by the `source_label` calculation on DeliveryReceipt to provide
friendly labels for admin UIs.
## Examples
resource_label(:order) #=> "Order"
resource_label(:ticket) #=> "Support Ticket"
"""
def resource_label(:order), do: "Order"
def resource_label(:ticket), do: "Support Ticket"
def resource_label(:user), do: "Customer"
def resource_label(_), do: nil
@doc """
Builds a URL for a resource with audience-specific routing.
## Parameters
- `resource_type` - Atom like :order, :ticket (matches event's data_key)
- `resource` - Map/struct with :id field
- `opts` - Keyword list with :audience (required), :path_only (optional)
## Examples
build_resource_url(:order, %{id: "abc"}, audience: :user, path_only: true)
#=> "/orders/abc"
build_resource_url(:order, %{id: "abc"}, audience: :admin)
#=> "https://myapp.com/admin/orders/abc"
"""
def build_resource_url(resource_type, resource, opts) do
audience = Keyword.fetch!(opts, :audience)
path_only = Keyword.get(opts, :path_only, false)
path_template = get_in(@app_paths, [audience, resource_type])
if is_nil(path_template) do
raise ArgumentError,
"No path configured for audience #{inspect(audience)}, " <>
"resource #{inspect(resource_type)}"
end
path = String.replace(path_template, ":id", to_string(resource.id))
if path_only do
path
else
get_base_url() <> path
end
end
defp get_base_url do
# Implement based on your endpoint configuration
"https://myapp.com"
end
endConfigure your path templates:
# config/config.exs
config :my_app, :app_paths,
user: %{
order: "/orders/:id",
ticket: "/support/:id"
},
admin: %{
order: "/admin/orders/:id",
ticket: "/admin/tickets/:id"
}With this configured, events with data_key defined will automatically generate source URLs. The data_key (e.g., :order) maps directly to the resource_type in your path config.
How it works:
- Event defines
data_key: :order - Default
source_url/2callsurl_builder.build_resource_url(:order, order, audience: channel.audience) - URL builder looks up path template for
:useror:adminaudience - Returns audience-specific path like
/orders/abcor/admin/orders/abc
Events without data_key or with paths not configured will return nil for source URL (graceful fallback).
DeliveryReceipt Calculated Fields
With the URL builder configured, DeliveryReceipt provides three calculated fields:
| Field | Description |
|---|---|
source_url | URL using the receipt's audience (:user → portal, :admin → admin) |
source_label | Human-readable label from resource_label/1 (e.g., "Order", "Ticket") |
admin_url | Always returns admin URL, regardless of receipt audience |
Frontend usage example (TypeScript):
// In your admin dashboard, always use adminUrl for links
<Link href={receipt.adminUrl}>
View {receipt.sourceLabel}
</Link>
// In user portal, use sourceUrl (audience-aware)
<Link href={receipt.sourceUrl}>
View {receipt.sourceLabel}
</Link>Why admin_url? When viewing receipts in an admin dashboard, you want admin links even for receipts that were sent to users (which have audience: :user). The admin_url calculation always uses audience: :admin when building the URL.
Add Your First Event
Let's add notifications to a Ticket resource.
0. Initial Setup (first time only)
Before creating your first event, set up the directory structure and layouts:
mix ash_dispatch.setup
This creates priv/ash_dispatch/layouts/ with default email templates. Customize these with your branding. See Generator Guide for details.
1. Add the extension
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
domain: MyApp.Tickets,
data_layer: AshPostgres.DataLayer,
extensions: [AshDispatch.Resource] # Add this!
# ... existing code ...
end2. Define your first event
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
domain: MyApp.Tickets,
extensions: [AshDispatch.Resource]
actions do
create :create do
accept [:title, :description, :user_id]
end
end
# Add dispatch section
dispatch do
event :created,
trigger_on: :create,
channels: [
[transport: :in_app, audience: :user]
],
content: [
notification_title: "Ticket Created",
notification_message: "Your ticket has been created and assigned ID #{{id}}"
],
metadata: [
notification_type: :success
]
end
end3. Test it out!
# Create a ticket
ticket = Ticket
|> Ash.Changeset.for_create(:create, %{
title: "Bug report",
description: "Something is broken",
user_id: user.id
})
|> Ash.create!()
# Check that notification was created
notifications = Notification
|> Ash.Query.filter(user_id == ^user.id)
|> Ash.read!()
# You should see:
# [%Notification{
# title: "Ticket Created",
# message: "Your ticket has been created and assigned ID #1",
# notification_type: :success
# }]4. Check delivery receipts
Every event creates a DeliveryReceipt for tracking:
require Ash.Query
import Ash.Expr
# Query receipts for this event
receipts = AshDispatch.Resources.DeliveryReceipt
|> Ash.Query.filter(expr(event_id == "ticket.created"))
|> Ash.read!()
# You should see:
# [%AshDispatch.Resources.DeliveryReceipt{
# id: "a1b2c3d4-...",
# event_id: "ticket.created",
# transport: :in_app,
# audience: :user,
# recipient: "user-id-...",
# status: :sent,
# sent_at: ~U[2025-01-16 10:30:00Z],
# content: %{
# title: "Ticket Created",
# message: "Your ticket has been created and assigned ID #1",
# notification_type: :success
# }
# }]
# Query failed deliveries
failed = AshDispatch.Resources.DeliveryReceipt
|> Ash.Query.filter(expr(status == :failed))
|> Ash.read!()
# Query by transport
email_receipts = AshDispatch.Resources.DeliveryReceipt
|> Ash.Query.filter(expr(transport == :email))
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.read!()Receipt Status Lifecycle:
:pending- Just created:scheduled- Oban job enqueued (for async transports):sending- Currently being delivered:sent- Successfully delivered ✅:failed- Delivery failed (will retry):skipped- Intentionally skipped (e.g., user opted out)
Add Email Notifications
Let's send an email when a ticket is resolved.
1. Create email templates
# lib/my_app/emails/templates/ticket_resolved.html.heex
<h1>Ticket Resolved</h1>
<p>Hi <%= @user_name %>,</p>
<p>Your ticket <strong>#<%= @ticket_id %></strong> has been resolved!</p>
<p><%= @resolution_notes %></p>
<p>
<a href="<%= @action_url %>">View Ticket</a>
</p># lib/my_app/emails/templates/ticket_resolved.text.eex
Ticket Resolved
Hi <%= @user_name %>,
Your ticket #<%= @ticket_id %> has been resolved!
<%= @resolution_notes %>
View Ticket: <%= @action_url %>2. Add the event with callback module
For complex events with templates, use a callback module.
IMPORTANT: You must specify the module: option explicitly for the event module's callbacks to be found:
dispatch do
# ... existing :created event ...
event :resolved,
trigger_on: :resolve,
module: MyApp.Events.Tickets.Resolved # Required! Without this, callbacks won't be called
endWhy is module: required? Without the explicit module: option, the DSL config's module field is nil, which means AshDispatch cannot look up callbacks like prepare_data/2, recipients/2, etc. Always specify the module when using callback-based events.
3. Create the event module
defmodule MyApp.Events.Tickets.Resolved do
@behaviour AshDispatch.Event
@impl true
def channels(_context) do
[
[transport: :in_app, audience: :user],
[transport: :email, audience: :user, delay: 60]
]
end
@impl true
def recipients(context, channel) do
case channel.audience do
:user -> [context.data.ticket.user]
end
end
@impl true
def from_email(_context, _channel), do: "support@myapp.com"
@impl true
def subject(_context, _channel), do: "Your ticket has been resolved"
@impl true
def prepare_template_assigns(context, _channel) do
%{
user_name: context.data.ticket.user.name,
ticket_id: context.data.ticket.id,
resolution_notes: context.data.ticket.resolution_notes,
action_url: "#{context.base_url}/tickets/#{context.data.ticket.id}"
}
end
@impl true
def render_html_email(context, channel) do
Phoenix.View.render_to_string(
MyAppWeb.EmailView,
"ticket_resolved.html",
prepare_template_assigns(context, channel)
)
end
@impl true
def render_text_email(context, channel) do
Phoenix.View.render_to_string(
MyAppWeb.EmailView,
"ticket_resolved.text",
prepare_template_assigns(context, channel)
)
end
@impl true
def notification_title(_context, _channel), do: "Ticket Resolved"
@impl true
def notification_message(context, _channel) do
"Ticket ##{context.data.ticket.id} has been resolved"
end
endResource-Triggered Events with prepare_data
When events are triggered by resource actions (via trigger_on), you often need to prepare additional data that's not directly on the record. The prepare_data/2 callback is essential for this.
Why prepare_data Matters
For in-app notifications to be created correctly, AshDispatch needs to know which user should receive the notification. This is done via context.data.user. The prepare_data callback lets you populate this.
The prepare_data/2 Callback
defmodule MyApp.Events.Leads.ProjectOwnerAssigned do
use AshDispatch.Event
@impl true
def prepare_data(changeset, _record) do
# Extract the user who should receive this notification
user_id = Ash.Changeset.get_argument(changeset, :project_owner_id)
if user_id do
case Ash.get(MyApp.Accounts.User, user_id, authorize?: false, load: [:full_name]) do
{:ok, user} when not is_nil(user) ->
# CRITICAL: Return with :user key for notification recipient
%{user: user}
_ ->
%{}
end
else
%{}
end
end
# ... other callbacks
endRequired Return Structure
CRITICAL: For in-app notifications to work, prepare_data must return a map with a :user key:
# ✅ Correct - notification will be created for this user
%{user: %{id: "user-id", email: "user@example.com"}}
# ❌ Wrong - notification won't know who to send to
%{project_owner: %{id: "user-id", email: "user@example.com"}}The :user key is what AshDispatch uses to:
- Create the notification with the correct
user_id - Resolve recipients via the
recipients/2callback - Check user preferences
Using data_key with prepare_data
When you define data_key in your event, it determines how the main record is stored in context:
# In DSL:
event :project_owner_assigned,
trigger_on: :assign_project_owner,
data_key: :lead, # Record stored as context.data.lead
module: MyApp.Events.Leads.ProjectOwnerAssigned
# In event module:
@impl true
def data_key, do: :lead
@impl true
def prepare_data(changeset, record) do
# record is the Lead that triggered the event
# Return additional data to merge into context.data
%{user: load_project_owner(changeset)}
end
# After prepare_data, context.data contains:
# %{
# lead: %Lead{...}, # From data_key
# user: %User{...} # From prepare_data
# }Common Pattern: Loading Related Users
@impl true
def prepare_data(changeset, _record) do
# Get user ID from changeset argument
user_id = Ash.Changeset.get_argument(changeset, :assigned_to_id)
# Or get from an attribute being set
user_id = user_id || Ash.Changeset.get_attribute(changeset, :owner_id)
if user_id do
case Ash.get(MyApp.Accounts.User, user_id, authorize?: false, load: [:full_name]) do
{:ok, user} -> %{user: user}
_ -> %{}
end
else
%{}
end
endWhen prepare_data is Called
prepare_data/2 is called:
- During resource action dispatch - When
trigger_onfires - Before channel resolution - So
context.datais populated forrecipients/2 - With changeset and record - Giving access to both before and after state
It is NOT called during:
- Manual triggers (use
generate_send_variables/2instead) - Preview mode (uses
sample_data/0instead)
Add Multiple Triggers
Events can be triggered by multiple actions:
dispatch do
event :status_changed,
trigger_on: [:start, :pause, :resume, :resolve, :close],
channels: [
[transport: :in_app, audience: :user]
],
content: [
notification_title: "Ticket Status Updated",
notification_message: "Ticket #{{id}} status: {{status}}"
]
endAdd Admin Notifications
Send notifications to different audiences:
dispatch do
event :created,
trigger_on: :create,
channels: [
# User gets in-app notification
[transport: :in_app, audience: :user],
# Admins get email immediately
[transport: :email, audience: :admin],
# User gets email after 5 minutes (if not read)
[transport: :email, audience: :user, delay: 300, policy: :skip_if_read]
],
content: [
subject: "New Ticket: {{title}}",
notification_title: "New Ticket",
notification_message: "{{user_name}} created ticket #{{id}}"
]
endPreload Relationships
If your event needs related data, preload it:
dispatch do
event :created,
trigger_on: :create,
load: [:user, :assignee], # Preload these relationships
channels: [
[transport: :email, audience: :user]
],
content: [
subject: "Ticket assigned to {{assignee_name}}"
]
endVariable Interpolation
AshDispatch automatically replaces {{variable}} placeholders with actual values from your resource data.
Basic Usage
Use double curly braces in any inline content field:
content: [
subject: "Ticket #{{id}} - {{status}}",
notification_title: "Hello {{user_name}}!",
notification_message: "Created at {{inserted_at}}",
action_url: "/tickets/{{id}}"
]Variable Sources
Variables can come from three sources:
1. Direct Attributes - Fields on the resource itself:
# In Ticket resource with fields: id, status, title, priority
content: [
notification_message: "Ticket #{{id}}: {{title}} (Priority: {{priority}})"
]
# → "Ticket #123: Bug report (Priority: high)"2. Nested Attributes - Preloaded relationships using dot notation:
dispatch do
event :assigned,
trigger_on: :assign,
load: [:user, :assignee], # Preload these first!
content: [
notification_message: "{{user.name}} assigned ticket to {{assignee.email}}"
]
end
# → "Alice assigned ticket to bob@example.com"3. Flattened Keys - Alternative syntax for nested attributes:
# These are equivalent:
notification_message: "Hello {{user.name}}"
notification_message: "Hello {{user_name}}" # Underscore becomes dot
# Useful for deeply nested data:
notification_message: "Org: {{organization.billing_contact.email}}"
notification_message: "Org: {{organization_billing_contact_email}}"Type Conversion
Values are automatically converted to strings:
# Atoms
{{status}} where status = :active → "active"
# Numbers
{{id}} where id = 123 → "123"
{{price}} where price = 99.99 → "99.99"
# Booleans
{{active}} where active = true → "true"
# Dates & Times
{{inserted_at}} where inserted_at = ~U[2025-01-16 10:30:00Z]
→ "2025-01-16 10:30:00Z"
# Nil values
{{missing}} where missing = nil → "" (empty string)Safety Features
Missing Variables - No errors, just empty strings:
notification_message: "Value: {{nonexistent_field}}"
# → "Value: " (empty)Nil Handling - Gracefully handled:
notification_message: "User: {{user.name}}"
# If user is nil → "User: "
# If user.name is nil → "User: "Missing Relationships - Must be preloaded:
# ❌ Will return empty if not preloaded:
notification_message: "{{user.name}}"
# ✅ Preload first:
dispatch do
event :created,
trigger_on: :create,
load: [:user], # Preload here!
content: [
notification_message: "{{user.name}} created a ticket"
]
endCommon Patterns
Dynamic URLs:
content: [
action_url: "/tickets/{{id}}/comments/{{comment_id}}"
]Conditional-like Content:
# You can't use real conditionals, but you can use multiple events:
event :ticket_urgent,
trigger_on: :create,
channels: [[transport: :email, audience: :admin]],
content: [
subject: "🚨 URGENT: Ticket #{{id}} - {{title}}"
]
event :ticket_normal,
trigger_on: :create,
channels: [[transport: :in_app, audience: :user]],
content: [
subject: "Ticket #{{id}} - {{title}}"
]Multi-level Nesting:
dispatch do
event :order_shipped,
trigger_on: :ship,
load: [:user, :shipping_address, :items],
content: [
notification_message: """
Hi {{user.name}},
Your order #{{id}} has shipped to:
{{shipping_address.street}}
{{shipping_address.city}}, {{shipping_address.state}}
Items: {{items}} (note: lists don't interpolate well)
"""
]
endTroubleshooting
Variable shows as {{name}} instead of value:
- ✅ Check spelling (variables are case-sensitive)
- ✅ Ensure field exists on resource
- ✅ For nested fields, verify relationship is in
load: [...] - ✅ Check that relationship loaded successfully (not
%Ecto.Association.NotLoaded{})
Variable shows as empty string:
- This means the value is
nilor the field doesn't exist - Add
load: [:relationship]if accessing nested data - Verify the field name matches exactly (case-sensitive)
User Preferences
Let users control which notifications they receive by implementing preference checking.
1. Configure Your Preference Checker
# config/config.exs
config :ash_dispatch,
user_preference: MyApp.NotificationPreferences2. Implement the Behaviour
defmodule MyApp.NotificationPreferences do
@behaviour AshDispatch.UserPreference
@impl true
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
case Ash.get(UserPreference, user_id) do
{:ok, prefs} ->
# Check if user disabled this category or transport
category not in prefs.disabled_categories and
transport not in prefs.disabled_transports
_ ->
true # Allow if no preferences found
end
end
end3. Add Categories to Events
dispatch do
event :promotional_offer,
trigger_on: :create,
channels: [[transport: :email, audience: :user]],
metadata: [
category: :marketing # Users can opt out of this
]
endHow Preference Checking Works
- Receipt Created - Delivery receipt created with status
:pending - Preference Check - Transport calls
UserPreference.allows?/3 - If Opted Out - Receipt marked
:skippedwith error"user_opted_out" - If Allowed - Delivery proceeds normally
Important: Receipts are always created for audit purposes, even if skipped.
Preference Granularity
Users can control at three levels:
By Category:
# User opts out of all marketing
disabled_categories: [:marketing, :promotional]By Transport:
# User opts out of emails only
disabled_transports: [:email] # Still gets :in_appCombined:
# User opts out of marketing emails but allows marketing in-app
def user_allows?(user_id, _event_id, transport, opts) do
category = opts[:category]
# Check category + transport combinations
{category, transport} not in get_disabled_combinations(user_id)
endWhich Events Are Checked?
✅ Preference checking applies to:
- Events with
audience: :user - User-configurable events
❌ Preferences are bypassed for:
- Events with
audience: :admin - Events with
audience: :team - Events with
audience: :system - Critical system notifications
Testing Preference Checking
test "user who opted out gets notification skipped" do
user = create_opted_out_user()
{:ok, order} = create_order(%{user_id: user.id})
# Verify receipt was skipped
receipts = DeliveryReceipt
|> Ash.Query.filter(event_id == "order.created")
|> Ash.read!()
assert hd(receipts).status == :skipped
assert hd(receipts).error_message == "user_opted_out"
endSee User Preferences for complete documentation including UI integration, caching, and advanced patterns.
Testing Events
Test that events are defined
defmodule MyApp.TicketTest do
use ExUnit.Case
alias MyApp.Tickets.Ticket
test "has dispatch events defined" do
dsl_state = Ticket.spark_dsl_config()
events = Spark.Dsl.Transformer.get_entities(dsl_state, [:dispatch])
assert length(events) == 2
assert Enum.find(events, &(&1.name == :created))
assert Enum.find(events, &(&1.name == :resolved))
end
endTest event dispatch
test "creating ticket dispatches event" do
ticket = create_ticket()
# Check that notification was created
notifications = Notification
|> Ash.Query.filter(user_id == ^ticket.user_id)
|> Ash.read!()
assert [notification] = notifications
assert notification.title == "Ticket Created"
endTest email content with factories
test "resolved email renders correctly" do
# Use factory to build test data (no database!)
ticket = build(:ticket, %{
id: 123,
user: build(:user, %{name: "Alice"}),
resolution_notes: "Fixed the bug"
})
context = %AshDispatch.Context{
event_id: "ticket.resolved",
data: %{ticket: ticket},
base_url: "https://app.example.com"
}
channel = %AshDispatch.Channel{transport: :email, audience: :user}
html = MyApp.Events.Tickets.Resolved.render_html_email(context, channel)
assert html =~ "Hi Alice"
assert html =~ "Ticket #123"
assert html =~ "Fixed the bug"
endAdd Real-Time Counters
Counters broadcast live updates to frontend UIs. Add them to your resource:
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
extensions: [AshDispatch.Resource]
# ... existing dispatch events ...
counters do
# User sees their open tickets
counter :open_tickets,
trigger_on: [:create, :resolve, :close],
query_filter: [status: :open],
audience: :user,
group: :tickets,
invalidates: ["tickets"]
# Admin sees ALL open tickets (system-wide)
counter :admin_open_tickets,
trigger_on: [:create, :resolve, :close],
query_filter: [status: :open],
audience: :admin,
authorize?: false, # Bypass policies - count ALL records
invalidates: ["tickets"]
end
endCounter Options Explained
| Option | Purpose |
|---|---|
trigger_on | Actions that trigger broadcast |
query_filter | Static filter for counting (e.g., [status: :open]) |
audience | WHO receives the broadcast (:user, :admin, custom) |
authorize? | false = bypass policies (admin dashboards) |
scope | Ash expression for custom filtering (see below) |
invalidates | Frontend query keys to invalidate |
Advanced: Scope Expressions
For complex scoping beyond simple user_id relationships:
# Regional admin sees only their region
counter :regional_open_tickets,
trigger_on: [:create, :resolve],
query_filter: [status: :open],
audience: :admin,
scope: expr(region == ^actor(:region)),
invalidates: ["tickets"]
# Admin sees their assigned tickets
counter :my_assigned_tickets,
trigger_on: [:create, :resolve],
query_filter: [status: :open],
audience: :admin,
scope: expr(assigned_to_id == ^actor(:id)),
invalidates: ["tickets"]See Counter Broadcasting for complete documentation.
Next Steps
Recommended next: App Integration - Set up custom resources, database, and RPC
Then explore:
- Phoenix Integration - Real-time channels and frontend
- Counter Broadcasting - Complete counter documentation
- User Preferences - Let users control notifications
- DSL Reference - Complete DSL documentation
Common Patterns
Delayed Email After In-App
Send in-app notification immediately, email if not read:
channels: [
[transport: :in_app, audience: :user],
[transport: :email, audience: :user, delay: 300, policy: :skip_if_read]
]Admin Alerts
channels: [
[transport: :email, audience: :admin],
[transport: :discord, audience: :team, webhook_url: "https://..."]
]Deduplicating Overlapping Audiences
When audiences overlap (e.g., an admin who is also a stakeholder), use deduplicate_group:
channels: [
# User always gets in_app notification (no group)
[transport: :in_app, audience: :customer],
# Internal staff share a group - each person gets only ONE in_app notification
[transport: :in_app, audience: :stakeholders, deduplicate_group: :internal],
[transport: :in_app, audience: :admin, deduplicate_group: :internal],
# Email also deduplicated separately
[transport: :email, audience: :stakeholders, deduplicate_group: :internal_email],
[transport: :email, audience: :admin, deduplicate_group: :internal_email]
]The first matching channel in DSL order wins when a user matches multiple audiences in the same group.
Progressive Reminders
# Define multiple events with increasing delays
event :payment_due_soon,
trigger_on: :create,
channels: [[transport: :email, audience: :user, delay: 86400]] # 1 day
event :payment_overdue,
trigger_on: :create,
channels: [[transport: :email, audience: :user, delay: 259200]] # 3 daysTroubleshooting
Events not firing
- Check that extension is added:
extensions: [AshDispatch.Resource] - Verify action name matches
trigger_on - Check Oban is running:
Oban.check_queue(queue: :ash_dispatch_email)
Emails not sending
- Verify Swoosh is configured
- Check Oban job logs
- Look for delivery receipts with
:failedstatus
Variables not interpolating
- Ensure variable exists on the resource
- Preload relationships with
load: [...] - Check spelling (case-sensitive!)
Callbacks not being called (prepare_data, recipients, etc.)
Problem: Your event module's callbacks like prepare_data/2 or recipients/2 are not being executed.
Cause 1: Missing module: option
Without an explicit module: option in your DSL config, the event module is nil:
# ❌ Wrong - module is nil, callbacks won't be found
event :assigned,
trigger_on: :assign
# ✅ Correct - explicit module reference
event :assigned,
trigger_on: :assign,
module: MyApp.Events.Tickets.AssignedCause 2: Module not loaded
If you're testing callbacks manually, ensure the module is loaded first:
# Check if module exports the callback
Code.ensure_loaded?(MyApp.Events.Tickets.Assigned)
function_exported?(MyApp.Events.Tickets.Assigned, :prepare_data, 2)Notifications not created (in-app)
Problem: The event fires but no notification appears in the database.
Cause: Missing :user key in prepare_data
For in-app notifications, context.data.user must contain the recipient:
# ❌ Wrong - no :user key
def prepare_data(changeset, _record) do
%{assignee: load_user(changeset)}
end
# ✅ Correct - :user key present
def prepare_data(changeset, _record) do
user = load_user(changeset)
%{user: user, assignee: user} # Use :user for notification recipient
endTests pass individually but fail together
Problem: Running mix test fails but individual test files pass.
Cause: Wrong Oban testing mode
With :manual mode, jobs from previous tests can interfere. Use :inline:
# config/test.exs
config :my_app, Oban,
testing: :inline # Jobs execute synchronouslySee Oban Configuration - Testing for details.
Delivery receipts show "pending" forever
Problem: Receipts are created but status never changes from :pending.
Solutions:
Check Oban is running:
Oban.check_queue(:emails)In tests, use
:inlinemode:config :my_app, Oban, testing: :inlineCheck for job errors:
Oban.Job |> Ash.Query.filter(state == "retryable" or state == "discarded") |> Ash.read!()
Event module callbacks return defaults instead of expected values
Problem: Callbacks like subject/2 or notification_title/2 return nil or default values.
Cause: Module not properly loaded at runtime
AshDispatch uses Code.ensure_loaded? before checking function_exported?. If you see unexpected defaults:
- Verify module compiles without errors
- Check module is in your application's load path
- In tests, ensure module is compiled before the test runs
# Force module to load
Code.ensure_loaded!(MyApp.Events.Tickets.Assigned)
# Now check the callback
MyApp.Events.Tickets.Assigned.subject(context, channel)Preview shows sample data but real send uses wrong data
Problem: Preview looks correct but actual emails have wrong/missing data.
Cause: generate_send_variables/2 not implemented
For events that need to generate data at send time (tokens, codes, etc.), implement generate_send_variables/2:
@impl true
def generate_send_variables(context, opts) do
if not Map.has_key?(opts, :reset_token) do
case generate_real_token(context.data.user) do
{:ok, token} -> {:ok, Map.put(opts, :reset_token, token)}
{:error, reason} -> {:error, reason} # Fail! Don't send with sample data
end
else
{:ok, opts}
end
end