Files

713 lines
27 KiB
Markdown

# Ecto.Multi Patterns
Patterns extracted from Ecto's `Ecto.Multi` source code.
## Contents
1. [`Multi.new() |> Multi.insert/update/delete` — Named Operation Pipeline](#1-multinew--multiinsertupdatedelete--named-operation-pipeline)
2. [`Multi.run/3` — Arbitrary Code in a Transaction](#2-multirun3--arbitrary-code-in-a-transaction)
3. [Dependent Operations with Function Variants](#3-dependent-operations-with-function-variants)
4. [`Multi.merge/2` — Dynamic Transaction Composition](#4-multimerge2--dynamic-transaction-composition)
5. [`Multi.append/2` / `Multi.prepend/2` — Static Multi Composition](#5-multiappend2--multiprepend2--static-multi-composition)
6. [Tuple Keys — Dynamic Collections of Operations](#6-tuple-keys--dynamic-collections-of-operations)
7. [`Multi.to_list/1` — Testing Without a Database](#7-multito_list1--testing-without-a-database)
---
## 1. `Multi.new() |> Multi.insert/update/delete` — Named Operation Pipeline
**Source:** [lib/ecto/multi.ex#L58](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L58)
```elixir
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:
```elixir
# 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:**
```elixir
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:**
```elixir
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:**
```elixir
# Overkill for a single operation
Multi.new()
|> Multi.insert(:user, User.changeset(params))
|> Repo.transaction()
```
**Better alternative:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L39)
```elixir
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:
```elixir
# 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L298)
```elixir
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:
```elixir
# 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L239)
```elixir
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:
```elixir
# 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L183)
```elixir
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:
```elixir
# 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:**
```elixir
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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L109)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
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:**
```elixir
# 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:**
```elixir
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](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/multi.ex#L88)
```elixir
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:
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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:**
```elixir
# 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
<!-- PATTERN_COMPLETE -->