Phoenix Integration

Copy Markdown View Source

Enact has no Phoenix dependency. A host application wires four integration points itself: the actor, the context calling convention, the controller, and the error renderer. This guide provides reference implementations for each.

Setup

# mix.exs
{:enact, "~> 0.1.0"}

# config/config.exs
config :enact, repo: MyApp.Repo

Contexts

Phoenix contexts remain the application API. Controllers, LiveViews, and jobs call context functions; those functions are one-liners that forward to Enact.run/3, dry_run/3, subject/3, or authorized/3. The action module is the write; the context is the name the rest of the app uses.

# lib/my_app/projects.ex
defmodule MyApp.Projects do
  alias MyApp.Projects.Actions.{ArchiveProject, CreateProject, UpdateProject}

  def create_project(params, opts), do: Enact.run(CreateProject, params, opts)
  def update_project(params, opts), do: Enact.run(UpdateProject, params, opts)
  def archive_project(params, opts), do: Enact.run(ArchiveProject, params, opts)

  def create_project_dry_run(params, opts), do: Enact.dry_run(CreateProject, params, opts)
  def update_project_dry_run(params, opts), do: Enact.dry_run(UpdateProject, params, opts)

  def create_project_authorized(params, opts), do: Enact.authorized(CreateProject, params, opts)
  def update_project_subject(params, opts), do: Enact.subject(UpdateProject, params, opts)

  # reads and load_subject fetchers stay here
end

Keep those bodies as a single Enact.run / Enact.dry_run / Enact.subject / Enact.authorized call. Do not reshape params, stamp persistable fields, or preload records into assigns: — that work belongs in the action (execute/2, load_subject/2, resolvers/0). opts pass through unchanged so :actor, :repo, :assigns, and :confirm_digest work as they do on Enact.run/3.

The one-liners may be generated instead of written by hand:

defmodule MyApp.Projects do
  alias MyApp.Projects.Actions.{ArchiveProject, CreateProject, UpdateProject}

  use Enact.Delegates, actions: [CreateProject, UpdateProject, ArchiveProject]

  # reads and load_subject fetchers stay here
end

Names come from the last segment of each action module (CreateProjectcreate_project / create_project_dry_run / create_project_subject / create_project_authorized). All four wrappers are generated for every listed action. Handwritten delegates remain valid; the helper is opt-in.

Action tests and IEx may still call Enact.run/3 directly. That is the implementation API, not a second door for controllers.

The actor: your scope struct

In Phoenix 1.8+, the recommended actor is the scope struct. Enact never reads the actor itself; only your authorize/1 callbacks and fetchers do.

Projects.create_project(params, actor: conn.assigns.current_scope)

Implement the Enact.Actor protocol for your scope struct so Enact can identify anonymous scopes:

# lib/my_app/accounts/scope.ex
defimpl Enact.Actor, for: MyApp.Accounts.Scope do
  def anonymous?(%{user: nil}), do: true
  def anonymous?(_scope), do: false
end

actor: nil always raises. This intentionally conflicts with current_scope: nil on public routes: public endpoints must construct an explicit anonymous scope at the boundary. An anonymous scope can carry a session ID and IP for rate limiting:

# a plug on public pipelines
def assign_public_scope(conn, _opts) do
  assign(conn, :current_scope, MyApp.Accounts.Scope.for_visitor(
    session_id: get_session(conn, :session_id),
    ip: conn.remote_ip
  ))
end

Only actions that declare anonymous?: true in config/0 accept an anonymous actor. All other actions return :forbidden before any pipeline work runs. To audit the unauthenticated write surface, search the codebase for anonymous?: true.

Without scopes

The scope pattern is not required. The actor is opaque to Enact, so any term works. In an application that assigns current_user, pass it directly:

Projects.create_project(params, actor: conn.assigns.current_user)

Authenticated actors need no Enact.Actor implementation; the fallback returns false for any term. Your authorize/1 callbacks and fetchers read ctx.actor as a user struct instead of a scope.

The rule for public endpoints is unchanged: never pass nil. The :anonymous atom is a built-in anonymous actor:

Bookings.create_booking(params, actor: :anonymous)

Unlike an anonymous scope, :anonymous carries no session ID or IP. If your anonymous?: true actions need those for rate limiting or auditing, use a scope-shaped actor instead.

Controllers

Phoenix merges path params into params, so the subject ID (read by load_subject/2) and the request body arrive together. Pass params through unchanged. Jobs and tests may pass atom keys — the runner stringifies them, so actions always see the same string-keyed shape:

