Files

27 KiB

Ecto.Multi Patterns

Patterns extracted from Ecto's Ecto.Multi source code.

Contents

  1. Multi.new() |> Multi.insert/update/delete — Named Operation Pipeline
  2. Multi.run/3 — Arbitrary Code in a Transaction
  3. Dependent Operations with Function Variants
  4. Multi.merge/2 — Dynamic Transaction Composition
  5. Multi.append/2 / Multi.prepend/2 — Static Multi Composition
  6. Tuple Keys — Dynamic Collections of Operations
  7. Multi.to_list/1 — Testing Without a Database

1. Multi.new() |> Multi.insert/update/delete — Named Operation Pipeline

Source: lib/ecto/multi.ex#L58

def reset(account, params) do
  Multi.new()
  |> Multi.update(:account, Account.password_reset_changeset(account, params))
  |> Multi.insert(:log, Log.password_reset_changeset(account, params))
  |> Multi.delete_all(:sessions, Ecto.assoc(account, :sessions))
end

# Execute:
case Repo.transaction(PasswordManager.reset(account, params)) do
  {:ok, %{account: account, log: log}} -> # success
  {:error, :account, changeset, _} -> # account step failed
end

Why: Each operation is named. On success, Repo.transaction returns {:ok, results_map} where each key is the name given to that operation. On failure, it returns {:error, failed_name, failed_value, changes_so_far}, making it immediately clear which step aborted the transaction and why. This is more precise than a bare transaction function where you'd have to inspect the return value to guess which step failed.

Anti-pattern: Using an anonymous function with bare case statements inside a transaction, where failure attribution is implicit:

# BAD — no way to know which operation failed from the return value alone
Repo.transaction(fn ->
  case Repo.update(Account.password_reset_changeset(account, params)) do
    {:ok, account} ->
      case Repo.insert(Log.password_reset_changeset(account, params)) do
        {:ok, log} -> {:ok, %{account: account, log: log}}
        {:error, changeset} -> Repo.rollback(changeset)
      end
    {:error, changeset} -> Repo.rollback(changeset)
  end
end)

When to Use

Triggers:

  • You have 2+ database operations that must all succeed or all roll back
  • The set of operations is known at compile time (not dynamically generated)
  • The caller needs to know which specific operation failed

Example — before:

def create_user_with_profile(params) do
  Repo.transaction(fn ->
    case Repo.insert(User.changeset(params)) do
      {:ok, user} ->
        case Repo.insert(Profile.changeset(user, params)) do
          {:ok, profile} -> {:ok, {user, profile}}
          {:error, cs} -> Repo.rollback(cs)
        end
      {:error, cs} -> Repo.rollback(cs)
    end
  end)
end

Example — after:

def create_user_with_profile(params) do
  Multi.new()
  |> Multi.insert(:user, User.changeset(params))
  |> Multi.insert(:profile, fn %{user: user} ->
    Profile.changeset(user, params)
  end)
  |> Repo.transaction()
end

# Caller:
case create_user_with_profile(params) do
  {:ok, %{user: user, profile: profile}} -> {:ok, user}
  {:error, :user, changeset, _} -> {:error, changeset}
  {:error, :profile, changeset, _} -> {:error, changeset}
end

When NOT to Use

Don't use this when:

  • You have a single database operation (just call Repo.insert/update/delete directly)
  • Operations are simple and sequential with no branching (a plain Repo.transaction(fn -> ... end) is more readable)
  • The overhead of building a Multi struct is not justified by the number of operations

Over-application example:

# Overkill for a single operation
Multi.new()
|> Multi.insert(:user, User.changeset(params))
|> Repo.transaction()

Better alternative:

Repo.insert(User.changeset(params))

Why: Ecto.Multi introduces indirection. For simple cases, calling Repo functions directly or using Repo.transaction(fn -> ... end) is clearer. The named-pipeline form pays off when 3+ operations are involved and failure attribution matters.


