README
View SourceOverview
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:
DSL Extension: The
AshCommanded.Commanded.Dsl
module defines five main sections:commands
: Define commands that trigger state changesevents
: Define events that are emitted by commandsprojections
: Define how events affect the resource stateevent_handlers
: Define general purpose handlers for eventsapplication
: Configure Commanded application settings
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)
Transformers: The DSL uses transformers to generate code:
GenerateCommandModules
: Generates command structsGenerateEventModules
: Generates event structsGenerateProjectionModules
: Generates projection modulesGenerateProjectorModules
: Generates Commanded event handlers that process eventsGenerateEventHandlerModules
: Generates general purpose event handlersGenerateAggregateModule
: Generates aggregate module for CommandedGenerateDomainRouterModule
: Generates router module for each domainGenerateMainRouterModule
: Generates main application routerGenerateCommandedApplication
: Generates Commanded application with projector and handler supervision
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
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:
ECommerce.Commands.RegisterCustomer
- Command structECommerce.Events.CustomerRegistered
- Event structECommerce.Projections.CustomerRegistered
- Projection definitionECommerce.Projectors.CustomerProjector
- Commanded event handler for projectionsECommerce.EventHandlers.CustomerNotificationHandler
- General purpose event handlerECommerce.EventHandlers.CustomerAnalyticsTrackerHandler
- General purpose event handlerECommerce.CustomerAggregate
- Aggregate moduleECommerce.Store.Router
- Domain-specific router (if in an Ash.Domain)AshCommanded.Router
- Main application router
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:
- Guides for commands, events, projections, event handlers, and routers
- Guides for middleware, parameter handling, transactions, and context propagation
- API reference for all modules
- Cheatsheets for the DSL
Additional documentation files:
- Commands
- Events
- Projections
- Event Handlers
- Middleware
- Parameter Handling
- Transactions
- Context Propagation
- Error Handling
- Application
- Routers
- Snapshotting
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:
- A struct with the specified fields
- Typespecs for all fields
- Standard module documentation
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:
handler_name
- Custom function name for the handler clauseaction
- Specify a different Ash action to call (defaults to command name)autogenerate_handler?
- Set to false to disable handler generation
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:
- A struct with the specified fields
- Typespecs for all fields
- Standard module documentation
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:
action
- The Ash action to perform (:create
,:update
,:destroy
, etc.)changes
- Static map or function that returns the changes to applyautogenerate?
- Set to false to disable projection generation
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:
events
- List of event names this handler will respond toaction
- Action to perform when handling the event (atom or function)handler_name
- Override the auto-generated handler module namepublish_to
- PubSub topic(s) to publish the event toidempotent
- Whether the handler is idempotent (default: false)autogenerate?
- Set to false to disable handler generation
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:
- Subscribe to specific event types defined in your resource
- Process events using the Commanded event handling system
- 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:
- Configures the event store and other Commanded settings
- Includes the domain router
- Provides a supervisor for all projectors
- Can be added to your application's supervision tree
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.