Overview

AshCommanded is an Elixir library that provides Command Query Responsibility Segregation (CQRS) and Event-Sourcing (ES) patterns for the Ash Framework. It extends Ash resources with a Commanded DSL that enables defining commands, events, and projections. The extension relies on the excellent Commanded library. The Commanded Guides section explains the different concepts better than I could.

Special thanks to Ben Smith for the Commanded library and to [Barnabas J.] for letting me steal the library name.

Build and Test Commands

# Install dependencies
mix deps.get
# Compile the project
mix compile
# Run all tests
mix test
# Run specific test file
mix test path/to/test_file.exs:
# Run specific test with line number
mix test path/to/test_file.exs:42:
# Run tests with coverage
mix test --cover:

Architecture

AshCommanded is built as a DSL extension for Ash Framework resources using the Spark DSL library for its extensible DSL capabilities. Its main components are:

  1. DSL Extension: The AshCommanded.Commanded.Dsl module defines five main sections:

    • commands: Define commands that trigger state changes
    • events: Define events that are emitted by commands
    • projections: Define how events affect the resource state
    • event_handlers: Define general purpose handlers for events
    • application: Configure Commanded application settings
  2. Code Generation: The library dynamically generates Elixir modules:

    • Command modules (structs with typespecs)
    • Event modules (structs with typespecs)
    • Projection modules (with event handlers)
    • Projector modules (Commanded event handlers that apply projections)
    • Event handler modules (general purpose event subscribers)
    • Aggregate modules (for Commanded integration)
    • Router modules (for command dispatching)
    • Commanded application modules (with projector and handler supervision)
  3. Transformers: The DSL uses transformers to generate code:

  4. Advanced Features:

    • Command Middleware: Process commands through a pipeline of middleware functions
    • Parameter Transformation: Transform command parameters before action execution
    • Parameter Validation: Validate command parameters before action execution
    • Transactional Commands: Execute commands within database transactions
    • Context Propagation: Pass command, aggregate, and metadata context to actions
    • Error Standardization: Normalized error handling across the extension
  5. Verifiers: Validate DSL usage:

    • Command validation (names, fields, handlers, etc.)
    • Event validation (names, fields, etc.)
    • Projection validation (events, actions, changes, etc.)
    • Event handler validation (events, actions, etc.)

Usage Example

defmodule ECommerce.Customer do
use Ash.Resource,
extensions: [AshCommanded.Commanded.Dsl]
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :email, :string
attribute :status, :atom, constraints: [one_of: [:pending, :active]]
end
identities do
identity :unique_id, [:id]
end
actions do
defaults [:read]
create :register do
accept [:name, :email]
change {Ash.Changeset, :set_attribute, [:status, :pending]}
end
update :confirm_email do
accept []
change {Ash.Changeset, :set_attribute, [:status, :active]}
end
end
commanded do
commands do
command :register_customer do
fields([:id, :name, :email])
identity_field(:id)
action :register
end
command :confirm_email do
fields([:id])
identity_field(:id)
action :confirm_email
end
end
events do
event :customer_registered do
fields([:id, :name, :email])
end
event :email_confirmed do
fields([:id])
end
end
projections do
projection :customer_registered do
action(:create)
changes(%{
status: :pending
})
end
projection :email_confirmed do
action(:update_by_id)
changes(%{
status: :active
})
end
end
event_handlers do
handler :notification_handler do
events [:customer_registered]
action fn event, _metadata ->
ECommerce.Notifications.send_welcome_email(event.email)
:ok
end
end
handler :analytics_tracker do
events [:customer_registered, :email_confirmed]
action fn event, _metadata ->
ECommerce.Analytics.track(event)
:ok
end
end
end
end
end

This will generate:

Documentation

AshCommanded provides comprehensive documentation that can be generated locally:

# Install dependencies
mix deps.get
# Generate cheatsheet and docs
mix gen.docs

The documentation includes:

Additional documentation files:

Commands

Commands define the actions that can be performed on your resources. AshCommanded generates command modules as structs with typespecs.

