End-to-end examples of common patterns. The samples follow the documented conventions: context-scoped module layout, private fetchers dispatching to context functions, and presence-preserving handling of Enact.updates/2 output.
1. Embedded data end-to-end
An order with line items: nested input casting, per-item validation, batch resolution of item references, and replace-wholesale persistence. The item schema is defined before the parent, because an alias referenced by embeds_many must resolve when the schema block expands.
# lib/my_app/orders/inputs/line_item_input.ex
defmodule MyApp.Orders.Inputs.LineItemInput do
use Ecto.Schema
import Ecto.Changeset
# item schemas take the bare import — changeset/2 is invoked by
# cast_embed, so they do not adopt the behaviour via use
import Enact.InputSchema, only: [cast_input: 3]
@primary_key false
embedded_schema do
field :product_id, :string
field :quantity, :integer
end
def changeset(item, params) do
item
|> cast_input(params, [:product_id, :quantity])
|> validate_required([:product_id, :quantity])
|> validate_number(:quantity, greater_than: 0)
end
end# lib/my_app/orders/inputs/order_input.ex
defmodule MyApp.Orders.Inputs.OrderInput do
use Ecto.Schema
use Enact.InputSchema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :note, :string
embeds_many :items, MyApp.Orders.Inputs.LineItemInput
end
@impl Enact.InputSchema
def changeset(base, params, :create) do
base
|> cast_input(params, [:note])
|> cast_embed(:items, required: true)
end
@impl Enact.InputSchema
def fields(:create), do: [:note, :items]
end# lib/my_app/orders/actions/create_order.ex
defmodule MyApp.Orders.Actions.CreateOrder do
use Enact.Action
alias MyApp.Catalog
alias MyApp.Orders.Inputs.OrderInput
alias MyApp.Orders.Order
@impl Enact.Action
def input, do: OrderInput
@impl Enact.Action
def authorize(ctx), do: MyApp.Policy.can?(ctx.actor, :create_order)
@impl Enact.Action
def resolvers do
[products: {[:items, :product_id], &fetch_products/2}]
end
@impl Enact.Action
def execute(changeset, ctx) do
updates = Enact.updates(changeset, ctx)
products = ctx.assigns.products
# items arrive as plain maps; swap public IDs for internal foreign keys
# by reading the batch lookup map
items =
Enum.map(updates.items, fn item ->
%{item | product_id: products[item.product_id].id}
end)
%Order{org_id: ctx.actor.org.id}
|> Order.changeset(%{updates | items: items})
|> ctx.repo.insert()
end
# one query for all unique IDs; scoped by the trust anchor first, and
# precise messages only for records the scoped query returned
defp fetch_products(public_ids, ctx) do
ctx.actor
|> Catalog.get_products_by_public_ids(public_ids)
|> Map.new(fn product ->
value = if product.active?, do: product, else: {:error, "is no longer available"}
{product.public_id, value}
end)
end
endAn unknown or cross-tenant product_id is absent from the fetcher's result map and renders the generic "not found" at the correct item index. A deactivated product in the caller's own tenant renders the precise message. Both arrive as ordinary 422 field errors.
2. Flattening an embed into columns
The API models an address as a nested object; the table stores flat columns. The translation lives in execute/2, and each of the three presence cases (omitted, explicit null, provided) must be handled:
# lib/my_app/customers/actions/update_customer.ex
defmodule MyApp.Customers.Actions.UpdateCustomer do
use Enact.Action
alias MyApp.Customers
alias MyApp.Customers.Customer
alias MyApp.Customers.Inputs.CustomerInput
@impl Enact.Action
def config, do: [mode: :patch]
@impl Enact.Action
def input, do: CustomerInput
@impl Enact.Action
def load_subject(%{"id" => id}, ctx), do: Customers.get_customer(ctx.actor, id)
@impl Enact.Action
def authorize(ctx), do: MyApp.Policy.can?(ctx.actor, :update, ctx.subject)
@impl Enact.Action
def execute(changeset, ctx) do
updates =
changeset
|> Enact.updates(ctx)
|> flatten_address()
ctx.subject
|> Customer.changeset(updates)
|> ctx.repo.update()
end
@address_columns ~w(address_line1 address_city address_postal_code)a
defp flatten_address(updates) do
case Map.fetch(updates, :address) do
# omitted → columns untouched
:error ->
updates
# explicit null → clear every column
{:ok, nil} ->
updates
|> Map.delete(:address)
|> Map.merge(Map.from_keys(@address_columns, nil))
# provided → the embed arrives as a plain map; spread it
{:ok, address} ->
updates
|> Map.delete(:address)
|> Map.merge(%{
address_line1: address.line1,
address_city: address.city,
address_postal_code: address.postal_code
})
end
end
endUse Map.fetch/2, not Map.get/2. Map.get returns nil for both "omitted" and "explicit null", collapsing two cases that must map differently. In the input module, from_subject/1 leaves the address embed unseeded; a validation that needs the current address reads ctx.subject directly.
3. Reading resolver assigns in execute/2
The scalar counterpart of recipe 1's batch lookup. Resolution is presence-gated, so whenever a non-nil reference appears in updates, the resolved record is present in ctx.assigns. Handle the three presence cases explicitly:
@impl Enact.Action
def resolvers do
[owner: {:owner_id, &fetch_owner/2}]
end
@impl Enact.Action
def execute(changeset, ctx) do
updates =
changeset
|> Enact.updates(ctx)
|> translate_owner(ctx)
ctx.subject
|> Project.changeset(updates)
|> ctx.repo.update()
end
defp translate_owner(updates, ctx) do
case Map.fetch(updates, :owner_id) do
# omitted → untouched
:error -> updates
# explicit null → the foreign key clears; nil passes through
{:ok, nil} -> updates
# provided → resolved and re-authorized; swap public ID for internal
{:ok, _public_id} -> %{updates | owner_id: ctx.assigns.owner.id}
end
end
defp fetch_owner(public_id, ctx) do
case MyApp.Accounts.get_org_user(ctx.actor, public_id) do
nil -> :error
user -> {:ok, user}
end
end4. Dry-run previews in an MCP response
A two-phase confirmation flow for an agent-facing tool. preview.updates is plain atom-keyed data, embeds included, so it JSON-encodes directly. Serialize preview.subject for the current side of the diff. The digest, not the serialized display, carries confirmation integrity, so the display format can be reshaped freely.
defmodule MyAppWeb.MCP.UpdateProjectTool do
alias MyApp.Projects
# Phase 1: no confirmation token → validate fully, reflect back, change nothing
def call(params, scope) do
case Projects.update_project_dry_run(params, actor: scope) do
{:ok, preview} ->
current =
preview.subject
|> Projects.serialize()
|> Map.take(Map.keys(preview.updates))
%{
status: "needs_confirmation",
changes: preview.updates,
current: current,
# resolver names only — loaded records never reach the agent; if
# the UI needs display info, render it host-side from your own reads
resolved: preview.resolved,
confirm_digest: preview.digest
}
{:error, error} ->
render_error(error)
end
end
# Phase 2: same params + the digest → execute
def call(params, scope, confirm_digest) do
case Projects.update_project(params, actor: scope, confirm_digest: confirm_digest) do
{:ok, project} ->
%{status: "done", project: Projects.serialize(project)}
{:error, %Enact.Error{type: :conflict}} ->
%{status: "stale", message: "The change no longer matches what was confirmed. Preview again."}
{:error, error} ->
render_error(error)
end
end
endThe full pipeline runs again on confirm — authorization, validation, resolution — so a digest match with changed world state still surfaces :invalid or :not_found normally. The digest binds the action, mode, locator params, and updates map. Both phases emit separate telemetry events, so audit trails count previews and executions separately.
5. Empty-string-at-rest columns
Most optional text should be nullable at rest, with NULL as the single representation of empty; those fields need none of the following (see the column convention in the Change Detection guide). On a NOT NULL DEFAULT '' column, however, "" is a valid value, and default cast behavior converts it to nil, which violates the NOT NULL constraint at persistence. The pattern: declare the exception in cast_input/4's keep_empty_strings: option, and reject explicit null in the action.
# lib/my_app/customers/inputs/customer_input.ex
defmodule MyApp.Customers.Inputs.CustomerInput do
use Ecto.Schema
use Enact.InputSchema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :name, :string
field :summary, :string
end
@scalars ~w(name summary)a
# summary is not in @required: on create, omission must be allowed so
# the column default applies, and validate_required would reject it
@required ~w(name)a
# NOT NULL DEFAULT '' columns: "" is a value, so empties must survive
# casting instead of coalescing to nil
@empty_string_text ~w(summary)a
@impl Enact.InputSchema
def changeset(base, params, :create) do
base
|> cast_input(params, @scalars, keep_empty_strings: @empty_string_text)
|> validate_required(@required)
end
# the :patch head applies the same cast; from_subject/1 projects the
# stored value (possibly "") like any other scalar
endThe emptiness test is trimmed, so " " counts as empty, and the keep_empty_strings: disposition stores it as "" rather than coalescing to nil.
Explicit null is the remaining case. The field must never be null but may be blank, which validate_required cannot express: it rejects both nil and blank strings. Only presence distinguishes "omitted" (allowed — the column default applies) from "explicit null" (an error), so the rule lives in the action's validate/2:
@impl Enact.Action
def validate(changeset, ctx) do
if Enact.provided?(ctx, :summary) and is_nil(get_field(changeset, :summary)) do
add_error(changeset, :summary, "can't be null")
else
changeset
end
endThe cases resolve as follows: "" persists as ""; " " counts as empty and persists as ""; explicit null returns a 422 on :summary; omitted leaves the field untouched, and create inserts fall to the column default. The drift test in the Testing guide catches any ""-at-rest field wired with a plain cast.
6. Partial updates on a singular embed
The default embed contract is replace-wholesale. For a singular config object — a booking_policy with several flags — that forces callers to send the whole object to change one flag. Declaring the embed in the input module's partial_embeds/1 manifest switches it to partial-object semantics: Enact.updates/2 filters its sub-keys by presence, so the updates map, previews, and confirmation digests carry exactly what the caller sent, one level down. (embeds_many cannot be declared partial — merging arrays requires item identity, which is a different contract; the guardrails enforce this.)
PATCH { "booking_policy": { "allow_booking": false } }The item schema stays shape-only. Partial sends cast over an empty struct, so validate_required here would wrongly reject them:
# lib/my_app/scheduling/inputs/booking_policy_input.ex
defmodule MyApp.Scheduling.Inputs.BookingPolicyInput do
use Ecto.Schema
import Enact.InputSchema, only: [cast_input: 3]
@primary_key false
embedded_schema do
field :allow_booking, :boolean
field :allow_reschedule, :boolean
end
def changeset(item, params) do
cast_input(item, params, [:allow_booking, :allow_reschedule])
end
end# lib/my_app/scheduling/inputs/link_input.ex
defmodule MyApp.Scheduling.Inputs.LinkInput do
use Ecto.Schema
use Enact.InputSchema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :name, :string
embeds_one :booking_policy, MyApp.Scheduling.Inputs.BookingPolicyInput
end
@impl Enact.InputSchema
def changeset(base, params, _mode) do
base
|> cast_input(params, [:name])
|> cast_embed(:booking_policy)
end
@impl Enact.InputSchema
def fields(_mode), do: [:name, :booking_policy]
@impl Enact.InputSchema
def from_subject(link), do: %__MODULE__{name: link.name}
@impl Enact.InputSchema
def partial_embeds(_mode), do: [:booking_policy]
endexecute/2 merges the partial object over the current value using Enact.merged/4 — the same function merged-result validations use, so the merge semantics have one definition:
@impl Enact.Action
def execute(changeset, ctx) do
updates =
changeset
|> Enact.updates(ctx)
|> merge_booking_policy(changeset, ctx)
ctx.subject
|> Link.changeset(updates)
|> ctx.repo.update()
end
defp merge_booking_policy(updates, changeset, ctx) do
case Map.fetch(updates, :booking_policy) do
# provided → the result-state view is the merged object
{:ok, %{}} ->
%{updates | booking_policy: Enact.merged(changeset, ctx, :booking_policy)}
# omitted (untouched) or explicit null (clears the whole object)
_ ->
updates
end
endWhen the policy is stored as flat columns instead of an embed, the merge disappears: provided sub-keys become columns, and omitted columns stay out of the write — untouched by the same presence semantics that protect top-level fields:
defp flatten_booking_policy(updates, ctx) do
case Map.fetch(updates, :booking_policy) do
{:ok, %{} = partial} ->
updates
|> Map.delete(:booking_policy)
# atoms derive from the input schema's closed field set
|> Map.merge(Map.new(partial, fn {key, value} -> {:"policy_#{key}", value} end))
{:ok, nil} ->
updates
|> Map.delete(:booking_policy)
|> Map.merge(%{policy_allow_booking: nil, policy_allow_reschedule: nil})
:error ->
updates
end
endThe cases resolve as follows: a provided sub-key merges; an explicitly-null sub-key is present as nil and clears that flag; an omitted sub-key is absent and stays untouched; a null for the whole object clears everything. Previews and digests contain exactly the provided sub-keys, so the user confirms the partial change itself.
Validating the merged result
Validations see the partially-cast object, not the merged result — the base never seeds embeds, so get_field on the item changeset returns nil for omitted sub-keys. Per-key intrinsic rules (formats, bounds) still work in the item schema, since Ecto validators skip absent and nil changes. But a rule about the merged policy belongs in the action's validate/2, against the result-state view built by Enact.merged/4 — the same function the execute merge uses:
@impl Enact.Action
def validate(changeset, ctx) do
policy = Enact.merged(changeset, ctx, :booking_policy)
if policy.allow_booking or policy.allow_reschedule do
changeset
else
add_error(changeset, :booking_policy, "must keep at least one option enabled")
end
endmerged/4 reads each sub-key from the caller's casted value where provided — including explicit nulls, which correctly read as clears — and from ctx.subject where not. The key list derives from the schema by default. See its documentation for the full case table (whole-object nulls, missing current objects).
Displaying partial changes in a confirmation flow
A confirmation preview serves two purposes. The digest binds the delta: the exact change the user confirms. The display provides context: the current and resulting values. Bind the delta; display both. Extending recipe 4's response shape:
current = Scheduling.serialize(preview.subject).booking_policy
resulting = Map.merge(current, preview.updates.booking_policy)
%{
changes: preview.updates, # the delta; bound by the digest
current: %{booking_policy: current},
resulting: %{booking_policy: resulting}, # display context; not digest-bound
confirm_digest: preview.digest
}resulting is computed against current state, which can change before the confirming run. The design makes no reservation: the confirming run re-validates against current state, and the confirmed delta is what executes.