2. Multi.run/3 — Arbitrary Code in a Transaction

Source: lib/ecto/multi.ex#L39

Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.run(:welcome_email, fn repo, %{user: user} ->
  case Mailer.send_welcome(user) do
    :ok -> {:ok, :sent}
    {:error, reason} -> {:error, reason}
  end
end)

Why: Multi.run is the escape hatch for operations that don't fit the standard insert/update/delete API. The callback receives (repo, changes_so_far) — the repo argument means you can issue raw queries using the same transaction connection. Returning {:error, value} from the function aborts the whole transaction and surfaces the value in the {:error, :welcome_email, value, _} result. Returning {:ok, value} stores the value under :welcome_email in the success map.

Anti-pattern: Embedding operations in Multi.run that don't need transaction context or that don't return the required {:ok, value} / {:error, value} shape:

# BAD — run used just to transform data, no transaction context needed
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.run(:formatted_name, fn _repo, %{user: user} ->
  # This has no side effects and doesn't need a transaction
  {:ok, String.upcase(user.name)}
end)

When to Use

Triggers:

  • You need to perform an operation that isn't a standard Ecto schema action (e.g., calling an external service, running a raw query, computing a value that might fail)
  • The operation must participate in the transaction rollback on failure
  • The result of the operation is needed by a subsequent step in the Multi

Example — before:

def register_user(params) do
  Repo.transaction(fn ->
    {:ok, user} = Repo.insert(User.changeset(params))
    # This runs outside Multi — if it fails, user was already inserted
    case ExternalService.provision(user.id) do
      {:ok, token} -> {:ok, %{user: user, token: token}}
      {:error, reason} -> Repo.rollback(reason)
    end
  end)
end

Example — after:

def register_user(params) do
  Multi.new()
  |> Multi.insert(:user, User.changeset(params))
  |> Multi.run(:provision, fn _repo, %{user: user} ->
    ExternalService.provision(user.id)
  end)
  |> Repo.transaction()
end

When NOT to Use

Don't use this when:

  • The operation is a standard Ecto schema action — use Multi.insert, Multi.update, Multi.delete, or Multi.delete_all instead
  • The callback only transforms data without any side effects or failure modes
  • You need to insert/update using a changeset that depends on prior results — use the function variant of Multi.insert/update (see Pattern 3) instead

Over-application example:

# Multi.run adds overhead when Multi.insert's function variant covers this
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.run(:comment, fn _repo, %{post: post} ->
  Repo.insert(Ecto.build_assoc(post, :comments, body: "first"))
end)

Better alternative:

Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.insert(:comment, fn %{post: post} ->
  Ecto.build_assoc(post, :comments, body: "first")
end)

Why: Multi.run requires you to return a tagged {:ok, value} / {:error, value} tuple. When the operation is a plain Ecto changeset, the function variants of Multi.insert/update/delete accept a function that returns a changeset and handle the wrapping internally. Reserve Multi.run for operations with their own failure semantics.


3. Dependent Operations with Function Variants

Source: lib/ecto/multi.ex#L298

Multi.new()
|> Multi.insert(:post, %Post{title: "first"})
|> Multi.insert(:comment, fn %{post: post} ->
  Ecto.build_assoc(post, :comments, body: "first comment")
end)

Why: Each of the standard Multi operations (insert, update, delete, delete_all, insert_or_update) accepts either a static changeset/struct or a one-argument function fn changes -> changeset end. When an operation depends on the result of a previous step, pass the function form. The function receives the accumulated changes map up to that point, giving you access to all prior results. This keeps operations in a single pipeline without introducing a separate Multi.run call.

Anti-pattern: Using Multi.run when a function variant of Multi.insert/update suffices:

# BAD — Multi.run is more verbose and requires explicit {:ok, value} wrapping
Multi.new()
|> Multi.insert(:post, %Post{title: "first"})
|> Multi.run(:comment, fn _repo, %{post: post} ->
  Repo.insert(Ecto.build_assoc(post, :comments, body: "first comment"))
end)

