Post
Taming LiveView Event Handlers: A CRM Integration Story (Part 2)
A Simple Boundary
In Part 1, I discussed the pitfalls of placing business logic—specifically external API integrations with Hubspot, Redtail, and MS Dynamics—directly inside LiveView handle_event callbacks. The coupling was tight, testing was hard, and handling retries or offline capabilities became complex.
To solve this, we introduced a simple rule to our architecture:
LiveViews emit commands. Domains handle commands.
We wanted a clear boundary between our UI events and our business logic. This isn't event sourcing, and it isn't strict CQRS. It's just a way to make user intent explicit, serializable, and safely replayable.
This philosophy eventually crystallized into a library called PhoenixCommands.
Enter PhoenixCommands
PhoenixCommands is a small, opinionated library that introduces explicit domain commands.
The core idea is to turn the LiveView into a pure UI adapter. Instead of mutating state or calling external APIs directly in the handle_event function, the LiveView builds a command struct and dispatches it.
Defining Intent
First, we define what the user wants to do as a struct. This is a plain Elixir module using PhoenixCommands.Command.
defmodule MyApp.CRM.Commands.SyncContact do
use PhoenixCommands.Command
import Ecto.Changeset
embedded_schema do
field :contact_id, :binary_id
field :actor_id, :binary_id
field :expected_version, :integer
end
@impl PhoenixCommands.Command
def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:id, :contact_id, :actor_id, :expected_version])
|> validate_required([:contact_id, :actor_id, :expected_version])
|> validate_number(:expected_version, greater_than_or_equal_to: 0)
end
end
Commands are immutable data, serializable, and explicitly state what the system should do.
Handling the Command
Next, we write a handler. The handler is pure application logic. It has zero knowledge of WebSockets, sockets, or LiveView state. It validates the intent, authorizes it, and performs the necessary domain mutations (or external API calls).
defmodule MyApp.CRM.SyncContactHandler do
use PhoenixCommands.Handler
@impl PhoenixCommands.Handler
def handle(%SyncContact{} = cmd) do
with {:ok, contact} <- Contacts.fetch(cmd.contact_id),
:ok <- check_version(contact, cmd.expected_version),
:ok <- authorize(cmd.actor_id, contact),
:ok <- Hubspot.sync(contact),
:ok <- Redtail.sync(contact),
:ok <- Dynamics.sync(contact) do
{:ok, contact}
else
{:error, {:conflict, _}} = conflict -> conflict
error -> error
end
end
defp check_version(%{version: v}, v), do: :ok
defp check_version(_, _), do: {:error, {:conflict, :stale_version}}
end
Handlers return a deterministic outcome: {:ok, result}, {:error, {:conflict, reason}}, or {:error, reason}.
Dispatching from LiveView
Now, back in our LiveView, we simply dispatch the command. The LiveView handles the result and updates the UI accordingly.
def handle_event("sync_contact", %{"id" => contact_id}, socket) do
case SyncContact.build(%{
contact_id: contact_id,
actor_id: socket.assigns.current_user.id,
expected_version: socket.assigns.contact_versions[contact_id]
}) do
{:ok, cmd} ->
case SyncContactHandler.handle(cmd) do
{:ok, _contact} -> {:noreply, put_flash(socket, :info, "Synced successfully!")}
{:error, {:conflict, _}} -> {:noreply, put_flash(socket, :error, "Contact was updated by someone else")}
{:error, reason} -> {:noreply, put_flash(socket, :error, "Sync failed: #{inspect(reason)}")}
end
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
endThe Payoff
This small architectural shift gave us immediate benefits:
1. Testability
We could now unit test our complex CRM synchronization logic without spinning up a Phoenix endpoint or simulating WebSocket events. The command handler is just a regular Elixir function that takes data and returns data.
2. Idempotency & Retries
Because commands have a unique ID, we can enforce at-most-once execution. If a user double-clicks the "Sync" button, the dispatcher sees the duplicate command ID and safely ignores it. No more duplicate records in Hubspot.
3. Optimistic Concurrency
By attaching an expected_version to the command, the handler can detect if the domain state has changed since the user loaded the page, returning a {:conflict, reason}.
4. Optional Offline Support
Because commands are serializable data, we gained a clear path to offline support. If the network drops, the frontend can serialize the command and store it in an offline queue. When the connection returns, we replay the commands, not the UI events. We never have to reconstruct the LiveView state to process offline actions.
Conclusion
PhoenixCommands isn't a heavy framework; it's a boundary. If you have a simple CRUD application, you probably don't need it. But if your LiveView app involves real domain logic, multiple clients, or integrations with complex external systems like CRMs, making your user intent explicit is a game-changer.
Check out the source code and try extracting just one complex event handler to see how it feels. The library is designed to be adopted incrementally and disappear into your architecture.