Axn (Axn v0.2.0)

View Source

Axn - A clean, step-based DSL library for defining actions with parameter validation, authorization, telemetry, and custom business logic.

Axn provides a unified interface that works seamlessly across Phoenix Controllers and LiveViews, solving the limitation that Plugs only work with Plug.Conn.

Core Features

  • Step-based Pipeline: Define actions as a series of composable steps that execute in order
  • Parameter Validation: Built-in schema-based parameter casting and validation using Params
  • Authorization: Simple patterns for implementing custom authorization logic
  • Telemetry: Automatic telemetry events with configurable metadata for monitoring
  • Phoenix Integration: Works with both Controllers (Plug.Conn) and LiveViews (Phoenix.Socket)
  • Error Handling: Consistent, structured error formats across all operations
  • Composability: Steps can be reused across actions and even shared between modules

Key Concepts

Actions

Actions are named units of work that execute a series of steps in order. Each action automatically gets telemetry wrapping and error handling.

Steps

Steps are individual functions that take a context and either continue the pipeline or halt it. Steps follow a simple contract: (ctx, opts) -> {:cont, new_ctx} | {:halt, result}.

Context

An Axn.Context struct flows through the step pipeline, carrying request data, user information, and any step-added fields. Provides helper functions similar to Plug.Conn and Phoenix.Component.

Quick Example

defmodule MyApp.UserActions do
  use Axn

  action :create_user do
    step :cast_validate_params, 
         schema: %{email!: :string, name!: :string},
         validate: &__MODULE__.validate_params/2
    step :require_admin
    step :handle_create
  end

  def validate_params(changeset, %{action: :create_user} = ctx) do
    changeset
    |> validate_format(:email, ~r/@/)
    |> validate_admin_permissions(ctx.assigns.current_user)
  end

  def require_admin(ctx) do
    if admin?(ctx.assigns.current_user) do
      {:cont, ctx}
    else
      {:halt, {:error, :unauthorized}}
    end
  end

  def handle_create(ctx) do
    case Users.create(ctx.params) do
      {:ok, user} -> {:halt, {:ok, user}}
      {:error, reason} -> {:halt, {:error, reason}}
    end
  end

  defp admin?(user), do: user && user.role == "admin"
end

Advanced Validation Example

Use pattern matching with context to handle multiple actions in a single validation function:

defmodule MyApp.AuthActions do
  use Axn

  action :request_otp do
    step :cast_validate_params, 
         schema: %{phone!: :string, region: [field: :string, default: "US"]},
         validate: &__MODULE__.validate_params/2
    step :handle_request
  end

  action :verify_otp do
    step :cast_validate_params,
         schema: %{phone!: :string, code!: :string},
         validate: &__MODULE__.validate_params/2
    step :handle_verify
  end

  # Single validation function handling multiple actions
  def validate_params(changeset, %{action: :request_otp} = ctx) do
    params = Ecto.Changeset.apply_changes(changeset)

    changeset
    |> validate_phone_number(:phone, region: params.region)
    |> validate_user_permissions(ctx.assigns.current_user)
  end

  def validate_params(changeset, %{action: :verify_otp} = ctx) do
    changeset
    |> validate_phone_number(:phone, region: "US")
    |> validate_otp_format(:code)
    |> validate_rate_limit(ctx.assigns.current_user)
  end
end

Usage in Phoenix

# Phoenix Controller
def create(conn, params) do
  case MyApp.UserActions.run(:create_user, params, conn) do
    {:ok, user} -> json(conn, %{success: true, user: user})
    {:error, %{reason: :invalid_params, changeset: changeset}} ->
      json(conn, %{errors: format_changeset_errors(changeset)})
    {:error, reason} -> json(conn, %{error: reason})
  end
end

# Phoenix LiveView
def handle_event("submit", params, socket) do
  case MyApp.UserActions.run(:create_user, params, socket) do
    {:ok, user} -> {:noreply, assign(socket, :user, user)}
    {:error, reason} -> {:noreply, put_flash(socket, :error, reason)}
  end
end

Design Principles

  • Explicit over implicit: Each action clearly shows its execution flow
  • Composable: Steps should be reusable across actions and modules
  • Safe by default: Telemetry and error handling do not leak sensitive data
  • Simple to implement: Minimal macro magic, straightforward execution model
  • Easy to test: Steps are pure functions that are easy to unit test
  • Familiar patterns: Feels natural to experienced Elixir developers

See the module documentation for Axn.Context for details on the context struct and helper functions available in steps.

Summary

Functions

Sets up a module to use the Axn DSL for defining actions.

Defines an action with its steps.

Defines an action with options and its steps.

Defines a step within an action.

Functions

__using__(opts)

(macro)

Sets up a module to use the Axn DSL for defining actions.

Options

  • :metadata - Function that takes a context and returns a map of custom metadata for telemetry events. Optional.

Examples

defmodule MyApp.UserActions do
  use Axn

  action :create_user do
    # Action definition
  end
end

defmodule MyApp.UserActions do
  use Axn, metadata: &__MODULE__.telemetry_metadata/1

  def telemetry_metadata(ctx) do
    %{
      user_id: ctx.assigns.current_user && ctx.assigns.current_user.id,
      tenant: ctx.assigns.tenant && ctx.assigns.tenant.slug
    }
  end
end

action(name, list)

(macro)

Defines an action with its steps.

Examples

action :create_user do
  step :cast_validate_params, schema: %{name!: :string}
  step :authorize, &can_create_users?/1
  step :handle_create
end

action :create_user, metadata: &action_metadata/1 do
  step :cast_validate_params, schema: %{name!: :string}
  step :handle_create
end

action(name, opts, list)

(macro)

Defines an action with options and its steps.

Options

  • :metadata - Function that takes a context and returns a map of action-specific metadata for telemetry events. Optional.

Examples

action :create_user, metadata: &create_user_metadata/1 do
  step :cast_validate_params, schema: %{name!: :string}
  step :handle_create
end

step(step_spec, opts \\ [])

(macro)

Defines a step within an action.

Steps must implement one of these function signatures:

  • step_name(ctx) - Receives context only
  • step_name(ctx, opts) - Receives context and step options

Return Values

Steps must return one of:

  • {:cont, updated_context} - Continue to next step with updated context
  • {:halt, {:ok, result}} - Stop pipeline with success result
  • {:halt, {:error, reason}} - Stop pipeline with error result

Examples

# Step with no options
step :my_step

# Step with options
step :my_step, option: value

# External step from another module
step {ExternalModule, :external_step}, option: value

# Built-in parameter validation step
step :cast_validate_params, schema: %{name!: :string, age: :integer}

Step Implementation

# Simple step that just continues
def my_step(ctx) do
  {:cont, Context.assign(ctx, :processed, true)}
end

# Step with options that modifies context
def my_step_with_options(ctx, opts) do
  value = Keyword.get(opts, :option, "default")
  {:cont, Context.assign(ctx, :custom_value, value)}
end

# Step that can halt the pipeline
def require_admin(ctx) do
  if admin?(ctx.assigns.current_user) do
    {:cont, ctx}
  else
    {:halt, {:error, :unauthorized}}
  end
end

# Step that completes the action
def handle_create(ctx) do
  case create_user(ctx.params) do
    {:ok, user} -> {:halt, {:ok, user}}
    {:error, reason} -> {:halt, {:error, reason}}
  end
end