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

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages