Integrating AshDispatch into Your Application

View Source

After completing the Getting Started tutorial, you have events triggering from your resources. This guide covers the essential integration steps to make AshDispatch production-ready in your application.

Overview

AshDispatch provides base resources (Notification.Base, DeliveryReceipt.Base) that you extend in your app. This pattern allows you to:

  • Add your own User relationship
  • Store data in your database (not ETS)
  • Add custom policies and validations
  • Expose actions via RPC to your frontend

Time estimate: 15-30 minutes for initial setup

Prerequisites


Step 1: Create Your Notification Resource

AshDispatch's Notification.Base provides all attributes, actions, and counter broadcasting. You extend it to add your User relationship.

1.1 Create the resource file

# lib/my_app/notifications/notification.ex
defmodule MyApp.Notifications.Notification do
  @moduledoc """
  In-app notifications for users.
  Extends AshDispatch.Resources.Notification.Base with User relationship.
  """

  use AshDispatch.Resources.Notification.Base,
    repo: MyApp.Repo,
    domain: MyApp.Notifications,
    extensions: [AshTypescript.Resource]  # Optional - add if using TypeScript

  # TypeScript type configuration (requires AshTypescript.Resource extension above)
  typescript do
    type_name("Notification")
  end

  # Add your User relationship
  relationships do
    belongs_to :user, MyApp.Accounts.User do
      source_attribute :user_id
      destination_attribute :id
      allow_nil? false
      public? true
      define_attribute? false  # Base already defines user_id
    end
  end

  # Optional: Add policies
  # policies do
  #   policy action_type(:read) do
  #     authorize_if expr(user_id == ^actor(:id))
  #   end
  # end
end

1.2 Create the domain

# lib/my_app/notifications.ex
defmodule MyApp.Notifications do
  use Ash.Domain,
    otp_app: :my_app,
    extensions: [AshTypescript.Rpc]  # If using RPC

  # RPC actions for frontend (optional)
  typescript_rpc do
    resource MyApp.Notifications.Notification do
      rpc_action :list_notifications, :list_for_user
      rpc_action :mark_notification_as_read, :mark_as_read
      rpc_action :mark_all_notifications_as_read, :mark_all_as_read
    end
  end

  resources do
    resource MyApp.Notifications.Notification do
      # Domain functions
      define :get_notification, action: :get, args: [:id], get?: true
      define :list_notifications, action: :list_for_user, args: [:user_id]
      define :mark_notification_as_read, action: :mark_as_read
      define :mark_all_notifications_as_read, action: :mark_all_as_read, args: [:user_id]
    end
  end
end

1.3 Generate the migration

mix ash.codegen add_notifications_table

This creates a migration for the notifications table with all required columns.

1.4 Configure AshDispatch to use your resource

# config/config.exs
config :ash_dispatch,
  notification_resource: MyApp.Notifications.Notification

Step 2: Create Your DeliveryReceipt Resource (Optional)

If you want delivery tracking stored in your database (recommended for production):

2.1 Create the resource file

# lib/my_app/deliveries/delivery_receipt.ex
defmodule MyApp.Deliveries.DeliveryReceipt do
  @moduledoc """
  Tracks all notification deliveries (email, in-app, webhooks).
  """

  use AshDispatch.Resources.DeliveryReceipt.Base,
    repo: MyApp.Repo,
    domain: MyApp.Deliveries,
    notification_resource: MyApp.Notifications.Notification,  # Required!
    extensions: [AshTypescript.Resource]  # Optional - add if using TypeScript

  # TypeScript type configuration (requires AshTypescript.Resource extension above)
  typescript do
    type_name("DeliveryReceipt")
  end

  # Add your User relationship
  relationships do
    belongs_to :user, MyApp.Accounts.User do
      source_attribute :user_id
      destination_attribute :id
      allow_nil? true
      public? true
    end
  end
end

2.2 Create the domain

