Skip to content

Joel-Raju/phoenix_commands

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PhoenixCommands

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.


What it is

A command is a plain Ecto embedded schema with:

  • A mandatory :id field (your idempotency key, auto-generated if not supplied)
  • A mandatory :issued_at field (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.

What it is not

  • Not a dispatcher or central registry
  • Not event sourcing or CQRS
  • Not a workflow engine
  • Not a reimplementation of Oban, Ecto, or LiveView

Installation

def deps do
  [
    {:phoenix_commands, "~> 0.2.0"},

    # Optional integrations
    {:oban, "~> 2.17"},
    {:phoenix_live_view, "~> 0.20"}
  ]
end

Quick Start

1. Define a command

defmodule 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
end

2. Define a handler

defmodule 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}}
end

3. Use from LiveView

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

4. Same command from a REST controller

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

Idempotency

Every 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.


Oban Integration

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

: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].


Testing

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)
end

Handlers have no Phoenix or socket dependency. Plain ExUnit.


License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages