Outputs and Effects
In this tutorial you build Actions that return plain outputs, deferred effect requests, and output envelopes. You then see how a Flow and an Agent receive those effects.
Mix.install([
{:jido, github: "mikehostetler/new_jido", branch: "main"}
])
Return a map
A normal Action output is a map. Jido.Exec.run/4 validates it against :output_schema and returns it.
defmodule ActionEffects.ApproveRefund do
alias Jido.Joi
use Jido.Action,
name: "approve_refund",
schema: Joi.object(%{order_id: Joi.string(), amount_cents: Joi.integer()}),
output_schema: Joi.object(%{refund_id: Joi.string(), amount_cents: Joi.integer()})
@impl true
def run(params, _context) do
{:ok, %{refund_id: "refund-" <> params.order_id, amount_cents: params.amount_cents}}
end
end
{:ok, %{refund_id: "refund-o1", amount_cents: 500}} =
Jido.Exec.run(ActionEffects.ApproveRefund, %{order_id: "o1", amount_cents: 500})
Any other bare value fails output validation, even when the Action succeeded. This protects callers that expect a map.
defmodule ActionEffects.RefundLabel do
use Jido.Action, name: "refund_label"
@impl true
def run(_params, _context), do: {:ok, "refund approved"}
end
{:error, %Jido.Error.ValidationError{message: "Action output validation must return a map"}} =
Jido.Exec.run(ActionEffects.RefundLabel)
Request effects
An effect is a request for work that the caller performs later. Return it in a list as the third element. Exec does not read or dispatch the list. The elements can be any term, so the caller and the Action agree on their meaning.
defmodule ActionEffects.IssueRefund do
use Jido.Action, name: "issue_refund"
@impl true
def run(%{order_id: order_id}, _context) do
{:ok, %{order_id: order_id, status: :issued},
[{:send_email, order_id}, {:notify_ledger, order_id}]}
end
end
{:ok, %{order_id: "o1", status: :issued}, effects} =
Jido.Exec.run(ActionEffects.IssueRefund, %{order_id: "o1"})
[{:send_email, "o1"}, {:notify_ledger, "o1"}] = effects
Handle both result shapes in the caller. An empty list returns the two-element form.
defmodule ActionEffects.MaybeNotify do
use Jido.Action, name: "maybe_notify"
@impl true
def run(_params, _context), do: {:ok, %{checked: true}, []}
end
{:ok, %{checked: true}} = Jido.Exec.run(ActionEffects.MaybeNotify)
The third element is only for effects. A value that is not a proper list is an error.
defmodule ActionEffects.BadEffects do
use Jido.Action, name: "bad_effects"
@impl true
def run(_params, _context), do: {:ok, %{}, %{trace: "abc"}}
end
{:error, %Jido.Error.ExecutionError{details: %{reason: :invalid_effects}}} =
Jido.Exec.run(ActionEffects.BadEffects)
Discard effects on failure
A failed Action exposes no effects. Exec accepts {:error, reason, effects} and drops the list. Output validation failure also drops the list.
defmodule ActionEffects.RejectRefund do
use Jido.Action, name: "reject_refund"
@impl true
def run(_params, _context), do: {:error, :over_limit, [{:send_email, "never"}]}
end
{:error, %Jido.Error.ExecutionError{details: %{reason: :over_limit}}} =
Jido.Exec.run(ActionEffects.RejectRefund)
defmodule ActionEffects.BadOutputWithEffects do
use Jido.Action,
name: "bad_output_with_effects",
output_schema: Jido.Joi.object(%{refund_id: Jido.Joi.string()})
@impl true
def run(_params, _context), do: {:ok, %{refund_id: 42}, [{:send_email, "never"}]}
end
{:error, %Jido.Error.ValidationError{}} = Jido.Exec.run(ActionEffects.BadOutputWithEffects)
Return a value that is not a map
Use Jido.Action.Output when a successful result must not be a map. The envelope states the intent, so a stray bare value still fails.
| Constructor | Kind | Value rule | Output schema |
|---|---|---|---|
Jido.Action.Output.raw/2 |
:raw |
Any term | Not applied |
Jido.Action.Output.stream/2 |
:stream |
Enumerable, not consumed | Not applied |
Jido.Action.Output.batch/2 |
:batch |
Proper list | Not applied |
Jido.Action.Output.opaque/2 |
:opaque |
Any term | Not applied |
Jido.Action.Output.preserve/2 |
:preserve |
Must parse to a map or struct | Applied, root shape kept |
Each constructor takes an optional meta: map. Constructors raise ArgumentError on a value or option they do not accept.
defmodule ActionEffects.ExportRefunds do
alias Jido.Action.Output
use Jido.Action,
name: "export_refunds",
output_schema: Jido.Joi.object(%{never: Jido.Joi.string()})
@impl true
def run(%{format: :csv}, _context), do: {:ok, Output.raw("id,amount\no1,500\n")}
def run(%{format: :batch}, _context) do
{:ok, Output.batch([%{id: "o1"}, %{id: "o2"}], meta: %{count: 2}), [{:audit, :export}]}
end
def run(%{format: :stream}, %{test_pid: test_pid}) do
stream =
Stream.map(1..3, fn index ->
send(test_pid, :consumed)
%{index: index}
end)
{:ok, Output.stream(stream)}
end
end
{:ok, %Jido.Action.Output{kind: :raw, value: "id,amount\no1,500\n"}} =
Jido.Exec.run(ActionEffects.ExportRefunds, %{format: :csv})
{:ok, %Jido.Action.Output{kind: :batch, meta: %{count: 2}}, [{:audit, :export}]} =
Jido.Exec.run(ActionEffects.ExportRefunds, %{format: :batch})
{:ok, %Jido.Action.Output{kind: :stream, value: stream}} =
Jido.Exec.run(ActionEffects.ExportRefunds, %{format: :stream}, %{test_pid: self()})
:not_consumed =
receive do
:consumed -> :consumed_by_exec
after
0 -> :not_consumed
end
[%{index: 1}, %{index: 2}, %{index: 3}] = Enum.to_list(stream)
Exec returned the stream without running it, because no :consumed message arrived before the Enum.to_list/1 call. The required :never field shows that these kinds skip :output_schema.
A stream runs in the process that consumes it, after the Action task has exited. Do not build one that depends on the Action process.
preserve/2 is the exception. It applies the output schema and keeps the parsed root value. A normal output with a struct schema becomes a plain map at the Action boundary, so use this kind when the caller needs the struct.
defmodule ActionEffects.RefundSummary do
alias Jido.Action.Output
alias Jido.Joi
defstruct [:total_cents]
use Jido.Action,
name: "refund_summary",
output_schema: Joi.struct(__MODULE__, %{total_cents: Joi.integer()}, coerce: true)
@impl true
def run(%{total_cents: total, keep_struct: true}, _context) do
{:ok, Output.preserve(%{total_cents: total}, meta: %{source: "ledger"})}
end
def run(%{total_cents: total}, _context), do: {:ok, %{total_cents: total}}
end
{:ok, %{total_cents: 900} = plain} =
Jido.Exec.run(ActionEffects.RefundSummary, %{total_cents: 900})
false = is_struct(plain)
{:ok,
%Jido.Action.Output{
kind: :preserve,
value: %ActionEffects.RefundSummary{total_cents: 900},
meta: %{source: "ledger"}
}} = Jido.Exec.run(ActionEffects.RefundSummary, %{total_cents: 900, keep_struct: true})
{:error, %Jido.Error.ValidationError{}} =
Jido.Exec.run(ActionEffects.RefundSummary, %{total_cents: "nine", keep_struct: true})
Collect effects from a Flow
A Flow returns the effects of every successful component it ran, in dependency order. It returns no effects if it fails.
defmodule ActionEffects.Reserve do
use Jido.Action, name: "reserve_funds"
@impl true
def run(%{order_id: order_id}, _context) do
{:ok, %{order_id: order_id}, [{:reserved, order_id}]}
end
end
defmodule ActionEffects.Capture do
use Jido.Action, name: "capture_funds"
@impl true
def run(%{order_id: "declined"}, _context), do: {:error, :declined}
def run(%{order_id: order_id}, _context), do: {:ok, %{captured: order_id}, [{:captured, order_id}]}
end
defmodule ActionEffects.RefundFlow do
use Jido.Flow,
name: "refund_flow",
schema: Jido.Joi.object(%{order_id: Jido.Joi.string()})
flow do
step "reserve", action: ActionEffects.Reserve, params: %{order_id: input(:order_id)}
step "capture", action: ActionEffects.Capture, params: %{order_id: result("reserve", :order_id)}
output result("capture")
end
end
{:ok, %{captured: "o1"}, [{:reserved, "o1"}, {:captured, "o1"}]} =
Jido.Exec.run(ActionEffects.RefundFlow, %{order_id: "o1"})
{:error, %Jido.Error.ExecutionError{details: %{reason: :declined}}} =
Jido.Exec.run(ActionEffects.RefundFlow, %{order_id: "declined"})
The first step succeeded in the failed run, but its {:reserved, ...} request does not escape. The Action still ran, so a side effect that it performed directly is not undone. That is the reason to return a request and let the caller act after success.
Return Directives from an Agent route
An Agent is one caller that gives effects a defined meaning. When a route runs an Action, the Action output is the complete next Agent state, and the third element is a list of Directives. Jido.Agent.build_signal/3 builds a Signal from the Action context.
defmodule ActionEffects.RecordRefund do
use Jido.Action,
name: "record_refund",
schema: Jido.Joi.object(%{amount_cents: Jido.Joi.integer()})
@impl true
def run(%{amount_cents: amount}, context) do
state = %{context.agent_state | refunded_cents: context.agent_state.refunded_cents + amount}
with {:ok, signal} <-
Jido.Agent.build_signal(context, "refund.recorded", %{
refunded_cents: state.refunded_cents
}) do
{:ok, state, [Jido.Agent.Directive.emit(signal)]}
end
end
end
defmodule ActionEffects.RefundAgent do
use Jido.Agent, name: "refund_agent"
state do
schema Jido.Joi.object(%{refunded_cents: Jido.Joi.integer() |> Jido.Joi.default(0)})
end
routes do
signal_source "/refunds"
route "refund.record", ActionEffects.RecordRefund
end
end
agent = ActionEffects.RefundAgent.instantiate!(id: "refunds-1", state: %{refunded_cents: 100})
signal = Jido.Signal.new!("refund.record", %{amount_cents: 50}, source: "/billing")
{:ok, next_agent, [%Jido.Agent.Directive.Emit{signal: emitted}]} =
ActionEffects.RefundAgent.cmd(agent, signal)
%{refunded_cents: 150} = next_agent.state
"refund.recorded" = emitted.type
cmd/2 validates and returns the Directive. It does not dispatch it. Jido.AgentServer commits the state and then does the runtime work.