When to Use

Triggers:

  • An operation's changeset or struct must be constructed using the result of a prior operation
  • You need an association ID, a foreign key, or any field that isn't available until a prior step runs
  • You want to keep the pipeline as Multi.insert/update/delete calls without dropping into Multi.run

Example — before:

def create_order_with_items(cart, user) do
  Multi.new()
  |> Multi.insert(:order, Order.changeset(cart, user))
  |> Multi.run(:items, fn _repo, %{order: order} ->
    items = Enum.map(cart.items, &Repo.insert(OrderItem.changeset(&1, order)))
    errors = Enum.filter(items, &match?({:error, _}, &1))
    if errors == [], do: {:ok, items}, else: {:error, errors}
  end)
end

Example — after:

def create_order_with_items(cart, user) do
  cart.items
  |> Enum.with_index()
  |> Enum.reduce(Multi.new() |> Multi.insert(:order, Order.changeset(cart, user)),
    fn {item, idx}, multi ->
      Multi.insert(multi, {:item, idx}, fn %{order: order} ->
        OrderItem.changeset(item, order)
      end)
    end)
end

When NOT to Use

Don't use this when:

  • The changeset does not depend on any prior operation — pass it directly as a static value
  • The dependent operation has complex failure logic that requires returning {:ok, value} / {:error, value} — use Multi.run in that case
  • The function would need to perform multiple Repo calls — the function variant only accepts a changeset/struct return, not arbitrary DB operations

Over-application example:

# Function variant used when the changeset doesn't depend on prior results
Multi.new()
|> Multi.insert(:user, fn _changes ->
  User.changeset(%User{}, params)   # No dependency on changes — pass directly
end)

Better alternative:

Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, params))

Why: The function variant defers changeset construction to transaction execution time, which means it can't be inspected with Multi.to_list/1 until the Multi runs. For static changesets, passing the value directly keeps the Multi inspectable and testable without executing the transaction.


4. Multi.merge/2 — Dynamic Transaction Composition

Source: lib/ecto/multi.ex#L239

Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.merge(fn %{post: post} ->
  if post.requires_approval do
    Multi.new()
    |> Multi.insert(:approval_request, ApprovalRequest.changeset(post))
  else
    Multi.new()
  end
end)

Why: Multi.merge/2 accepts a function that receives the changes accumulated so far and must return another Ecto.Multi. The returned Multi's operations are appended to the pipeline at execution time. This is the correct tool when the set of operations to add — not just the changesets — depends on prior results. A function variant of Multi.insert can swap in a different changeset; merge can add or remove entire operations.

Anti-pattern: Using merge when append or a function variant of insert/update would suffice — merge defers the entire sub-pipeline to runtime, making it harder to inspect:

# BAD — merge used just to pass a different changeset, not to change which operations run
Multi.new()
|> Multi.insert(:post, post_changeset)
|> Multi.merge(fn %{post: post} ->
  Multi.new()
  |> Multi.update(:post_meta, PostMeta.changeset(post))
end)

When to Use

Triggers:

  • Whether certain operations should be included at all depends on runtime data
  • A prior step's result determines which of several alternative sub-pipelines to execute
  • You have a reusable function that takes results and returns a configured Ecto.Multi

Example — before:

def publish_article(article, user) do
  multi = Multi.new()
  |> Multi.update(:article, Article.publish_changeset(article))

  # Tacking on conditonal operations awkwardly outside the pipeline
  multi =
    if article.notify_subscribers do
      Multi.run(multi, :notifications, fn _repo, %{article: article} ->
        send_notifications(article, user)
      end)
    else
      multi
    end

  multi
end

Example — after:

