Architecture & Centralized Modules
View SourceThis guide explains AshDispatch's internal architecture and the centralized modules that ensure consistent behavior throughout the library.
Design Principles
AshDispatch follows these key principles:
- Single Source of Truth - Each piece of logic lives in one place
- Safe Callback Execution - All event callbacks are called with error handling
- Consistent Defaults - Default values defined in one place, not scattered
- Discoverability - All configuration options documented together
Centralized Modules
AshDispatch.Config
Purpose: Centralized configuration access for all AshDispatch settings.
Problem solved: Configuration lookups were scattered across 30+ files, each potentially with different default values.
Usage:
# Instead of:
Application.get_env(:ash_dispatch, :user_module)
# Use:
AshDispatch.Config.user_module()Key functions:
| Function | Purpose | Default |
|---|---|---|
user_module() | User resource for recipient resolution | nil |
domains() | Ash domains with dispatch-enabled resources | [] |
delivery_receipt_resource() | Receipt tracking resource | AshDispatch.Resources.DeliveryReceipt |
notification_resource() | In-app notification resource | AshDispatch.Resources.Notification |
default_from_email() | Fallback email sender | "noreply@example.com" |
email_backend() | Email sending backend | nil |
When to use:
- Always use
Configfunctions instead ofApplication.get_env(:ash_dispatch, ...)in runtime code - Exception: Mix tasks may need
Application.get_envfor compile-time access
AshDispatch.EventResolver
Purpose: Safe execution of event module callbacks with consistent error handling.
Problem solved: Direct callback invocations (module.recipients(context, channel)) could crash if the function wasn't exported or raised an error.
Usage:
# Instead of:
module.recipients(context, channel)
# Use:
EventResolver.recipients(module, context, channel)
# For custom callbacks with defaults:
EventResolver.call_if_exported(module, :custom_callback, [arg1, arg2], default: [])Key functions:
| Function | Purpose |
|---|---|
find_module(event_id) | Find event module by ID |
all_events() | Get all registered events |
call_if_exported(module, function, args, opts) | Safely call any callback |
sample_data(module) | Get preview data |
subject(module, context, channel) | Get email subject |
from(module, context, channel) | Get from address |
recipients(module, context, channel) | Get recipients |
prepare_template_assigns(module, context, channel) | Get template assigns |
Features:
- Checks
function_exported?before calling - Wraps calls in
try/rescuefor error safety - Returns sensible defaults when callback missing or fails
- Logs errors at debug level for troubleshooting
AshDispatch.ReceiptStatus
Purpose: Centralized receipt status management for all delivery workers.
Problem solved: Receipt status update logic was duplicated across multiple workers (email, webhook, etc.), each with slightly different implementations.
Usage:
alias AshDispatch.ReceiptStatus
# In a worker
def perform(%Oban.Job{args: args}) do
with {:ok, receipt} <- get_receipt(args["receipt_id"]),
{:ok, receipt} <- ReceiptStatus.mark_sending(receipt),
{:ok, response} <- do_delivery(receipt) do
ReceiptStatus.mark_sent(receipt, response)
:ok
else
{:error, reason} ->
ReceiptStatus.mark_failed(receipt, reason)
{:error, reason}
end
endStatus flow:
pending → sending → sent
↘ failed → failed_permanent (after max retries)
↘ scheduled (for async transports)
↘ skipped (user opted out)Key functions:
| Function | Purpose | Returns |
|---|---|---|
mark_sending(receipt) | Delivery in progress | {:ok, receipt} or {:error, changeset} |
mark_sent(receipt, response) | Delivery successful | Updated receipt (raises on failure) |
mark_failed(receipt, reason) | Delivery failed | Updated receipt (raises on failure) |
mark_skipped(receipt, reason) | Intentionally not sent | Updated receipt (raises on failure) |
mark_scheduled(receipt, opts) | Job enqueued | Updated receipt (raises on failure) |
AshDispatch.ChannelResolver
Purpose: Resolve channels for events from DSL or callback sources.
Problem solved: Channel resolution logic needed to work with both inline DSL definitions and event module callbacks.
Usage:
# Resolve all channels for an event
channels = ChannelResolver.resolve(event_id, event_module, context)
# With pre-loaded DSL channels
channels = ChannelResolver.resolve(event_id, event_module, context, dsl_channels: loaded_channels)
# Check if event has specific transport
has_email? = ChannelResolver.has_transport?(event_id, event_module, context, :email)Resolution priority:
- DSL-defined channels (inline in resource)
- Event module
channels/1callback - Empty list if neither defined
AshDispatch.Helpers.ResourceIntrospection
Purpose: Derive configuration automatically by introspecting Ash resources.
Problem solved: Counter and recipient resolution needed explicit user_id_path configuration for every resource. With introspection, we can derive this automatically from belongs_to relationships.
Usage:
alias AshDispatch.Helpers.ResourceIntrospection
# Derive user_id_path from resource relationships
ResourceIntrospection.derive_user_id_path(MyApp.Orders.Order)
#=> [:user_id]
# Find all user relationships on a resource
ResourceIntrospection.find_user_relationships(MyApp.Tickets.Ticket)
#=> [%{name: :user, source_attribute: :user_id}, ...]
# Check if audience is relationship-based or filter-based
ResourceIntrospection.is_relationship_audience?(:user)
#=> true (extract from record)
ResourceIntrospection.is_relationship_audience?(:admin)
#=> false (query all matching users)
# Build nested filter from path
ResourceIntrospection.build_user_filter([:cart, :user_id], "user-123")
#=> [cart: [user_id: "user-123"]]
# Parse audience config (used by both counters and events)
ResourceIntrospection.parse_audience_config([:user, {:admin, true}])
#=> {[:user], [admin: true]}
# Extract just the filter portion
ResourceIntrospection.extract_audience_filter([:user, {:admin, true}])
#=> [admin: true]Key functions:
| Function | Purpose |
|---|---|
derive_user_id_path(resource) | Find user_id field via belongs_to relationships |
derive_user_id_path(resource, audience) | Audience-aware derivation (auto-picks matching relationship) |
find_user_relationships(resource) | List all relationships to user module |
has_user_relationship?(resource) | Check if resource relates to user |
build_user_filter(path, user_id) | Build nested filter from path |
is_relationship_audience?(audience) | Check if audience extracts from record |
get_audience_relationship(audience) | Get relationship name for audience |
parse_audience_config(config) | Parse [:user, {:admin, true}] → {path, filter} |
extract_audience_filter(config) | Get just the filter portion from config |
Audience Pattern:
The library distinguishes between two audience types based on configuration format:
config :ash_dispatch,
audiences: [
:user, # Bare atom = relationship-based
:creator, # Extract from :creator relationship
{:admin, [:user, {:admin, true}]}, # Tuple = filter-based (query all admins)
{:partner, [:user, {:role, :partner}]}
]- Relationship-based (
:user): Extract recipient from record's relationship - Filter-based (
{:admin, ...}): Query all users matching the filter
Ambiguity Handling:
When a resource has multiple belongs_to relationships to the user module (e.g., :user, :started_by, :resolved_by), the introspection uses audience-aware disambiguation:
- If
audiencename matches a relationship name (e.g.,audience: :userand:userrelationship exists), auto-picks that relationship - Otherwise, logs a warning with guidance to add explicit
user_id_pathin counter DSL
# Ticket has :user, :started_by, :resolved_by, :closed_by relationships
# With audience: :user, auto-picks :user → [:user_id]
counter :open_tickets,
audience: :user, # Matches :user relationship - auto-derived!
...
# Admin counters bypass authorization for system-wide totals
counter :admin_open_tickets,
audience: :admin,
authorize?: false # Bypass policies - counts ALL ticketsThree-Layer Counter Control Model:
| Layer | Option | Purpose |
|---|---|---|
| Audience | audience: :admin | WHO receives the broadcast |
| Authorization | authorize?: false | WHAT records the actor CAN see (Ash policies) |
| Scoping | scope: expr(...) | WHAT subset we WANT to count |
This separation enables powerful, flexible combinations:
# User sees their own count (auto-derived scoping via user_id_path)
counter :my_orders, audience: :user
# Admin sees system-wide count (bypass authorization)
counter :all_orders, audience: :admin, authorize?: false
# Admin sees THEIR assigned tickets (custom scope expression)
counter :my_assigned_tickets,
audience: :admin,
scope: expr(assigned_to_id == ^actor(:id))
# Regional admin sees orders in their region
counter :regional_orders,
audience: :admin,
scope: expr(region == ^actor(:region))Nested Resources:
For resources without a direct user relationship (e.g., CartItem → Cart → User):
# CartItem only has :cart relationship, not :user
counter :cart_items,
audience: :user,
user_id_path: [:cart, :user_id] # Follow cart → user_idAshDispatch.ContentMap
Purpose: Handle mixed atom/string keys in content maps (common with PostgreSQL JSONB).
Problem solved: Content stored in JSONB comes back with string keys, but code often uses atom keys. Pattern like content["field"] || content[:field] was duplicated everywhere.
Usage:
import AshDispatch.ContentMap
# Get content regardless of key type
title = get_content(receipt.content, :title)
message = get_content(receipt.content, :message)
# Works with both:
# %{title: "Hello"}
# %{"title" => "Hello"}Where used:
Module Relationships
┌─────────────────────────────────────────────────────────────────┐
│ Dispatcher │
│ Entry point for all event dispatching │
│ Uses: Config, EventResolver, ChannelResolver │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌──────────────┐ ┌──────────────────────────┐
│ Transports │ │ Counters │ │ Helpers │
│ Email, InApp, etc │ │ Broadcasting │ │ ResourceIntrospection │
│ Uses: ContentMap │ │ Uses: Config │ │ CounterLoader, etc │
└───────────────────┘ └──────────────┘ └──────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Workers │
│ SendEmail, SendWebhook (Oban jobs) │
│ Uses: Config, ReceiptStatus │
└─────────────────────────────────────────────────────────────────┘Best Practices
For Library Contributors
Use Config for all configuration access:
# Good user_module = Config.user_module() # Avoid user_module = Application.get_env(:ash_dispatch, :user_module)Use EventResolver for callback execution:
# Good - safe with error handling subject = EventResolver.subject(module, context, channel) # Avoid - crashes if not exported subject = module.subject(context, channel)Use ReceiptStatus for status updates:
# Good - consistent status management ReceiptStatus.mark_sent(receipt, response) # Avoid - manual changeset creation receipt |> Ash.Changeset.for_update(:mark_sent, %{...}) |> Ash.update!()Use ContentMap for JSONB content:
# Good - handles both key types title = get_content(content, :title) # Avoid - misses string keys title = content[:title]
For Application Developers
When extending AshDispatch:
- Event modules should use the
AshDispatch.Eventbehaviour which handles defaults - Custom transports should use
ConfigandReceiptStatus - Custom workers should follow the pattern in
SendEmailandSendWebhook
Troubleshooting
Debug Configuration
To verify configuration is loaded correctly:
# In IEx
AshDispatch.Config.user_module()
AshDispatch.Config.domains()
AshDispatch.Config.email_backend()Debug Event Resolution
To verify event callbacks are working:
# Find an event
{:ok, module} = AshDispatch.EventResolver.find_module("orders.created")
# Check what callbacks are exported
AshDispatch.EventResolver.exports?(module, :recipients, 2)
AshDispatch.EventResolver.exports?(module, :subject, 2)
# Test callback with sample context
context = AshDispatch.EventResolver.build_sample_context("orders.created", module)
subject = AshDispatch.EventResolver.subject(module, context, %AshDispatch.Channel{})Debug Receipt Status
To check receipt status transitions:
# Get a receipt
receipt = AshDispatch.Config.delivery_receipt_resource() |> Ash.get!(receipt_id)
# Check current status
receipt.status
# Try a status transition (use in IEx only)
{:ok, updated} = AshDispatch.ReceiptStatus.mark_sending(receipt)Next Steps
- Configuration - All available configuration options
- Delivery Receipts - Receipt management and admin actions
- Getting Started - Build your first event
- Manual Dispatch - Event modules and preview