AshDispatch
Status: ๐ง Active Development - Extracting proven notification engine from Magasin into reusable Ash extension
AshDispatch is an event-driven notification and messaging system for Ash Framework. It provides a declarative DSL for defining events in your resources and automatically dispatching them across multiple transports (email, in-app notifications, broadcast/PubSub, Discord, Slack, webhooks, etc.).
Why AshDispatch?
Declarative Event Definitions
Define events directly in your resources using familiar Ash DSL patterns:
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
actions do
create :create_from_cart do
accept [:user_id]
# Your action logic...
end
end
dispatch do
event :created,
trigger_on: :create_from_cart,
priority: :standard,
channels: [
[transport: :in_app, audience: :user],
[transport: :email, audience: :user, delay: 300]
],
content: [
subject: "Order #{{order_number}} created",
notification_title: "Your order was created",
notification_message: "Order #{{order_number}} is being processed"
],
metadata: [
notification_type: :success
]
end
end
Key Features
- ๐ฏ Automatic Dispatch - Events are automatically triggered by resource actions
- ๐ฌ Multi-Transport - Email, in-app, Discord, Slack, SMS, webhooks out of the box
- ๐จ Priority Levels -
:urgent,:standard,:informationalโ consumers gate delivery timing by priority - โฐ Delayed Delivery - Schedule notifications for later delivery
- ๐ค User Preferences - Respect user notification preferences automatically
- ๐ Delivery Tracking - Full audit trail with delivery receipts
- ๐ Automatic Retries - Failed deliveries retry with exponential backoff
- ๐จ Template Interpolation -
{{variable}}syntax for dynamic content - ๐ Localization (i18n) - Multi-language templates with dynamic locale resolution from record fields
- ๐ Real-Time Counters - Declarative counter DSL with automatic Phoenix Channel broadcasting
- โก Zero-Config Helpers -
ChannelState,CounterLoader,NotificationLoaderfor Phoenix integration - โ๏ธ Generated TypeScript SDK - Ready-to-use React hooks, NotificationProvider, and NotificationBell components
- ๐ก Lightweight Broadcast -
:broadcasttransport for real-time PubSub events without receipt overhead - ๐ Extensible - Add custom transports and event modules
- ๐งช Test-Friendly - Factory integration for testing templates
- ๐ Smart Deduplication - Optional
deduplicate_groupto prevent duplicate notifications when audiences overlap
Tutorials
- Getting Started with AshDispatch - Basic event setup with inline DSL
- Manual Dispatch and Event Modules - Standalone events, manual triggers, and the two-path pattern
Topics
- What is AshDispatch?
- Localization (i18n) - Multi-language templates with dynamic locale resolution
- Phoenix Channel Integration - Zero-config helpers for real-time updates
- Counter Broadcasting - Declarative counter DSL with auto-discovery
- TypeScript SDK - Generated React hooks, components, and Zustand store
- Priority Levels - Urgent, standard, informational โ context-aware delivery gating
- User Preferences
- Recipient Resolution
- Configuration
- App Integration
- Code Generation
- Oban Configuration
Reference
Architecture Overview
graph TB
A[Resource Action] -->|triggers| B[Event]
B -->|creates| C[DeliveryReceipt]
C -->|dispatches to| D{Transport}
D -->|in_app| E[Notification]
D -->|email| F[Oban Job]
D -->|discord| G[Webhook]
F -->|sends| H[Email Service]
E -->|updates| I[User UI]
G -->|posts| J[Discord Channel]
Installation
Quick Start (Recommended)
mix igniter.install ash_dispatch
This creates all required resources, domains, and configuration automatically. Then run migrations:
mix ash.codegen add_ash_dispatch
mix ash.migrate
Manual Installation
def deps do
[
{:ash_dispatch, "~> 0.1.0"}
]
end
See Getting Started for complete manual setup instructions.
Production Configuration (Required for Releases)
Important: For production releases, you must enable template compilation. Without this, email templates won't load in production because lib/ source files aren't included in releases.
# config/prod.exs - REQUIRED for releases
config :ash_dispatch,
compile_templates: true
This copies templates to priv/ash_dispatch/templates/ during compilation, ensuring they're available at runtime. See Code Generation for details.
Quick Example
# 1. Add extension to resource
defmodule MyApp.Tickets.Ticket do
use Ash.Resource,
extensions: [AshDispatch.Resource]
# 2. Define events
dispatch do
# Inline-complete event (no module needed!)
# Works out of the box with just DSL configuration
event :created,
trigger_on: :create,
channels: [
[transport: :in_app, audience: :user]
],
content: [
notification_title: "Ticket Created",
notification_message: "{{user_name}} created ticket #{{id}}"
],
metadata: [
notification_type: :success
]
# Event with email requires templates
# Run `mix ash.codegen` to generate template stubs
event :assigned,
trigger_on: :assign,
channels: [
[transport: :in_app, audience: :user],
[transport: :email, audience: :user]
],
content: [
subject: "Ticket #{{id}} assigned to you",
notification_title: "Ticket Assigned"
]
# Complex event with custom module (your override)
event :escalated,
trigger_on: :escalate,
module: MyApp.Events.Tickets.Escalated
end
end
# 3. That's it! Events dispatch automatically when actions run
Ticket
|> Ash.Changeset.for_create(:create, %{title: "Bug report"})
|> Ash.create!()
# -> Automatically dispatches :created event
# -> Creates in-app notification for user (no templates needed!)
Hybrid Architecture
AshDispatch uses a hybrid approach where DSL configuration takes precedence, and generated modules provide fallbacks:
Define events in DSL - Configure channels, content, and metadata directly in your resource
Run
mix ash.codegen- Generates event modules with templates for each event (unless you providemodule:override)Hybrid dispatch - At runtime, DSL content is used first; module callbacks fill in gaps (templates, recipients, etc.)
Override with custom modules: Set module: to provide your own implementation for complex events (custom recipients, conditional sending, etc.)
Real-Time Counter Broadcasting
AshDispatch also provides automatic real-time counter updates with zero boilerplate:
# 1. Define counters in your resource
defmodule MyApp.Orders.ProductOrder do
use Ash.Resource,
extensions: [AshDispatch.Resource]
counters do
# User sees their own pending orders
counter :pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :pending_orders,
query_filter: [status: :pending],
audience: :user,
invalidates: ["orders"]
# Admins see ALL pending orders
counter :admin_pending_orders,
trigger_on: [:create, :complete, :cancel],
counter_name: :admin_pending_orders,
query_filter: [status: :pending],
audience: :admin,
invalidates: ["orders", "analytics"]
end
end
# 2. Configure broadcasting (one line!)
# config/config.exs
config :ash_dispatch,
counter_broadcast_fn: {MyAppWeb.UserChannel, :broadcast_counter}
# 3. Use helper in Phoenix Channel (one line!)
defmodule MyAppWeb.UserChannel do
alias AshDispatch.Helpers.ChannelState
def handle_info(:after_join, socket) do
# Loads ALL counters automatically - no manual queries!
initial_state = ChannelState.build(socket.assigns.user_id)
# => %{"counters" => %{"pending_orders" => 5}, "notifications" => [...]}
push(socket, "initial_state", initial_state)
{:noreply, socket}
end
end
# 4. That's it! Counters update in real-time automatically
Order.create!(%{status: :pending})
# -> Automatically broadcasts counter update to Phoenix Channel
# -> Frontend receives "counter_updated" event with new value
Zero configuration, automatic discovery, real-time updates!
See Counter Broadcasting and Phoenix Integration for complete guides.
Internal Architecture
AshDispatch uses centralized resolver modules to ensure consistent behavior across all dispatch pathways:
ChannelResolver
AshDispatch.ChannelResolver handles all channel resolution with consistent priority logic:
- DSL channels take precedence over module callbacks
- Converts various channel formats (DSL structs, maps, keyword lists) to
%Channel{}structs - Supports future
strategy: :mergeoption to combine both sources
# Used internally by Dispatcher, ManualTrigger, preview generators, etc.
channels = AshDispatch.ChannelResolver.resolve(event_id, event_module, context,
dsl_channels: event_config.channels
)
EventResolver
AshDispatch.EventResolver centralizes event module discovery and safe callback execution:
- Find event modules by ID
- Call callbacks with consistent error handling
- Build sample contexts for previews
# Find an event module
{:ok, module} = AshDispatch.EventResolver.find_module("orders.created")
# Safe callback execution with defaults
subject = AshDispatch.EventResolver.subject(module, context, channel, default: "No subject")
Naming
AshDispatch.Naming handles consistent filename and label generation for multi-audience templates:
# Generate audience-specific filenames
AshDispatch.Naming.filename("email", :admin, "summary", "html")
# => "email.admin.summary.html"
# Generate display labels
AshDispatch.Naming.label(:email, :admin, "summary")
# => "email (admin, summary)"
Design Principles
1. Resource-Centric
Events are defined in resources, just like actions, attributes, and relationships.
2. Progressive Complexity
Start with simple inline events. Upgrade to callback modules when you need custom logic.
3. Receipt-First Pattern
All deliveries create a receipt record before dispatch, enabling full audit trails and reliable retries.
4. Fail-Safe Defaults
User preferences, rate limiting, and delivery policies protect users from notification fatigue.
5. Framework Integration
Deep integration with Ash actions, Oban jobs, and the Ash ecosystem.
6. Centralized Logic
Channel resolution, event discovery, and naming all use centralized modules to ensure consistency and maintainability.
Development Status
Current: โ Resource extension complete and tested Next: ๐ง Runtime dispatcher and Domain extension
Contributing
This is currently being extracted from Magasin where it has been running in production. Once stabilized, it will be published as a standalone package.
License
MIT License - see LICENSE file for details.
Acknowledgments
Built on the excellent Ash Framework by Zach Daniel and the Ash community.
Inspired by patterns from AshStateMachine, AshAuthentication, and years of building notification systems.