Give your domain operations a shape.
PhoenixCommands provides validated, serializable command structs for Phoenix applications. It introduces a clear boundary between UI events and domain logic without reinventing the tools Elixir already gives you.
A command is a plain Ecto embedded schema with:
- A mandatory
:idfield (your idempotency key, auto-generated if not supplied) - A mandatory
:issued_atfield (stamped at build time) - Your domain fields, validated via a standard Ecto changeset
Dispatch is your responsibility. Call your handler directly, enqueue via Oban, or send to a GenServer. The library does not own routing.
- Not a dispatcher or central registry
- Not event sourcing or CQRS
- Not a workflow engine
- Not a reimplementation of Oban, Ecto, or LiveView
def deps do
[
{:phoenix_commands, "~> 0.2.0"},
# Optional integrations
{:oban, "~> 2.17"},
{:phoenix_live_view, "~> 0.20"}
]
enddefmodule MyApp.Cards.Commands.UpdateCard do
use PhoenixCommands.Command
import Ecto.Changeset
embedded_schema do
field :card_id, :binary_id
field :actor_id, :binary_id
field :title, :string
field :expected_version, :integer
end
@impl PhoenixCommands.Command
def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:card_id, :actor_id, :title, :expected_version])
|> validate_required([:card_id, :actor_id, :expected_version])
|> validate_length(:title, min: 1, max: 255)
end
enddefmodule MyApp.Cards.UpdateCardHandler do
use PhoenixCommands.Handler
@impl PhoenixCommands.Handler
def handle(%UpdateCard{} = cmd) do
Repo.transaction(fn ->
with {:ok, card} <- Cards.fetch(cmd.card_id),
:ok <- check_version(card, cmd.expected_version),
:ok <- authorize(cmd.actor_id, card),
{:ok, card} <- Cards.update(card, cmd) do
card
else
{:error, reason} -> Repo.rollback(reason)
end
end)
end
defp check_version(%{version: v}, v), do: :ok
defp check_version(_, _), do: {:error, {:conflict, :stale_version}}
enddef handle_event("update_card", %{"title" => title}, socket) do
case UpdateCard.build(%{
card_id: socket.assigns.card.id,
actor_id: socket.assigns.current_user.id,
title: title,
expected_version: socket.assigns.card.version
}) do
{:ok, cmd} ->
case UpdateCardHandler.handle(cmd) do
{:ok, card} -> {:noreply, assign(socket, :card, card)}
{:error, {:conflict, _}} -> {:noreply, put_flash(socket, :error, "Someone else updated this")}
{:error, _reason} -> {:noreply, put_flash(socket, :error, "Failed")}
end
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
enddef update(conn, params) do
attrs = Map.put(params, "actor_id", conn.assigns.current_user.id)
case UpdateCard.build(attrs) do
{:ok, cmd} ->
case UpdateCardHandler.handle(cmd) do
{:ok, card} -> json(conn, card)
{:error, {:conflict, _}} -> conn |> put_status(409) |> json(%{error: "conflict"})
{:error, reason} -> conn |> put_status(422) |> json(%{error: inspect(reason)})
end
{:error, changeset} ->
conn |> put_status(422) |> json(%{errors: format_errors(changeset)})
end
endEvery command has an :id. Supply a stable one (generated client-side before the request) and the operation is safe to retry.
For development and single-node apps:
# Add to your supervision tree
children = [{PhoenixCommands.IdempotencyStore.ETS, []}]
# Wrap dispatch
PhoenixCommands.IdempotencyStore.wrap(
PhoenixCommands.IdempotencyStore.ETS,
cmd.id,
fn -> UpdateCardHandler.handle(cmd) end
)For production clusters, use Oban's unique jobs (recommended) or implement PhoenixCommands.IdempotencyStore against your database.
defmodule MyApp.Workers.CommandWorker do
use PhoenixCommands.Oban.Worker,
queue: :commands,
max_attempts: 3,
handlers: [MyApp.Cards.UpdateCardHandler]
end
{:ok, _job} = PhoenixCommands.Oban.enqueue(cmd, MyApp.Workers.CommandWorker)Conflicts are discarded (not retried). Other errors retry up to max_attempts.
:telemetry.attach_many(
"phoenix-commands-logger",
PhoenixCommands.Telemetry.events(),
&PhoenixCommands.Telemetry.log_handler/4,
nil
)Events emitted: [:phoenix_commands, :command, :build, :start | :stop] and [:phoenix_commands, :handler, :handle, :start | :stop | :exception].
test "returns conflict on stale version" do
{:ok, cmd} = UpdateCard.build(%{
card_id: card.id,
actor_id: user.id,
expected_version: 999
})
assert {:error, {:conflict, :stale_version}} =
UpdateCardHandler.handle(cmd)
endHandlers have no Phoenix or socket dependency. Plain ExUnit.
MIT