# lib/my_app/deliveries.ex
defmodule MyApp.Deliveries do
  use Ash.Domain, otp_app: :my_app

  resources do
    resource MyApp.Deliveries.DeliveryReceipt do
      define :list_receipts, action: :read
      define :get_receipt, action: :read, get_by: [:id]
    end
  end
end

2.3 Generate the migration

mix ash.codegen add_delivery_receipts_table

2.4 Configure AshDispatch

# config/config.exs
config :ash_dispatch,
  notification_resource: MyApp.Notifications.Notification,
  delivery_receipt_resource: MyApp.Deliveries.DeliveryReceipt

Step 3: Register Domains with Ash

Add your new domains to your application's Ash configuration:

# config/config.exs
config :my_app,
  ash_domains: [
    MyApp.Accounts,
    MyApp.Orders,
    MyApp.Notifications,  # Add this
    MyApp.Deliveries      # Add this if using custom DeliveryReceipt
  ]

Step 4: Setup and Templates

Before generating events, set up the directory structure and layouts:

4.1 Initial Setup

# Creates layouts and directory structure
mix ash_dispatch.setup

This creates:

priv/ash_dispatch/
 layouts/
    email.html.heex    # Customize with your brand
    email.text.eex
 templates/             # Event templates go here

4.2 Customize Your Layout

Edit priv/ash_dispatch/layouts/email.html.heex with your branding:

  • Logo and header styling
  • Brand colors
  • Footer with contact info

All event templates will automatically use this layout - they only need event-specific content.

4.3 Define Events in Resource DSL

Events are defined in your resource using the dispatch do block:

# lib/my_app/orders/product_order.ex
defmodule MyApp.Orders.ProductOrder do
  use Ash.Resource,
    extensions: [AshDispatch.Resource]

  dispatch do
    event :created do
      module MyApp.Orders.Events.Created.Event
      trigger_on [:create]
      data_key :order

      channels do
        channel :in_app, :user,
          title: "Order skapad",
          message: "Din order har registrerats"
        channel :email, :user,
          subject: "Din order har skapats"
      end
    end
  end
end

Then generate missing templates and event modules:

mix ash_dispatch.gen
# Or: mix ash.codegen

See Generator for full documentation.


Step 5: Frontend Integration

AshDispatch provides a complete TypeScript SDK for real-time updates.

5.1 Generate the SDK

# Generate everything: SDK, counter types, hooks
mix ash_dispatch.gen

# Also generate RPC types
mix ash_typescript.codegen

This creates:

  • lib/ash-dispatch/ - Complete SDK with hooks and stores
  • lib/counters.ts - Type-safe counter definitions
  • ash_rpc.ts - RPC action types

5.2 Use the Generated Hooks

import { useCounter, useChannel, useCounterStore } from '@/lib/ash-dispatch'

// Single counter value
function CartBadge() {
  const cartItems = useCounter('cart_items')
  return <Badge count={cartItems} />
}

// All counters
function Dashboard() {
  const counters = useCounterStore(state => state.counters)
  return (
    <div>
      <span>Orders: {counters.pending_orders}</span>
      <span>Notifications: {counters.unread_notifications}</span>
    </div>
  )
}

// Channel connection
function AppProvider({ children }) {
  const channel = useUserChannel(userId)

  useChannel({
    channel,
    onNotification: (notification) => {
      toast.success(notification.title)
    }
  })

  return <>{children}</>
}

See Generator for complete SDK documentation.

5.3 RPC Actions

Important: AshTypescript generates specific signatures. Check the generated code for exact parameter names.

// ❌ Common mistake - wrong parameter names
await markNotificationAsRead({ id: notificationId })

// ❌ Common mistake - empty fields array
await markNotificationAsRead({
  primaryKey: notificationId,
  fields: [],  // ERROR: "Fields array cannot be empty"
  headers: buildCSRFHeaders()
})

// ✅ Correct - include at least one field
await markNotificationAsRead({
  primaryKey: notificationId,  // Update actions use primaryKey
  fields: ["id", "read"],      // Must include at least one field!
  headers: buildCSRFHeaders()
})

// ✅ Generic actions use input wrapper
await markAllNotificationsAsRead({
  input: { userId: user.id },   // Arguments wrapped in input
  headers: buildCSRFHeaders()
})

5.4 Example React hook

import {
  markNotificationAsRead,
  markAllNotificationsAsRead,
  buildCSRFHeaders
} from '@/lib/ash_rpc'

export function useNotifications() {
  const markAsRead = async (notificationId: string) => {
    await markNotificationAsRead({
      primaryKey: notificationId,
      fields: ["id", "read"],  // Must include at least one field
      headers: buildCSRFHeaders()
    })
  }

  const markAllAsRead = async (userId: string) => {
    await markAllNotificationsAsRead({
      input: { userId },
      headers: buildCSRFHeaders()
    })
  }

  return { markAsRead, markAllAsRead }
}

Step 6: Run Migrations

mix ash.migrate

Complete Configuration Example

Here's a complete config.exs with all AshDispatch settings:

# config/config.exs
config :ash_dispatch,
  # Required for layout discovery
  otp_app: :my_app,

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

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

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

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

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

  # Custom template formats (optional)
  # Add new formats for future transports like SMS, Discord, Slack
  format_extensions: %{
    # Defaults (already included):
    # html: "html.heex",
    # text: "text.eex",

    # Custom formats:
    markdown: "md.eex"     # For Discord/Slack messages
    # json: "json.eex"     # For webhook payloads
  }

Verification Checklist

After completing setup, verify everything works:

☐ Database tables exist

mix ash.migrate
psql -d my_app_dev -c "\dt notifications"
psql -d my_app_dev -c "\dt delivery_receipts"

☐ Resources compile

mix compile --warnings-as-errors

☐ TypeScript types generate

mix ash_typescript.codegen
# Check that notification functions exist in generated file

☐ Test an event

# In IEx
user = MyApp.Accounts.get_user!(user_id)
ticket = MyApp.Tickets.create_ticket!(%{title: "Test", user_id: user.id})

# Check notification was created
MyApp.Notifications.list_notifications(user.id)

☐ Test RPC from frontend

// Should see database query in server logs
await markNotificationAsRead({
  primaryKey: notificationId,
  fields: [],
  headers: buildCSRFHeaders()
})

Common Issues

"No function clause matching"

Cause: Wrong parameter names in RPC call.

Solution: Check generated TypeScript for exact signature:

  • Update actions: { primaryKey, fields }
  • Generic actions: { input: { ... } }
  • Read actions: { args: { ... } } or direct parameters

"Fields array cannot be empty"

Cause: RPC call has fields: [] for an update/create action.

Solution: Include at least one field:

await markNotificationAsRead({
  primaryKey: notificationId,
  fields: ["id", "read"],  // Not empty!
  headers: buildCSRFHeaders()
})

"Notification not created"

Cause: notification_resource not configured.

Solution:

config :ash_dispatch,
  notification_resource: MyApp.Notifications.Notification

"relation does not exist"

Cause: Migration not run.

Solution:

mix ash.codegen add_notifications_table
mix ash.migrate

Counter updates not broadcasting

Cause: counter_broadcast_fn not configured.

Solution: See Phoenix Integration for channel setup.


Why Base Modules?

You might wonder why AshDispatch uses this "inheritance" pattern instead of just providing resources directly.

The challenge: AshDispatch can't know your app's User module at compile time.

Alternatives considered:

  1. Configure user_id as string - Loses type safety and relationships
  2. Use polymorphic associations - Complex and not idiomatic Ash
  3. Require manual resource creation - Too much boilerplate

The Base pattern gives you:

  • ✅ All standard attributes, actions, and counters pre-defined
  • ✅ Full type safety with your User relationship
  • ✅ Ability to add custom policies, calculations, validations
  • ✅ Database storage in your schema
  • ✅ No manual attribute copying

It's a few lines of setup for significant flexibility.


Next Steps