Configuration

View Source

AshDispatch is designed to be flexible and configurable. This guide covers all available configuration options and when to use them.

Required Configuration

Counter Broadcasting (for real-time counters)

Configure the function to call when broadcasting counter updates to Phoenix Channels:

# config/config.exs
config :ash_dispatch,
  counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}

Options:

# MFA tuple (recommended - easier to test)
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}

# Or function capture
counter_broadcast_fn: &MyAppWeb.UserChannel.broadcast_counter/4

Your broadcast function should accept:

  • user_id (string) - The user to broadcast to
  • counter_name (atom) - The counter name (e.g., :pending_orders)
  • value (integer) - The current count
  • opts (keyword list) - Options including :metadata with invalidate_queries

Example implementation:

defmodule MyAppWeb.UserChannel do
  def broadcast_counter(user_id, counter_name, value, opts \\ []) do
    metadata = Keyword.get(opts, :metadata, %{})

    MyAppWeb.Endpoint.broadcast("user:#{user_id}", "counter_updated", %{
      counter: counter_name,
      value: value,
      metadata: metadata
    })
  end
end

See Phoenix Channel Integration for complete setup guide.

Email Backend (if using email transport)

Configure Swoosh for email delivery:

# config/config.exs
config :ash_dispatch,
  email_backend: AshDispatch.EmailBackend.Swoosh,
  swoosh_mailer: MyApp.Mailer

config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")

Oban (for async delivery)

Configure Oban for asynchronous email delivery:

config :my_app, Oban,
  engine: Oban.Engines.Basic,
  notifier: Oban.Notifiers.Postgres,
  repo: MyApp.Repo,
  queues: [
    emails: 10  # AshDispatch uses :emails queue
  ]

See Oban Configuration for complete setup.

Standalone Event Module Configuration

If you're using standalone event modules (with AshDispatch.Event behaviour) instead of the DSL-based approach, you need to configure these options:

Event Modules (Auto-Discovered)

Event modules are automatically discovered from your Ash domains - no manual configuration needed!

AshDispatch scans all resources with AshDispatch.Resource extension and finds:

  1. Events with explicit module: option in DSL
  2. Auto-generated event modules following the naming convention
# OLD - No longer needed!
# config :ash_dispatch, :event_modules, [...]

# NEW - Just configure your otp_app and domains
config :ash_dispatch, :otp_app, :my_app

config :my_app, :ash_domains, [
  MyApp.Orders,
  MyApp.Tickets
]

The AshDispatch.EventRegistry module handles discovery:

  • EventRegistry.get_event_modules() - Returns [{event_id, module}, ...]
  • EventRegistry.find_event("order.created") - Find specific event
  • EventRegistry.find_module("order.created") - Find event module

This configuration is used by:

User Resource

Specify your application's User resource for recipient resolution:

config :ash_dispatch,
  user_resource: MyApp.Accounts.User,
  user_domain: MyApp.Accounts

Why needed:

  • Resolves :user audience to actual user records
  • Loads user relationships for template rendering
  • Used by recipient resolution when dispatching events

Default: nil (must be configured for :user audience)

Preference Provider

Implement user notification preferences checking:

config :ash_dispatch,
  preference_provider: MyApp.NotificationPreferences

Your preference provider must implement AshDispatch.PreferenceProvider behaviour:

defmodule MyApp.NotificationPreferences do
  @behaviour AshDispatch.PreferenceProvider

  @impl true
  def allows_notification?(user_id, event_id, transport, opts) do
    # Check if user allows this notification
    # Return true/false
  end
end

Why needed:

  • Respects user opt-out preferences
  • Required if you want user-configurable notifications
  • Called before every delivery to :user audience

Default: nil (all notifications allowed)

See User Preferences for implementation guide.

Recipient Fields (Recipient Field Extraction)

Configure how recipient information is extracted for each transport:

config :ash_dispatch,
  recipient_fields: [
    email: [
      identifier: :email,
      name: [:contact_person, :display_name, :name]
    ],
    in_app: [
      identifier: :id,
      name: [:contact_person, :display_name, :name]
    ]
  ]

Why needed:

  • Different transports need different recipient data (email address vs phone number vs user ID)
  • Supports fallback chains for name fields
  • Allows per-audience overrides for special cases