def publish_article(article, user) do
  Multi.new()
  |> Multi.update(:article, Article.publish_changeset(article))
  |> Multi.merge(fn %{article: published} ->
    if published.notify_subscribers do
      Multi.new()
      |> Multi.run(:notifications, fn _repo, _changes ->
        send_notifications(published, user)
      end)
    else
      Multi.new()
    end
  end)
end

When NOT to Use

Don't use this when:

  • The set of operations is fixed — use Multi.append/2 to combine two pre-built Multis instead
  • Only the changeset values vary between operations — use the function variant of Multi.insert/update
  • The merge function is trivially always the same Multi (just use Multi.append)

Over-application example:

# merge used when append would do — the sub-multi never varies
Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.merge(fn _changes ->
  Multi.new() |> Multi.insert(:audit_log, AuditLog.create())
end)

Better alternative:

audit_multi = Multi.new() |> Multi.insert(:audit_log, AuditLog.create())

Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.append(audit_multi)

Why: Multi.merge with a constant-returning function is just Multi.append with extra indirection. Use merge only when the function body actually branches on the changes argument.


5. Multi.append/2 / Multi.prepend/2 — Static Multi Composition

Source: lib/ecto/multi.ex#L183

def audit_multi(entity, user) do
  Multi.new()
  |> Multi.insert(:audit_log, AuditLog.changeset(entity, user))
end

def create_post(params, user) do
  post_multi = Multi.new() |> Multi.insert(:post, Post.changeset(params))
  Multi.append(post_multi, audit_multi(:post, user))
end

Why: append and prepend combine two fully-built Ecto.Multi structs into one. All operation names across both Multis must be unique — a conflict raises at composition time, not at execution time. This makes transaction fragments reusable: audit_multi/2 can be appended to any business operation without duplicating the audit logic. prepend puts the second Multi's operations first; append adds them at the end.

Anti-pattern: Re-defining the same operations in every Multi instead of extracting reusable fragments:

# BAD — audit logic duplicated across every operation
def create_post(params, user) do
  Multi.new()
  |> Multi.insert(:post, Post.changeset(params))
  |> Multi.insert(:audit_log, AuditLog.changeset(:post, user))
end

def update_post(post, params, user) do
  Multi.new()
  |> Multi.update(:post, Post.changeset(post, params))
  |> Multi.insert(:audit_log, AuditLog.changeset(:post, user))  # copy-paste
end

When to Use

Triggers:

  • You have a recurring group of operations (auditing, notifications, cleanup) that should attach to multiple different transactions
  • Two independently-defined Multis must run in the same transaction
  • You want to compose transaction fragments from different modules without either module knowing about the other's internals

Example — before:

def transfer_funds(from, to, amount) do
  Multi.new()
  |> Multi.update(:debit, Account.debit_changeset(from, amount))
  |> Multi.update(:credit, Account.credit_changeset(to, amount))
  |> Multi.insert(:ledger_entry, LedgerEntry.changeset(from, to, amount))
  |> Multi.insert(:audit_log, AuditLog.changeset(:transfer, %{from: from, to: to}))
end

Example — after:

def audit_multi(action, context) do
  Multi.new()
  |> Multi.insert(:audit_log, AuditLog.changeset(action, context))
end

def transfer_funds(from, to, amount) do
  transfer =
    Multi.new()
    |> Multi.update(:debit, Account.debit_changeset(from, amount))
    |> Multi.update(:credit, Account.credit_changeset(to, amount))
    |> Multi.insert(:ledger_entry, LedgerEntry.changeset(from, to, amount))

  Multi.append(transfer, audit_multi(:transfer, %{from: from, to: to}))
end

When NOT to Use

Don't use this when:

  • Which operations to add depends on the results of earlier operations — use Multi.merge/2 instead
  • The two Multis share operation names — this will raise at composition time and requires renaming
  • The combination is only used once — just build a single Multi inline

Over-application example:

# Splitting a single logical operation into two Multis for no benefit
user_part = Multi.new() |> Multi.insert(:user, user_cs)
profile_part = Multi.new() |> Multi.insert(:profile, profile_cs)
Multi.append(user_part, profile_part)

