We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Use `Ecto.Multi` for complex transactions in Phoenix contexts
almirsarajcic
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.
copied to clipboard
Comments (0)
Sign in with GitHub to join the discussion