Command Middleware
View SourceAshCommanded provides a middleware system that allows you to intercept and modify commands before they are dispatched, as well as process the results afterward. This concept is similar to Commanded's middleware functionality but adapted for the Ash framework context. This is useful for implementing cross-cutting concerns such as:
- Validation
- Logging
- Authentication and authorization
- Rate limiting
- Metrics and monitoring
- Auditing
- Parameter transformation
- Error handling and standardization
Middleware Concepts
Middleware Chain: Middleware is applied in sequence, with each middleware having the opportunity to:
- Inspect and modify the command
- Provide additional context
- Short-circuit the chain by returning an error
- Process the result after command execution
Middleware Levels: You can specify middleware at three different levels:
- Global (application-wide)
- Resource-level (applies to all commands in a resource)
- Command-level (applies to a specific command)
Middleware Context: Middleware can pass information to each other using a context map.
Using Built-in Middleware
AshCommanded includes several built-in middleware components that you can use immediately:
LoggingMiddleware
Logs information about commands and their results:
defmodule MyApp.User do
use Ash.Resource,
extensions: [AshCommanded.Commanded.Dsl]
commanded do
commands do
# Resource-level middleware - applies to all commands
middleware [AshCommanded.Commanded.Middleware.LoggingMiddleware]
# Command-specific middleware with options
command :register_user do
fields [:id, :name, :email]
middleware [
{AshCommanded.Commanded.Middleware.LoggingMiddleware, level: :debug}
]
end
end
end
end
Configuration options:
level
: The log level to use (:debug
,:info
,:warn
,:error
). Defaults to:info
.error_level
: The log level to use for errors. Defaults to:error
.
ValidationMiddleware
Validates commands before they are dispatched:
defmodule MyApp.User do
use Ash.Resource,
extensions: [AshCommanded.Commanded.Dsl]
commanded do
commands do
command :register_user do
fields [:id, :name, :email, :age]
middleware [
{AshCommanded.Commanded.Middleware.ValidationMiddleware,
# Require specific fields
required: [:name, :email],
# Validate field formats
format: [
email: ~r/@/,
name: ~r/^[a-zA-Z\s]+$/
],
# Custom validation function
validate: fn command ->
if command.age && command.age < 18 do
{:error, "User must be at least 18 years old"}
else
:ok
end
end
}
]
end
end
end
end
Configuration options:
required
: List of fields that must be present and non-nilformat
: Map of field names to regular expressions for format validationvalidate
: Custom validation function that takes a command and returns:ok
or{:error, reason}
validations
: List of validations to apply (combines the above options)
Creating Custom Middleware
You can create your own middleware by implementing the AshCommanded.Commanded.Middleware.CommandMiddleware
behaviour or by using AshCommanded.Commanded.Middleware.BaseMiddleware
as a starting point. This is conceptually similar to how you would implement middleware in Commanded:
defmodule MyApp.AuthorizationMiddleware do
use AshCommanded.Commanded.Middleware.BaseMiddleware
@impl true
def before_dispatch(command, context, next) do
# Extract user from context
user = Map.get(context, :current_user)
# Check authorization
if authorized?(command, user) do
# Continue with the command
next.(command, context)
else
# Deny the command
{:error, :unauthorized}
end
end
@impl true
def after_dispatch({:ok, result} = success, _command, _context) do
# Process successful result
success
end
def after_dispatch({:error, reason} = error, _command, _context) do
# Process error result
error
end
# Helper function for authorization check
defp authorized?(command, user) do
# Your authorization logic here...
true
end
end
Global Middleware Configuration
You can specify global middleware that applies to all commands in your application using the application configuration:
# In config/config.exs
config :ash_commanded, :global_middleware, [
AshCommanded.Commanded.Middleware.LoggingMiddleware,
{MyApp.AuthorizationMiddleware, role_check: true}
]
Middleware Context
Middleware can use the context to share information:
defmodule MyApp.TimingMiddleware do
use AshCommanded.Commanded.Middleware.BaseMiddleware
@impl true
def before_dispatch(command, context, next) do
# Add timestamp to context
context_with_time = Map.put(context, :start_time, System.monotonic_time())
# Call next middleware with updated context
next.(command, context_with_time)
end
@impl true
def after_dispatch(result, _command, context) do
# Calculate elapsed time
start_time = Map.get(context, :start_time)
elapsed = System.convert_time_unit(
System.monotonic_time() - start_time,
:native,
:millisecond
)
# Log timing information
Logger.info("Command processed in #{elapsed}ms")
result
end
end
Middleware Order
Middleware is applied in the following order:
- Global middleware (from application config)
- Resource-level middleware
- Command-specific middleware
Within each level, middleware is applied in the order it's defined.
Best Practices
- Keep Middleware Focused: Each middleware should have a single responsibility
- Use Context for Communication: Share data between middleware using the context map
- Handle Errors Gracefully: Make sure to handle errors appropriately in
after_dispatch
- Log Debugging Information: Include helpful log messages for debugging
- Test Middleware in Isolation: Write unit tests for your middleware
- Consider Performance: Keep middleware lightweight to avoid impacting command processing time