Transport-first structure: Each transport defines its own identifier (where to send) and name (who to display).

Fallback chains: For name fields, specify a list of fields to try in order:

name: [:display_name, :name, :contact_person]  # tries each until one has a value

Per-audience overrides:

recipient_fields: [
  email: [identifier: :email, name: :contact_person],

  audiences: [
    admin: [email: [name: :full_name]],  # admins show full name
    customer: [email: [identifier: :contact_email]]  # customers use different email
  ]
]

See Recipient Field Extraction for complete guide and advanced formats.

Notification Resource

Override the default notification resource with your own (for in-app notifications):

config :ash_dispatch,
  notification_resource: MyApp.Notifications.Notification

Why needed:

  • Use your own Notification resource with custom fields
  • Enable PubSub broadcasting for real-time updates
  • Store notifications in Postgres instead of ETS
  • Add custom policies and validations

Default: AshDispatch.Resources.Notification (ETS-based, no broadcasting)

Requirements for custom notification resource:

  • Must accept these attributes in :create action:
    • :user_id (uuid)
    • :title (string)
    • :message (string)
    • :action_url (string, optional)
    • :event_id (string, optional)
    • :type or :notification_type (atom: :info, :success, :warning, :error)

Example custom notification resource:

defmodule MyApp.Notifications.Notification do
  use Ash.Resource,
    domain: MyApp.Notifications,
    data_layer: AshPostgres.DataLayer,
    notifiers: [Ash.Notifier.PubSub]

  postgres do
    table "notifications"
    repo MyApp.Repo
  end

  # Enable real-time broadcasting
  pub_sub do
    module MyAppWeb.Endpoint
    prefix "notifications"

    publish_all :create, ["user", :user_id]
    publish_all :update, ["user", :user_id]
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      accept [
        :user_id,
        :type,           # or :notification_type
        :title,
        :message,
        :action_url,
        :event_id
      ]
    end

    update :mark_as_read do
      accept [:read, :read_at]
    end
  end

  attributes do
    uuid_v7_primary_key :id

    attribute :user_id, :uuid do
      allow_nil? false
    end

    attribute :type, :atom do
      allow_nil? false
      constraints one_of: [:info, :success, :warning, :error]
    end

    attribute :title, :string do
      allow_nil? false
    end

    attribute :message, :string do
      allow_nil? false
    end

    attribute :action_url, :string

    attribute :event_id, :string

    attribute :read, :boolean do
      default false
    end

    attribute :read_at, :utc_datetime_usec

    timestamps()
  end

  relationships do
    belongs_to :user, MyApp.Accounts.User do
      allow_nil? false
    end
  end
end

Optional Configuration

Localization

Configure default locale for template resolution:

config :ash_dispatch,
  default_locale: "sv",  # Default when no locale found (defaults to "en")
  gettext_backend: MyAppWeb.Gettext  # Optional: translate content: strings via Gettext

Why needed:

  • default_locale sets the fallback language for templates and content translation
  • Used when no locale is configured on channel, event, or resource
  • Templates fall back to non-localized versions if locale-specific not found
  • gettext_backend (optional) enables automatic translation of content: block strings at dispatch time. See Localization for details.

Default: "en"

See Localization for the complete i18n guide including:

  • Resource-level locale configuration
  • Dynamic locale from record fields
  • Template fallback chain
  • Multi-language templates

Phoenix Channel Topic

Configure the channel topic prefix for real-time notifications:

config :ash_dispatch,
  channel_topic: "inbox"  # Creates topics like "inbox:user_id"

Default: "user" (creates topics like "user:user_id")

Why needed:

  • Must match your Phoenix channel topic structure
  • Used for broadcasting notifications and counter updates

Ash Domains (for counter auto-discovery)

Specify which Ash domains to scan for counter definitions:

config :ash_dispatch,
  domains: [MyApp.Orders, MyApp.Tickets, MyApp.Catalog, MyApp.Accounts]

Why needed:

  • CounterLoader scans these domains to discover counter definitions
  • Required if you're using the counter broadcasting feature
  • Counters defined in resources outside these domains won't be loaded

Default: [] (no domains scanned)

Configure a declarative recipient resolver module for audience resolution:

config :ash_dispatch,
  recipient_resolver: MyApp.RecipientResolver