commanded do
commands do
command :register_customer do
fields([:id, :name, :email])
identity_field(:id)
action :register
end
command :confirm_email do
fields([:id])
identity_field(:id)
action :confirm_email
end
end
end

Generated command modules include:

Example generated command:

defmodule ECommerce.Commands.RegisterCustomer do
@moduledoc """
Command for registering a new customer
"""
@type t :: %__MODULE__{
id: String.t(),
email: String.t(),
name: String.t(),
status: atom()
}
defstruct [:id, :email, :name, :status]
end

Command Handlers

Command handlers are modules that process commands and apply business logic. AshCommanded generates handler modules that invoke Ash actions.

defmodule AshCommanded.Commanded.CommandHandlers.CustomerHandler do
@behaviour Commanded.Commands.Handler
def handle(%ECommerce.Commands.RegisterCustomer{} = cmd, _metadata) do
Ash.run_action(ECommerce.Customer, :register, Map.from_struct(cmd))
end
def handle(%ECommerce.Commands.ConfirmEmail{} = cmd, _metadata) do
Ash.run_action(ECommerce.Customer, :confirm_email, Map.from_struct(cmd))
end
end

Handler options:

Middleware, Parameter Handling, and Transactions

AshCommanded provides advanced features for command processing:

Middleware

Command middleware allows you to intercept and modify commands before they are executed:

commanded do
commands do
# Apply middleware to all commands in this resource
middleware AuditLogger
middleware {Authorization, roles: [:admin]}
command :register_customer do
fields([:id, :name, :email])
# Command-specific middleware
middleware {RateLimiter, limit: 10}
end
end
end

Parameter Transformation and Validation

You can transform and validate command parameters before action execution:

command :create_order do
fields([:id, :items, :customer_id, :total])
transform_params do
map item_ids: :items
compute :timestamp, &DateTime.utc_now/0
cast :total, :decimal
end
validate_params do
validate :total, number: [greater_than: 0]
validate :items, present: true
end
end

Transaction Support

Execute commands within database transactions:

command :place_order do
fields [:id, :customer_id, :items]
# Use inline transaction options
in_transaction? true
repo MyApp.Repo
transaction_timeout 5000
transaction_isolation_level :serializable
# Or use block syntax
transaction do
enabled? true
repo MyApp.Repo
timeout 5000
isolation_level :read_committed
end
end

Context Propagation

Control how command context is passed to actions:

command :register_customer do
fields [:id, :name, :email]
# Context options
include_aggregate? true
include_command? true
include_metadata? true
context_prefix :cmd
static_context %{source: :registration_api}
end

Events

Events represent facts that have occurred in your system. AshCommanded generates event modules as structs with typespecs.

commanded do
events do
event :customer_registered do
fields([:id, :name, :email])
end
event :email_confirmed do
fields([:id])
end
end
end

Generated event modules include:

Example generated event:

defmodule ECommerce.Events.CustomerRegistered do
@moduledoc """
Event emitted when a customer is registered
"""
@type t :: %__MODULE__{
id: String.t(),
email: String.t(),
name: String.t(),
status: atom()
}
defstruct [:id, :email, :name, :status]
end

Aggregates and Events-Handlers

Aggregates process events and update state. AshCommanded generates aggregate modules for each resource. Each event that mutate state is handled by the Aggregate via an apply function that is automatically generated for you.

defmodule ECommerce.CustomerAggregate do
defstruct [:id, :email, :name, :status]
# Apply event to update the aggregate state
def apply(%__MODULE__{} = state, %ECommerce.Events.CustomerRegistered{} = event) do
%__MODULE__{
state |
id: event.id,
email: event.email,
name: event.name
}
end
def apply(%__MODULE__{} = state, %ECommerce.Events.EmailConfirmed{} = event) do
%__MODULE__{state | status: :active}
end
end

The aggregate maintains the current state by applying events in sequence. Each event handler updates specific fields based on the event data.

Projections

Projections define how events should update your read models. AshCommanded generates projection modules that handle specific event types.

