What is AshDispatch?

View Source

The Problem: Notification Sprawl

In most applications, notifications start simple but quickly become complex:

# Started simple...
def create_order(params) do
  with {:ok, order} <- Orders.create(params) do
    Email.send_order_confirmation(order)
    {:ok, order}
  end
end

# Then grew organically...
def create_order(params) do
  with {:ok, order} <- Orders.create(params) do
    # Email to customer
    Email.send_order_confirmation(order.user)

    # Email to admin
    Email.send_admin_notification(order)

    # In-app notification
    Notifications.create_for_user(order.user, "Order created")

    # Discord webhook for team
    Discord.post_webhook("New order: #{order.number}")

    # Update dashboard counters
    PubSub.broadcast("update_order_count")

    {:ok, order}
  end
end

Problems with this approach:

  • ❌ Notification logic scattered across codebase
  • ❌ Hard to test (sends real emails in tests!)
  • ❌ No user preferences (can't opt out)
  • ❌ No delivery tracking (did it send?)
  • ❌ No retry logic (fails silently)
  • ❌ Not reusable (copy-paste for each resource)

The Solution: Event-Driven Dispatch

AshDispatch moves notification logic into declarative resource definitions:

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

  actions do
    create :create_from_cart do
      accept [:user_id, :items]
      # Just create the order - AshDispatch handles notifications!
    end
  end

  dispatch do
    event :created,
      trigger_on: :create_from_cart,
      channels: [
        [transport: :email, audience: :user],
        [transport: :email, audience: :admin],
        [transport: :in_app, audience: :user],
        [transport: :discord, audience: :team, webhook_url: "..."]
      ],
      content: [
        subject: "Order #{{order_number}} created",
        notification_title: "Order Created",
        notification_message: "Your order is being processed"
      ]
  end
end

Benefits:

  • ✅ All notification logic in one place
  • ✅ Declarative and testable
  • ✅ Automatic user preference checking
  • ✅ Full delivery tracking with receipts
  • ✅ Automatic retries on failure
  • ✅ Reusable pattern across all resources

Core Concepts

1. Events

Events represent things that happen in your system:

  • Order created
  • Ticket resolved
  • User registered
  • Payment failed

Events are defined in resources and automatically triggered by actions.

2. Transports

Transports are delivery mechanisms:

  • :email - Send emails (via Swoosh)
  • :in_app - Create in-app notifications
  • :discord - Post to Discord webhooks
  • :slack - Post to Slack webhooks
  • :sms - Send SMS messages
  • :webhook - Custom HTTP webhooks

3. Channels

Channels combine a transport with an audience and timing:

[transport: :email, audience: :user, delay: 300]

This means: "Send an email to the user, 5 minutes from now"

Channels can also be grouped for deduplication when audiences overlap:

channels: [
  [transport: :in_app, audience: :stakeholders, deduplicate_group: :internal],
  [transport: :in_app, audience: :admin, deduplicate_group: :internal]
]

Users matching multiple audiences in the same group receive only one notification.

4. Delivery Receipts

Every dispatched event creates a DeliveryReceipt Ash resource record:

%AshDispatch.Resources.DeliveryReceipt{
  id: "a1b2c3d4...",
  event_id: "product_order.created",
  transport: :email,
  audience: :user,
  recipient: "user@example.com",
  status: :sent,
  sent_at: ~U[2025-01-16 10:30:00Z],
  # Full content stored for audit trail and retries
  subject: "Order #1234 created",
  body_html: "<h1>Order Created</h1>...",
  body_text: "Order #1234 created...",
  content: %{subject: "Order #1234 created", ...},
  # Retry tracking
  retry_count: 0,
  # Provider tracking
  provider_id: "msg_abc123",
  provider_response: %{...}
}

Receipt Features:

  • ✅ Full Ash resource with state machine
  • ✅ ETS data layer (override with Postgres in your app)
  • ✅ State transitions: pending → scheduled → sending → sent/failed
  • ✅ Automatic retry counting
  • ✅ Provider response tracking
  • ✅ Query receipts: DeliveryReceipt |> Ash.Query.filter(status == :failed)

Receipts enable:

  • Audit trails ("When did we send this?")
  • Delivery tracking ("Did it fail?")
  • Retry logic ("Try again in 15 minutes")
  • Analytics ("How many emails sent this month?")
  • Debugging ("What content was sent?")

5. User Preferences

Users can opt out of configurable notifications:

%UserEmailPreferences{
  user_id: user.id,
  order_updates: false,  # User opted out
  ticket_updates: true
}

AshDispatch automatically checks preferences before delivering when configured.

6. Real-Time Counters

Counters broadcast live updates to frontend UIs when data changes:

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

  # Admin sees ALL pending orders (system-wide)
  counter :admin_pending_orders,
    trigger_on: [:create, :complete],
    query_filter: [status: :pending],
    audience: :admin,
    authorize?: false,
    invalidates: ["orders"]
end

Counter Features:

  • ✅ Auto-broadcast when actions trigger
  • ✅ Three-layer control: audience, authorization, scoping
  • scope expressions for flexible filtering (regional, team, etc.)
  • ✅ Frontend query invalidation
  • ✅ TypeScript type generation

Use cases:

  • Cart item counts
  • Pending order badges
  • Unread notification counts
  • Admin dashboard metrics

How It Works

sequenceDiagram
    participant Action
    participant Transformer
    participant DispatchEvent
    participant Receipt
    participant Transport

    Note over Action: Resource action executes
    Transformer->>Action: Injects DispatchEvent change
    Action->>DispatchEvent: Calls after action success
    DispatchEvent->>Receipt: Creates DeliveryReceipt
    Receipt->>Transport: Dispatches to transport
    alt In-App
        Transport->>Notification: Creates notification
        Notification-->>Receipt: Updates status: sent
    else Email
        Transport->>Oban: Enqueues job
        Oban-->>Receipt: Updates status: scheduled
        Oban->>Email: Sends email
        Email-->>Receipt: Updates status: sent
    end

Step-by-Step

  1. Compile Time: Transformer injects DispatchEvent change into action
  2. Runtime: Action executes successfully
  3. After Success: DispatchEvent change runs
  4. Create Receipt: DeliveryReceipt Ash resource created with full content (status: :pending)
  5. Dispatch: For each channel:
    • In-App: Create Notification, update receipt to :sent immediately (via Ash changeset)
    • Email: Enqueue Oban job, update receipt to :scheduled (via Ash changeset)
    • Webhook: Enqueue Oban job, update receipt to :scheduled (via Ash changeset)
  6. Async Delivery: Oban workers send emails/webhooks and update receipt status
  7. Retry on Failure: Failed receipts automatically retry via the retry worker

Note: Email is sent through a pluggable backend. The default AshDispatch.EmailBackend.Mock logs instead of sending — configure the Swoosh backend (AshDispatch.EmailBackend.Swoosh) to deliver real mail. See Configuration.

Progressive Complexity

Level 1: Simple Inline Events

Perfect for straightforward notifications:

dispatch do
  event :created,
    trigger_on: :create,
    channels: [[transport: :email, audience: :user]],
    content: [subject: "Welcome!"]
end

Level 2: Multiple Channels & Timing

Add complexity as needed:

dispatch do
  event :created,
    trigger_on: :create,
    channels: [
      [transport: :in_app, audience: :user],
      [transport: :email, audience: :user, delay: 300],
      [transport: :email, audience: :admin]
    ],
    content: [
      subject: "Order #{{order_number}} created",
      notification_title: "Order Created"
    ],
    metadata: [
      notification_type: :success,
      user_configurable: true
    ]
end

Level 3: Callback Modules

When you need custom logic:

dispatch do
  event :created,
    trigger_on: :create,
    module: MyApp.Events.Orders.Created
end
defmodule MyApp.Events.Orders.Created do
  @behaviour AshDispatch.Event

  @impl true
  def channels(_context) do
    # Dynamic channel logic
    if weekend?() do
      [[transport: :in_app, audience: :user]]
    else
      [[transport: :email, audience: :user]]
    end
  end

  @impl true
  def recipients(context, channel) do
    # Custom recipient logic
    case channel.audience do
      :user -> [context.data.order.user]
      :admin -> fetch_admins_on_duty()
    end
  end

  # ... more callbacks
end

Comparison with Alternatives

Manual Event Handling

# Before: Scattered logic
def create_order(params) do
  {:ok, order} = Orders.create(params)
  Email.send(order.user, "Order created")
  Notifications.create(order.user, "Order created")
  Discord.post("New order")
  {:ok, order}
end
# After: Declarative
dispatch do
  event :created, trigger_on: :create, ...
end

Phoenix PubSub

PubSub is great for real-time updates, but doesn't handle:

  • Delivery tracking
  • Retries
  • User preferences
  • Multiple transports
  • Template rendering

AshDispatch complements PubSub - use both!

Swoosh Directly

Swoosh is the email transport, but doesn't provide:

  • Multi-transport support
  • Declarative DSL
  • Delivery receipts
  • Retry logic
  • User preferences

AshDispatch uses Swoosh as a transport layer.

When to Use AshDispatch

Good Fit:

  • ✅ User-facing notifications (emails, in-app, SMS)
  • ✅ Admin alerts and reports
  • ✅ Webhook notifications to external systems
  • Real-time counters (cart items, pending orders, unread notifications)
  • ✅ Audit trails needed
  • ✅ User preference management required
  • ✅ Multiple delivery channels

Not a Fit:

  • ❌ High-throughput event streaming (use event sourcing)
  • ❌ Complex workflows (use Oban Pro workflows)
  • ❌ General PubSub (use Phoenix.PubSub - AshDispatch is for notifications/counters)

Next Steps

  1. Getting Started Tutorial - Build your first event
  2. App Integration - Set up custom resources, database, and RPC
  3. Phoenix Integration - Real-time channels and frontend

Implementation Status

AshDispatch is actively being developed. Here's the current status:

✅ Complete

  • Resource Extension - Define events and counters in resources via DSL
  • Event Validation - Compile-time validation of event configuration
  • Change Injection - Automatic change injection via transformers
  • DeliveryReceipt Resource - Full Ash resource with state machine
  • Receipt Persistence - ETS data layer (override with Postgres)
  • State Tracking - Receipt status: pending → scheduled → sending → sent/failed
  • Info Module - Query events: Info.events(Resource), Info.events_for_action(Resource, :create)
  • InApp Transport - Real notification records with PubSub support
  • Email Transport - Oban job enqueueing with Swoosh delivery
  • Error Handling - Graceful failures don't break actions
  • Recipient Resolution - Config-based admin/user lookup with Ash introspection
  • Template System - HEEx template rendering with layouts
  • Counter Broadcasting - Real-time counter updates via Phoenix Channels
  • Counter DSL - authorize?, scope, user_id_path for flexible scoping
  • Retry System - Automatic retry cron job for failed deliveries

📋 Planned

  • Remaining Transports - Slack, SMS implementations
  • Migration Guide - Converting existing event modules to AshDispatch

Data Layer Flexibility

AshDispatch uses ETS by default (in-memory), perfect for:

  • ✅ Development and testing
  • ✅ Standalone extensions
  • ✅ Fast iteration

For production, override with Postgres in your app:

# In your app's DeliveryReceipt resource
defmodule MyApp.Deliveries.DeliveryReceipt do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer
    
  # Inherit attributes from AshDispatch.Resources.DeliveryReceipt
  # Add your own relationships, policies, calculations, etc.
end

Learn More