Better alternative:

Multi.new()
|> Multi.insert(:user, user_cs)
|> Multi.insert(:profile, profile_cs)

Why: append and prepend are tools for reuse. When operations are only ever combined in one place, defining them as a single pipeline is simpler. Extract fragments only when the same group of operations genuinely recurs across different transactions.


6. Tuple Keys — Dynamic Collections of Operations

Source: lib/ecto/multi.ex#L109

Enum.reduce(accounts, Multi.new(), fn account, multi ->
  Multi.update(
    multi,
    {:account, account.id},
    Account.password_reset_changeset(account, params)
  )
end)

# Error pattern-matching:
case Repo.transaction(multi) do
  {:ok, results} -> Map.keys(results)  # [{:account, 1}, {:account, 2}, ...]
  {:error, {:account, id}, changeset, _} -> "account #{id} failed"
end

Why: Multi operation names can be any term, not just atoms. Tuple keys like {:account, account.id} give each operation in a dynamically-generated collection a unique, structured name. On failure, the name in {:error, name, value, _} tells you exactly which item failed — without this, all accounts would compete for the same atom name, which would raise a duplicate name error.

Anti-pattern: Using a bare atom for all iterations, which raises at composition time:

# BAD — all operations have the same name :account — raises DuplicateNameError
Enum.reduce(accounts, Multi.new(), fn account, multi ->
  Multi.update(multi, :account, Account.password_reset_changeset(account, params))
end)

When to Use

Triggers:

  • You're building a Multi by reducing over a collection and need each item to be a separate named operation
  • You need to identify which specific item in a collection caused the transaction to fail
  • The result map keys must be inspectable to determine which items succeeded

Example — before:

# Forced to use Multi.run and handle errors manually
def reset_passwords(accounts, params) do
  Multi.new()
  |> Multi.run(:all_accounts, fn _repo, _changes ->
    results = Enum.map(accounts, fn account ->
      Repo.update(Account.password_reset_changeset(account, params))
    end)
    errors = Enum.filter(results, &match?({:error, _}, &1))
    if errors == [], do: {:ok, results}, else: {:error, hd(errors)}
  end)
end

Example — after:

def reset_passwords(accounts, params) do
  Enum.reduce(accounts, Multi.new(), fn account, multi ->
    Multi.update(
      multi,
      {:account, account.id},
      Account.password_reset_changeset(account, params)
    )
  end)
end

# Caller knows exactly which account failed:
case Repo.transaction(reset_passwords(accounts, params)) do
  {:ok, _} -> :ok
  {:error, {:account, id}, changeset, _} ->
    Logger.error("Failed to reset account #{id}: #{inspect(changeset.errors)}")
end

When NOT to Use

Don't use this when:

  • The collection has a fixed, known size — just name each operation with a distinct atom instead
  • You don't need per-item failure attribution — a Multi.run that processes all items may be simpler
  • All items should be processed regardless of individual failures — use a Multi.run that collects partial results

Over-application example:

# Tuple keys on a fixed two-item "collection"
[:primary, :secondary]
|> Enum.reduce(Multi.new(), fn role, multi ->
  Multi.insert(multi, {:membership, role}, Membership.changeset(user, role))
end)

Better alternative:

Multi.new()
|> Multi.insert(:primary_membership, Membership.changeset(user, :primary))
|> Multi.insert(:secondary_membership, Membership.changeset(user, :secondary))

Why: Tuple keys shine for truly dynamic collections where the size is not known at compile time. For small, fixed sets, distinct atom names are more readable and produce clearer error messages.


7. Multi.to_list/1 — Testing Without a Database

Source: lib/ecto/multi.ex#L88