The RecipientResolver module uses a DSL to define how recipients are resolved:

defmodule MyApp.RecipientResolver do
  use AshDispatch.RecipientResolver,
    user_resource: MyApp.Accounts.User

  audiences do
    audience :user, from_context: :user
    audience :admins, query: [role: :admin, is_active: true]
    audience :owner, resolve: :resolve_owner
    audience :stakeholders, combine: [:owner, :team]
  end

  @impl true
  def to_recipient(%MyApp.Accounts.User{} = user) do
    %{id: user.id, email: to_string(user.email), display_name: user.full_name}
  end

  def resolve_owner(resource, context) do
    # Custom resolution logic
  end
end

Resolution Strategies:

StrategyExampleDescription
from_contextfrom_context: :userExtract from context.data
queryquery: [role: :admin]Query user_resource with Ash filter
pathpath: [:team, :users]Follow relationship path on resource
combinecombine: [:owner, :team]Union of other audiences
resolveresolve: :resolve_ownerCustom resolver function

See Recipient Resolution for the complete guide.

Audiences (Legacy Configuration)

Note: The audiences config is legacy. Prefer using recipient_resolver above for new projects.

Configure how recipients are resolved for each audience type:

config :ash_dispatch,
  audiences: [
    # Bare atom = relationship-based (extract from record)
    :user,      # Extract from :user relationship
    :creator,   # Extract from :creator relationship
    :partner,   # Extract from :partner relationship

    # Tuple = filter-based (query all matching users)
    {:admin, [:user, {:admin, true}]},
    {:super_admin, [:user, {:super_admin, true}]},
    {:support, [:user, {:role, :support}]}
  ]

Two configuration patterns:

PatternFormatBehavior
Relationship-based:userExtract from record's relationship
Filter-based{:admin, [...]}Query all users matching filter

Default: [] (assumes relationship-based for unknown audiences)

See Counter Broadcasting and Recipient Resolution for details.

Base URL

Set the base URL for generating links in notifications:

config :ash_dispatch,
  base_url: "https://app.example.com"

Default: "http://localhost:4000"

Used by:

  • Link generation in emails
  • Action URLs in notifications
  • Available in context as context.base_url

Email From Address

Default sender email address:

config :ash_dispatch,
  default_from_email: {"My App Support", "support@example.com"}

Default: {"AshDispatch", "noreply@example.com"}

Note: Event modules can override this per-event via from/2 callback.

Delivery Receipt Resource

Override the default delivery receipt resource:

config :ash_dispatch,
  delivery_receipt_resource: MyApp.Deliveries.DeliveryReceipt

Default: AshDispatch.Resources.DeliveryReceipt

Why override:

  • Add custom fields for your use case
  • Integrate with existing audit/logging systems
  • Custom policies or validations

Send Now Authorizer

Configure authorization for the send_now action on delivery receipts. This action allows manually triggering email delivery for scheduled receipts from an admin UI.

config :ash_dispatch,
  send_now_authorizer: MyApp.Deliveries.SendNowAuthorizer

Default: nil (any authenticated actor can use send_now)

Why needed:

  • Restrict manual email triggering to specific user roles (e.g., super admins only)
  • Prevent accidental mass email sends by regular admins
  • Audit control over who can bypass normal scheduling

Implementing an authorizer:

Your authorizer module must implement an authorize/1 function that receives the actor and returns :ok or {:error, message}:

defmodule MyApp.Deliveries.SendNowAuthorizer do
  @moduledoc """
  Authorizes send_now action for delivery receipts.
  Only super admins can manually trigger email sending.
  """

  @doc """
  Check if actor is authorized to use send_now.

  Returns:
  - `:ok` if authorized
  - `{:error, message}` if not authorized
  """
  def authorize(%{super_admin: true}), do: :ok
  def authorize(_actor), do: {:error, "Only super admins can manually trigger email sending"}
end

Behavior:

  • When nil: Any authenticated actor can use send_now
  • When configured: The authorizer is called with the actor
  • System calls (no actor): Always allowed regardless of authorizer

Important: The actor must be passed to Ash.Changeset.for_update/3 for the authorizer to receive it:

# Correct - actor passed to for_update
receipt
|> Ash.Changeset.for_update(:send_now, %{}, actor: current_user)
|> Ash.update(authorize?: true)

