Getting Started with AshDispatch

View Source

This 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:

The fastest way to get started is using the Igniter installer:

mix igniter.install ash_dispatch

This will automatically:

  • Create Notification and DeliveryReceipt resources
  • Create Notifications and Deliveries domains
  • Add domains to your :ash_domains configuration
  • Create a RecipientResolver module 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.Rpc extension)

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.5.0"},
    {:oban, "~> 2.17"},  # Required for async delivery
    {:swoosh, "~> 1.16"} # Required for email transport
  ]
end

Note: 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])
end

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

Available Swoosh adapters:

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

If 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.gen finds 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.UrlBuilder

The 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
end

Configure 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:

  1. Event defines data_key: :order
  2. Default source_url/2 calls url_builder.build_resource_url(:order, order, audience: channel.audience)
  3. URL builder looks up path template for :user or :admin audience
  4. Returns audience-specific path like /orders/abc or /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:

FieldDescription
source_urlURL using the receipt's audience (:user → portal, :admin → admin)
source_labelHuman-readable label from resource_label/1 (e.g., "Order", "Ticket")
admin_urlAlways 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 ...
end

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

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

Why 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
end

Resource-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
end

Required 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:

  1. Create the notification with the correct user_id
  2. Resolve recipients via the recipients/2 callback
  3. 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
# }
@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
end

When prepare_data is Called

prepare_data/2 is called:

  • During resource action dispatch - When trigger_on fires
  • Before channel resolution - So context.data is populated for recipients/2
  • With changeset and record - Giving access to both before and after state

It is NOT called during:

  • Manual triggers (use generate_send_variables/2 instead)
  • Preview mode (uses sample_data/0 instead)

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}}"
    ]
end

Add 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}}"
    ]
end

Preload 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}}"
    ]
end

Variable 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"
    ]
end

Common 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)
      """
    ]
end

Troubleshooting

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 nil or 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.NotificationPreferences

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

3. 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
    ]
end

How Preference Checking Works

  1. Receipt Created - Delivery receipt created with status :pending
  2. Preference Check - Transport calls UserPreference.allows?/3
  3. If Opted Out - Receipt marked :skipped with error "user_opted_out"
  4. 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_app

Combined:

# 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)
end

Which 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"
end

See 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
end

Test 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"
end

Test 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"
end

Add 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
end

Counter Options Explained

OptionPurpose
trigger_onActions that trigger broadcast
query_filterStatic filter for counting (e.g., [status: :open])
audienceWHO receives the broadcast (:user, :admin, custom)
authorize?false = bypass policies (admin dashboards)
scopeAsh expression for custom filtering (see below)
invalidatesFrontend 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:

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 days

Troubleshooting

Events not firing

  1. Check that extension is added: extensions: [AshDispatch.Resource]
  2. Verify action name matches trigger_on
  3. Check Oban is running: Oban.check_queue(queue: :ash_dispatch_email)

Emails not sending

  1. Verify Swoosh is configured
  2. Check Oban job logs
  3. Look for delivery receipts with :failed status

Variables not interpolating

  1. Ensure variable exists on the resource
  2. Preload relationships with load: [...]
  3. 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.Assigned

Cause 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
end

Tests 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 synchronously

See Oban Configuration - Testing for details.

Delivery receipts show "pending" forever

Problem: Receipts are created but status never changes from :pending.

Solutions:

  1. Check Oban is running:

    Oban.check_queue(:emails)
  2. In tests, use :inline mode:

    config :my_app, Oban, testing: :inline
  3. Check 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:

  1. Verify module compiles without errors
  2. Check module is in your application's load path
  3. 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

Help & Support