The Decider Pattern
View Source< LiveView Integration | Up: Recipes | Index | Batch Loading >
Event-sourced domain logic where the core decision is a pure function. Effects handle state and persistence; the decider stays plain Elixir.
The pure decider
A decider is a function (command, state) -> events. It's pure: given
a command and current state, it produces a list of events. No database,
no side effects.
defmodule BankAccount do
def decide(%OpenAccount{initial_balance: balance}, nil) do
if balance < 0 do
[{:error, :negative_balance}]
else
[%AccountOpened{balance: balance}]
end
end
def decide(%Deposit{amount: amount}, state) do
[%AmountDeposited{amount: amount}]
end
def decide(%Withdraw{amount: amount}, state) do
if amount > state.balance do
[{:error, :insufficient_funds}]
else
[%AmountWithdrawn{amount: amount}]
end
end
endEvolve state from events
def evolve(nil, %AccountOpened{balance: balance}), do: %{balance: balance}
def evolve(state, %AmountDeposited{amount: amount}), do: %{state | balance: state.balance + amount}
def evolve(state, %AmountWithdrawn{amount: amount}), do: %{state | balance: state.balance - amount}Putting it together
Load state, call the pure decider, evolve, and persist:
defcomp handle(cmd) do
state <- State.get()
events = BankAccount.decide(cmd, state)
case events do
[{:error, reason}] ->
{:error, reason}
_ ->
_ <- Writer.tell(:events, events)
new_state = Enum.reduce(events, state, &evolve/2)
_ <- State.put(new_state)
{:ok, new_state}
end
enddecide and evolve are plain Elixir functions — testable without
effects. State and Writer handle persistence.
Streaming commands
Separate the pipeline into decide and evolve phases. Brook.flat_map
runs each command through the pure decider and flattens the resulting
event lists into a single stream. A second Brook.map persists each
event — with automatic N+1 batching via Query.Contract.
Event store contract
Define deffetch operations for persisting events:
defmodule EventStore do
use Skuld.Query
deffetch evolve(event :: term()) :: :ok
endThe pipeline
def process_stream(stream) do
stream
|> Brook.flat_map(fn cmd ->
comp do
state <- State.get()
BankAccount.decide(cmd, state)
end
end)
|> Brook.map(&EventStore.evolve/1, concurrency: 4)
endThe caller handles the source and sink:
commands
|> Brook.from_enum()
|> process_stream()
|> Brook.to_list()flat_map runs decide concurrently and flattens the event lists.
The second phase maps each event through evolve — and because
evolve is a deffetch operation under FiberPool, the
scheduler batches concurrent calls for the executor:
commands
|> Brook.from_enum()
|> process_stream()
|> Brook.to_list()
|> Skuld.Query.with_executor(EventStore, EventStore.EctoExecutor)
|> State.with_handler(%{balance: 0})
|> Writer.with_handler([], tag: :errors)
|> Channel.with_handler()
|> FiberPool.with_handler()
|> Comp.run!()The decider stays pure. The pipeline demonstrates composition:
Brook for streaming, flat_map for flattening, Query for
batched persistence — each a separate concern, combined into
a single computation.
< LiveView Integration | Up: Recipes | Index | Batch Loading >