# Incorrect - actor won't reach the validation
receipt
|> Ash.Changeset.for_update(:send_now, %{})
|> Ash.update(actor: current_user, authorize?: true)

Audience Resolution

Configure how recipients are resolved for each audience using recipient_filters:

config :ash_dispatch,
  recipient_filters: [
    audiences: [
      # Bare atom: extract from relationship with same name
      :user,
      :creator,

      # Filter-based: query users matching filter
      admin: [:user, admin: true],
      partner: [:user, role: :partner],

      # Relationship chain: follow multiple relationships
      seller: [:user, :associated_seller]
    ]
  ]

This replaces the need for custom resolver modules. See Recipient Resolution for all 6 supported audience formats and advanced examples.

Complete Example

Here's a complete configuration with all features enabled:

# config/config.exs
config :ash_dispatch,
  # Required: Your app's OTP name (for event auto-discovery)
  otp_app: :my_app,

  # Counter broadcasting (real-time updates)
  counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter},
  domains: [MyApp.Orders, MyApp.Tickets, MyApp.Catalog, MyApp.Accounts],
  user_module: MyApp.Accounts.User,

  # Audience configuration (unified format)
  audiences: [
    # Relationship-based (extract from record)
    :user,
    :partner,

    # Filter-based (query all matching users)
    {:admin, [:user, {:admin, true}]},
    {:super_admin, [:user, {:super_admin, true}]}
  ],

  # Event modules are AUTO-DISCOVERED from domains - no manual config needed!
  # The EventRegistry scans resources with AshDispatch.Resource extension

  # User/admin resolution
  user_resource: MyApp.Accounts.User,
  user_domain: MyApp.Accounts,

  # Custom resources
  notification_resource: MyApp.Notifications.Notification,
  delivery_receipt_resource: MyApp.Deliveries.DeliveryReceipt,

  # Action authorization (restrict send_now to super admins)
  send_now_authorizer: MyApp.Deliveries.SendNowAuthorizer,

  # User preferences
  preference_provider: MyApp.NotificationPreferences,

  # Email configuration
  email_backend: AshDispatch.EmailBackend.Swoosh,
  swoosh_mailer: MyApp.Mailer,
  default_from_email: {"My App", "noreply@myapp.com"},

  # Base URL
  base_url: "https://app.myapp.com"

# Swoosh configuration
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")

# Oban configuration
config :my_app, Oban,
  engine: Oban.Engines.Basic,
  notifier: Oban.Notifiers.Postgres,
  repo: MyApp.Repo,
  queues: [emails: 10]

Environment-Specific Configuration

Development

# config/dev.exs
config :ash_dispatch,
  base_url: "http://localhost:4000"

# Use local email adapter
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Local

Test

# config/test.exs
config :ash_dispatch,
  base_url: "http://localhost:4002"

# Use test adapter
config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Test

Production

# config/runtime.exs
config :ash_dispatch,
  base_url: System.get_env("APP_URL") || "https://app.example.com"

config :my_app, MyApp.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")

Configuration Validation

AshDispatch validates configuration at compile time and runtime. You'll see warnings if:

  • Email backend configured but Swoosh not available
  • User resource configured but not found
  • Notification resource missing required attributes

Broadcast Transport

The :broadcast transport provides lightweight PubSub broadcasting for real-time events that don't need receipt tracking or notification records.

Admin Channel Topic

Configure the admin/firehose PubSub topic:

config :ash_dispatch,
  admin_channel_topic: "admin:firehose"

Default: "admin:firehose"

How It Works

  • User audience: Broadcasts to "{channel_topic}:{user_id}" — same topic prefix as :in_app transport
  • Admin audience: Broadcasts to the admin_channel_topic
  • No receipts: Skips DeliveryReceipt creation for zero DB overhead — suitable for high-frequency streaming events
  • Config-driven: Uses pubsub_module and channel_topic from your existing config

Example

dispatch do
  event :chat_chunk,
    trigger_on: :manual,
    channels: [[transport: :broadcast, audience: :user]],
    metadata: [category: :streaming]
end

Dispatch manually from your pipeline:

AshDispatch.Dispatcher.dispatch("my_resource.chat_chunk", %{
  user: %{id: user_id},
  content: chunk_text,
  session_id: session_id
})

Next Steps