Axn - Unified Action DSL for Phoenix

A clean, step-based DSL library for defining actions that work seamlessly across Phoenix Controllers and LiveViews. Axn provides a unified interface for parameter validation, authorization, telemetry, and business logic where Plugs cannot be used.

Why Axn? Plugs only work with Plug.Conn but not Phoenix.LiveView.Socket. Axn bridges this gap, letting you write action logic once and use it in both contexts.

Hex.pmDocumentation

Installation

def deps do
  [
    {:axn, "~> 0.1.0"}
  ]
end

Quick Start

defmodule MyApp.UserActions do
  use Axn

  action :create_user do
    step :validate_params
    step :require_admin
    step :create_user

    def validate_params(ctx) do
      # Simple validation - details in docs
      {:cont, ctx}
    end

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

    def create_user(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
end

# Use in Phoenix Controller
def create(conn, params) do
  case MyApp.UserActions.run(:create_user, params, conn) do
    {:ok, user} -> json(conn, %{user: user})
    {:error, reason} -> json(conn, %{error: reason})
  end
end

# Use in Phoenix LiveView
def handle_event("create_user", 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, "Error: #{reason}")}
  end
end

Core Concepts

Actions

Actions are named units of work that execute steps in order:

action :action_name do
  step :step_name
  step :step_name, option: value
  step {ExternalModule, :external_step}
end

Steps

Steps take a context and either continue or halt the pipeline:

def my_step(ctx) do
  {:cont, updated_ctx}           # Continue to next step
  # OR
  {:halt, {:ok, result}}         # Stop with success
  # OR
  {:halt, {:error, reason}}      # Stop with error
end

Context

The Axn.Context struct flows through steps, carrying data:

%Axn.Context{
  action: :create_user,
  assigns: %{current_user: user},    # Phoenix-style assigns
  params: %{email: "...", name: "..."},  # Request parameters
  private: %{},                      # Internal state
  result: nil                        # Final result
}

Built-in Steps

Parameter Validation

step :cast_validate_params, schema: %{
  email!: :string,                        # Required
  name: :string,                          # Optional
  age: [field: :integer, default: 18]     # With default
}

# With custom validation
step :cast_validate_params,
     schema: %{phone!: :string},
     validate: &validate_phone/1

Authorization

Create simple authorization steps:

step :require_admin

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

Telemetry

Axn automatically emits telemetry events:

Custom Metadata

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

Unified Phoenix Integration

The Problem: Plugs work with Controllers but not LiveViews, creating code duplication.

The Solution: Axn works with both contexts seamlessly.

# Same action works in both:
MyApp.UserActions.run(:create_user, params, conn)    # Controller
MyApp.UserActions.run(:create_user, params, socket)  # LiveView

The action automatically extracts assigns from either conn or socket, eliminating the need to duplicate authorization, validation, and business logic.

Testing

test "create_user succeeds with valid input" do
  assigns = %{current_user: %User{role: "admin"}}
  params = %{"email" => "test@example.com", "name" => "John"}

  assert {:ok, user} = MyApp.UserActions.run(:create_user, params, assigns)
  assert user.email == "test@example.com"
end

Error Handling

Axn provides consistent error handling:

# Parameter errors
{:error, %{reason: :invalid_params, changeset: changeset}}

# Authorization errors
{:error, :unauthorized}

# Custom errors
{:error, :custom_reason}

External Steps

Use steps from other modules:

action :complex_operation do
  step :validate_params
  step {MySteps, :enrich_context}, fields: [:preferences]
  step :handle_operation
end

Performance

Comparison

vs. Phoenix Plugs

vs. Phoenix Contexts

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Add tests for your changes
  4. Ensure all tests pass (mix test)
  5. Run static analysis (mix credo)
  6. Commit your changes (git commit -am 'Add some feature')
  7. Push to the branch (git push origin my-new-feature)
  8. Create a new Pull Request

License

MIT