commanded do
projections do
projection :customer_registered do
action(:create)
changes(%{
status: :pending
})
end
projection :email_confirmed do
action(:update_by_id)
changes(%{
status: :active
})
end
end
end

Projection options:

Event Handlers

Event handlers define how to respond to domain events with side effects, integrations, notifications, or other operations. Unlike projections which focus on updating read models, event handlers are for operations that don't necessarily affect resource state.

commanded do
event_handlers do
# Function-based handler for sending notifications
handler :welcome_notification do
events [:customer_registered]
action fn event, _metadata ->
ECommerce.Notifications.send_welcome_email(event.email)
:ok
end
end
# Handler with multiple events
handler :analytics_tracker do
events [:customer_registered, :email_confirmed]
action fn event, _metadata ->
ECommerce.Analytics.track(event)
:ok
end
end
# Handler using an Ash action
handler :external_system_sync do
events [:customer_registered]
action :sync_to_crm
idempotent true
end
# PubSub broadcasting handler
handler :event_broadcaster do
events [:customer_registered, :email_confirmed]
publish_to "customer_events"
end
end
end

Event handler options:

Generated event handler modules handle the specified events and execute the defined actions or functions:

defmodule ECommerce.EventHandlers.CustomerWelcomeNotificationHandler do
use Commanded.Event.Handler,
application: ECommerce.CommandedApplication,
name: "ECommerce.EventHandlers.CustomerWelcomeNotificationHandler"
def handle(%ECommerce.Events.CustomerRegistered{} = event, _metadata) do
ECommerce.Notifications.send_welcome_email(event.email)
:ok
end
end

Projectors

Projectors are Commanded event handlers that listen for domain events and update read models. AshCommanded automatically generates projector modules using the GenerateProjectorModules transformer. These projectors:

  1. Subscribe to specific event types defined in your resource
  2. Process events using the Commanded event handling system
  3. Apply changes to your resources via Ash actions (create, update, destroy)

For example, a generated projector might look like:

defmodule ECommerce.Projectors.CustomerProjector do
use Commanded.Projections.Ecto, name: "ECommerce.Projectors.CustomerProjector"
project(%ECommerce.Events.CustomerRegistered{} = event, _metadata, fn _context ->
Ash.Changeset.new(ECommerce.Customer, event)
|> Ash.Changeset.for_action(:create, %{status: :pending})
|> Ash.create()
end)
project(%ECommerce.Events.EmailConfirmed{} = event, _metadata, fn _context ->
Ash.Changeset.new(ECommerce.Customer, %{id: event.id})
|> Ash.Changeset.for_action(:update, %{status: :active})
|> Ash.update()
end)
# Functions to apply different action types
defp apply_action_fn(:create), do: &Ash.create/1
defp apply_action_fn(:update), do: &Ash.update/1
defp apply_action_fn(:destroy), do: &Ash.destroy/1
end

You can customize the projector name with the projector_name option or disable automatic generation with autogenerate?: false.

Router Usage

The generated routers allow dispatching commands to their appropriate handlers:

# Dispatch a command through the main router
command = %ECommerce.Commands.RegisterCustomer{id: "123", email: "customer@example.com", name: "John Doe"}
AshCommanded.Router.dispatch(command)

Commanded Application

The application section in the DSL allows configuring a Commanded application at the domain level:

defmodule ECommerce.Store do
use Ash.Domain, extensions: [AshCommanded.Commanded.Dsl]
resources do
resource ECommerce.Product
resource ECommerce.Customer
resource ECommerce.Order
end
commanded do
application do
otp_app :ecommerce
event_store Commanded.EventStore.Adapters.EventStore
include_supervisor? true
end
end
end

This generates a Commanded application module that:

Where are the Process Managers?

Process Managers in Commanded are responsible for coordinating one or more aggregates. They handle events and dispatch commands in response. This is very business logic specific and would be rather difficult to generate appropriately. It is suggested to write your Process Managers using Reactor instead, which is a library specifically designed for workflow orchestration in Elixir and works well with Commanded's event-driven architecture.