Use `Ecto.Multi` for complex transactions in Phoenix contexts

almirsarajcic

almirsarajcic

yesterday

0 comments

Manual transaction management with nested operations becomes unreadable and error-prone. Ecto.Multi provides named operations, automatic rollback, and clear transaction composition.

# ❌ MANUAL TRANSACTIONS - Hard to read and maintain
defmodule Accounts do
  def register_user(attrs) do
    Repo.transaction(fn ->
      with {:ok, user} <- create_user(attrs),
           {:ok, profile} <- create_profile(user),
           {:ok, _settings} <- create_default_settings(user),
           {:ok, _audit} <- log_registration(user) do
        user
      else
        {:error, changeset} ->
          Repo.rollback(changeset)
      end
    end)
  end
end

# ✅ ECTO.MULTI - Self-documenting transaction steps
defmodule Accounts do
  def register_user(attrs) do
    Multi.new()
    |> Multi.insert(:user, User.changeset(%User{}, attrs))
    |> Multi.insert(:profile, fn %{user: user} ->
      Profile.changeset(%Profile{user_id: user.id}, %{})
    end)
    |> Multi.insert(:settings, fn %{user: user} ->
      Settings.default_changeset(user)
    end)
    |> Multi.insert(:audit, fn %{user: user} ->
      AuditLog.registration_entry(user)
    end)
    |> Repo.transaction()
  end
end

Each operation in the Multi pipeline is named and can reference results from previous steps. If any operation fails, the entire transaction rolls back automatically.

Access transaction results by name

Multi returns a map with all operation results, making it easy to work with multiple related records:

case Accounts.register_user(user_params) do
  {:ok, %{user: user, profile: profile, settings: settings}} ->
    send_welcome_email(user)
    {:ok, user}

  {:error, :user, changeset, _changes_so_far} ->
    {:error, "Failed to create user", changeset}

  {:error, :profile, changeset, _changes_so_far} ->
    {:error, "Failed to create profile", changeset}

  {:error, failed_operation, failed_value, changes_so_far} ->
    {:error, "Transaction failed at #{failed_operation}"}
end

Conditional operations with Multi.run/3

Use Multi.run/3 for operations that need custom logic or external service calls:

defmodule Billing do
  def create_subscription(user, plan_id) do
    Multi.new()
    |> Multi.run(:validate_plan, fn _repo, _changes ->
      case Plans.get(plan_id) do
        nil -> {:error, :invalid_plan}
        plan -> {:ok, plan}
      end
    end)
    |> Multi.run(:charge_payment, fn _repo, %{validate_plan: plan} ->
      StripeAPI.charge_customer(user, plan.price)
    end)
    |> Multi.insert(:subscription, fn %{validate_plan: plan} ->
      Subscription.changeset(%Subscription{}, %{
        user_id: user.id,
        plan_id: plan.id,
        status: :active
      })
    end)
    |> Multi.update(:user, fn %{subscription: sub} ->
      User.changeset(user, %{subscription_id: sub.id})
    end)
    |> Repo.transaction()
  end
end

If StripeAPI.charge_customer/2 fails, the entire transaction rolls back - no subscription record is created, and the user isn’t updated.

Reusable transaction components

Build complex transactions from smaller, reusable pieces:

defmodule OrderProcessing do
  def create_order_multi(user, items) do
    Multi.new()
    |> Multi.insert(:order, Order.new_changeset(user))
    |> Multi.merge(fn %{order: order} ->
      add_order_items_multi(order, items)
    end)
  end

  defp add_order_items_multi(order, items) do
    items
    |> Enum.with_index()
    |> Enum.reduce(Multi.new(), fn {item, index}, multi ->
      Multi.insert(multi, {:item, index}, OrderItem.changeset(order, item))
    end)
  end

  def complete_order(order_id) do
    Multi.new()
    |> Multi.update(:order, fn _ ->
      order = Repo.get!(Order, order_id)
      Order.complete_changeset(order)
    end)
    |> Multi.merge(fn %{order: order} ->
      charge_payment_multi(order)
    end)
    |> Multi.merge(fn %{order: order} ->
      send_confirmation_multi(order)
    end)
    |> Repo.transaction()
  end

  defp charge_payment_multi(order), do: # ...
  defp send_confirmation_multi(order), do: # ...
end

Debugging with named operations

When a transaction fails, you know exactly which step caused the problem:

case Repo.transaction(multi) do
  {:ok, changes} ->
    Logger.info("Transaction completed: #{inspect(Map.keys(changes))}")
    {:ok, changes}

  {:error, failed_operation, failed_value, changes_so_far} ->
    Logger.error("""
    Transaction failed at: #{failed_operation}
    Error: #{inspect(failed_value)}
    Completed steps: #{inspect(Map.keys(changes_so_far))}
    """)
    {:error, failed_operation, failed_value}
end

Use Ecto.Multi whenever you have multiple database operations that must succeed or fail together. Your transaction logic becomes testable, composable, and self-documenting.

Comments (0)

Sign in with GitHub to join the discussion