defmodule MyAppWeb.ProjectController do
  use MyAppWeb, :controller

  action_fallback MyAppWeb.FallbackController

  alias MyApp.Projects

  def new(conn, _params) do
    with :ok <-
           Projects.create_project_authorized(%{}, actor: conn.assigns.current_scope) do
      render(conn, :new)
    end
  end

  def create(conn, params) do
    with {:ok, project} <- Projects.create_project(params, actor: conn.assigns.current_scope) do
      conn |> put_status(:created) |> render(:show, project: project)
    end
  end

  def edit(conn, params) do
    with {:ok, project} <-
           Projects.update_project_subject(params, actor: conn.assigns.current_scope) do
      render(conn, :edit, project: project)
    end
  end

  def update(conn, params) do
    with {:ok, project} <- Projects.update_project(params, actor: conn.assigns.current_scope) do
      render(conn, :show, project: project)
    end
  end
end

Inertia / HTML form posts

JSON controllers can with on the context function because success is a render and every error is a status. Inertia form posts are different: success, :invalid, and :conflict are Phoenix redirect/2 (Inertia intercepts those). :invalid in particular is not a 422 page — assign_errors/2 (from inertia-phoenix) stashes the changeset and the next GET paints the form. That is the Inertia contract, not Enact's.

:forbidden, :not_found, and :internal still belong to action_fallback. Return the error tuple; do not call the fallback module from the helper. action_fallback only runs when the action returns something other than a %Plug.Conn{}.

# lib/my_app_web/enact.ex
defmodule MyAppWeb.Enact do
  import Inertia.Controller
  import Phoenix.Controller

  def enact_redirect(result, conn, opts) do
    case result do
      {:ok, _} ->
        conn
        |> put_flash(:info, Keyword.fetch!(opts, :success_flash))
        |> redirect(to: Keyword.fetch!(opts, :success_path))

      {:error, %Enact.Error{type: :invalid, changeset: changeset}} ->
        conn
        |> assign_errors(changeset)
        |> redirect(to: Keyword.fetch!(opts, :invalid_path))

      {:error, %Enact.Error{type: :conflict}} ->
        conn
        |> put_flash(:error, Keyword.fetch!(opts, :conflict_flash))
        |> redirect(to: Keyword.get(opts, :conflict_path, Keyword.fetch!(opts, :success_path)))

      {:error, %Enact.Error{type: type} = error}
      when type in [:forbidden, :not_found, :internal] ->
        {:error, error}
    end
  end
end
def create(conn, params) do
  params
  |> Projects.create_project(actor: conn.assigns.current_scope)
  |> enact_redirect(conn,
    success_flash: "Project created",
    success_path: ~p"/projects",
    invalid_path: ~p"/projects/new",
    conflict_flash: "Project could not be created"
  )
end

If the success redirect needs the created record (~p"/projects/#{project}"), take it from {:ok, value}. Conflict has no new record, so that path cannot be the conflict default.

Import the helper from use MyAppWeb, :controller if you want it on every controller. Keep the name prefixed (enact_redirect/3) so it does not collide with Phoenix.Controller.redirect/2, and import with only: so later host helpers do not leak into API controllers.

API controllers should not use this helper. They stay on with {:ok, record} <- Projects.create_project(...).

Rendering errors

One fallback clause handles every action. Because the error taxonomy is closed, this code does not change as actions are added:

defmodule MyAppWeb.FallbackController do
  use MyAppWeb, :controller

  def call(conn, {:error, %Enact.Error{} = error}) do
    conn
    |> put_status(status(error.type))
    |> put_view(json: MyAppWeb.ErrorJSON)
    |> render(:enact, error: error)
  end

  defp status(:invalid), do: :unprocessable_entity
  defp status(:forbidden), do: :forbidden
  defp status(:not_found), do: :not_found
  defp status(:conflict), do: :conflict
  defp status(:internal), do: :internal_server_error
end
defmodule MyAppWeb.ErrorJSON do
  # :invalid is the only type that carries caller-visible detail. The
  # changeset describes the caller's own input, so traverse_errors is safe
  # to serialize. Nested and indexed embed errors (including resolver
  # failures) render correctly with no per-action code.
  def enact(%{error: %Enact.Error{type: :invalid, changeset: changeset}}) do
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
  end

  # Default: generic copy by type. Do not echo error.reason.
  def enact(%{error: %Enact.Error{type: type}}) do
    %{errors: %{detail: detail(type)}}
  end

  defp detail(:forbidden), do: "Forbidden"
  defp detail(:not_found), do: "Not found"
  defp detail(:conflict), do: "Conflict"
  defp detail(:internal), do: "Internal server error"

  defp translate_error({msg, opts}) do
    # Gettext-backed in a real app. This renderer is the API-versioning seam.
    Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
      opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
    end)
  end
end

Renderer policies:

  • 404/403 collapse. Rendering :forbidden as 404 prevents responses from revealing that a resource exists. Keep the two types distinct internally; collapse only at the renderer. The policy can be format-specific: HTML (and Inertia) often collapse, JSON APIs often keep 403. Put the clause above the catch-all %Enact.Error{} head:

    def call(conn, {:error, %Enact.Error{type: :forbidden}}) do
      if get_format(conn) == "html" do
        call(conn, {:error, :not_found})
      else
        call(conn, {:error, :forbidden})
      end
    end
  • Forbidden copy. Default 403 is generic. To vary the message, add a clause that matches a host-owned reason from authorize/1 and keep the generic :forbidden head as the fallback. Do not put reason in the JSON. Do not do this for :not_found.

    def enact(%{error: %Enact.Error{type: :forbidden, reason: :link_disabled}}) do
      %{errors: %{detail: "This scheduling link is disabled"}}
    end
  • Stable error codes. Resolver failures carry validation: :resolution in the error metadata, so translate_error/1 can emit machine-readable codes that distinguish reference errors from format errors without string matching.

Constraint errors and stale races

A {:error, %Ecto.Changeset{}} returned from execute/2 rolls the transaction back and is promoted to :invalid. This is how declared constraints (unique_constraint, foreign_key_constraint) surface as ordinary 422 field errors when the database catches a race that pre-flight validation could not.

Stale-entry races arrive through the same channel. Repo.update(changeset, stale_error_field: :lock_version) also returns {:error, changeset}, so without intervention an optimistic-lock failure renders as :invalid with a field error on :lock_version. The correct type for that case is :conflict (409): the input was valid, but the record changed. If an action uses optimistic locking, translate that failure in execute/2. The runner passes an %Enact.Error{} through unchanged:

@impl Enact.Action
def execute(changeset, ctx) do
  result =
    ctx.subject
    |> Project.changeset(Enact.updates(changeset, ctx))
    |> Ecto.Changeset.optimistic_lock(:lock_version)
    |> ctx.repo.update(stale_error_field: :lock_version)

  case result do
    {:error, %Ecto.Changeset{errors: errors} = failed} ->
      if errors[:lock_version] do
        {:error, Enact.Error.conflict(:stale)}
      else
        # constraint errors continue to flow to :invalid
        {:error, failed}
      end

    other ->
      other
  end
end

The changeset in a promoted constraint error is the persistence changeset, not the input changeset. Keep constraint-bearing field names aligned with the input schema's names, or re-map them in execute/2, so the rendered field addressing matches your API contract.

Some actions should never reflect the persistence changeset to callers — for example, when the action declares no constraints, or when persistence field names do not match the input's. In that case, an unexpected changeset error indicates that validation and persistence have drifted, which is a bug rather than caller input. Log it and return :internal. The default renderer does not echo reason, so the caller sees a generic 500:

require Logger

@impl Enact.Action
def execute(changeset, ctx) do
  updates = Enact.updates(changeset, ctx)

  case ctx.repo.insert(Project.changeset(%Project{}, updates)) do
    {:ok, project} ->
      {:ok, project}

    {:error, failed} ->
      Logger.error(
        "#{inspect(__MODULE__)} persistence changeset rejected pre-validated input: " <>
          inspect(failed.errors)
      )

      {:error, Enact.Error.internal(:persistence_rejected)}
  end
end

Every :internal occurrence in production logs is a defect to fix. It is never rendered to callers.

Background jobs

Background jobs use the same calling convention. Reconstruct the actor from stored identity; do not bypass the context (or the action behind it):

defmodule MyApp.Workers.ArchiveStaleProject do
  use Oban.Worker

  alias MyApp.Projects

  @impl Oban.Worker
  def perform(%{args: %{"project_id" => id, "actor_user_id" => user_id}}) do
    actor = MyApp.Accounts.scope_for_user_id!(user_id)

    case Projects.archive_project(%{"id" => id}, actor: actor) do
      {:ok, _} -> :ok
      # already archived or deleted: don't retry
      {:error, %Enact.Error{type: :not_found}} -> :ok
      {:error, error} -> {:error, error}
    end
  end
end

Telemetry

The runner emits telemetry events for every action. Attach a handler for metrics and audit trails:

:telemetry.attach_many(
  "enact-audit",
  [
    [:enact, :action, :run],
    [:enact, :action, :run, :error],
    [:enact, :action, :dry_run],
    [:enact, :action, :dry_run, :error],
    [:enact, :action, :subject],
    [:enact, :action, :subject, :error],
    [:enact, :action, :authorized],
    [:enact, :action, :authorized, :error]
  ],
  &MyApp.Audit.handle_enact_event/4,
  nil
)

Error events carry type: in metadata, so a handler can record entries such as "actor X attempted Y, denied :forbidden". Dry runs, subject loads, and authorized checks emit separate events, so previews and form GETs are auditable without inflating execution counts.

Confirmation flows (agent surfaces, MCP tools)

{:ok, preview} = Projects.update_project_dry_run(params, actor: actor)
# present preview.updates for confirmation (diff against preview.subject), then:
{:ok, project} =
  Projects.update_project(params, actor: actor, confirm_digest: preview.digest)

The digest binds the action, mode, locator params, and updates map; any difference between the previewed change and the confirming run returns :conflict. Diff preview.updates against preview.subject. The preview lists resolver names only; if the confirmation UI needs display information (for example, the resolved user's name), render it host-side from your own reads.