test "dry run password reset" do
  account = %Account{password: "letmein"}
  multi = PasswordManager.reset(account, params)

  assert [
    {:account, {:update, account_changeset, []}},
    {:log, {:insert, log_changeset, []}},
    {:sessions, {:delete_all, query, []}}
  ] = Ecto.Multi.to_list(multi)

  assert account_changeset.valid?
end

Why: Multi.to_list/1 returns the list of operations in the Multi as {name, {operation_type, changeset_or_query, opts}} tuples. This allows you to assert on changeset validity, query structure, and operation order without running the transaction against a database. Tests that previously required database setup and teardown can become pure unit tests — faster and fully isolated.

Anti-pattern: Always running the transaction in tests even when only changeset validity is being checked:

# BAD — hits the database just to validate a changeset
test "password reset changeset is valid" do
  account = %Account{password: "letmein"}
  {:ok, %{account: updated}} =
    PasswordManager.reset(account, valid_params)
    |> Repo.transaction()

  # The changeset validity was the whole point, not the DB state
  assert updated.password != account.password
end

When to Use

Triggers:

  • You want to unit-test changeset validity or query construction without a database connection
  • Your test environment cannot or should not touch the database for a given test
  • You want to assert on operation names, order, or count in a Multi pipeline

Example — before:

# Tests require database + ExUnit.DataCase + fixtures
@tag :integration
test "creates user with profile" do
  {:ok, %{user: user, profile: profile}} =
    UserRegistration.multi(valid_params())
    |> Repo.transaction()

  assert user.email == "test@example.com"
  assert profile.user_id == user.id
end

Example — after:

# Pure unit test — no database, no fixtures, no DataCase
test "multi contains user insert with valid changeset" do
  multi = UserRegistration.multi(valid_params())

  assert [
    {:user, {:insert, user_changeset, []}},
    {:profile, _}
  ] = Ecto.Multi.to_list(multi)

  assert user_changeset.valid?
  assert Ecto.Changeset.get_change(user_changeset, :email) == "test@example.com"
end

When NOT to Use

Don't use this when:

  • Operations use the function variant (e.g., Multi.insert(:comment, fn %{post: post} -> ... end)) — those deferred functions are opaque in to_list output until the transaction runs
  • You need to test that the operations actually succeed against a real database (constraint checks, triggers, concurrent writes)
  • The Multi uses Multi.run with side effects — to_list cannot execute those callbacks

Over-application example:

# to_list can't help here — the changeset is hidden inside a function
multi =
  Multi.new()
  |> Multi.insert(:post, post_changeset)
  |> Multi.insert(:comment, fn %{post: post} ->
    Comment.changeset(post, params)  # This fn is opaque to to_list
  end)

# This assertion will fail or be meaningless
[{:comment, {:insert, cs, _}}] = Enum.drop(Ecto.Multi.to_list(multi), 1)
assert cs.valid?  # cs is a function, not a changeset

Better alternative:

# Test the function-variant changesets in isolation
test "comment changeset is valid given a post" do
  post = %Post{id: 1, title: "first"}
  changeset = Comment.changeset(post, valid_comment_params())
  assert changeset.valid?
end

Why: Multi.to_list/1 reflects the state of the pipeline at build time. Deferred values (function variants, Multi.run callbacks, Multi.merge functions) are not evaluated until Repo.transaction runs them. Test those deferred pieces independently rather than trying to inspect them through to_list.


Decision Tree

  • If operations are static and ordered → Multi.new() |> Multi.insert/update/delete
  • If an operation depends on a prior operation's result → use function variant fn changes -> changeset end
  • If later operations depend on runtime data to decide what to include → Multi.merge/2 with anonymous function
  • If you have reusable Multi fragments to combine → Multi.append/2 or Multi.prepend/2
  • If you're updating a dynamic collection → tuple keys {:operation, id}
  • If you want to validate changesets without hitting the DB → Multi.to_list/1 in tests
  • If operations are simple and static (no dynamic branching) → consider Repo.transaction(fn -> ... end) instead