The Decider Pattern
View Source< Hexagonal Architecture | Up: Patterns & Recipes | Index | Handler Stacks >
The Decider pattern is a functional approach to event-sourced domain logic. It separates domain behaviour into three pure functions:
- Decide - interpret a command against current state, produce events
- Evolve - apply events to state to produce new state
- Persist - store the events and/or updated state
Skuld's Command and EventAccumulator effects map directly to this pattern, keeping the decide/evolve logic pure and effectful while persistence is handled by the handler stack.
The pattern
Command → Decide(state, command) → Events
Events → Evolve(state, events) → New State
Events → Persist(events) → Side EffectsExample: a shopping cart
Domain events and commands
defmodule Cart.Events do
defmodule ItemAdded do
defstruct [:cart_id, :item_id, :quantity, :price]
end
defmodule ItemRemoved do
defstruct [:cart_id, :item_id]
end
defmodule CartCheckedOut do
defstruct [:cart_id, :total]
end
end
defmodule Cart.Commands do
defmodule AddItem do
defstruct [:cart_id, :item_id, :quantity, :price]
end
defmodule RemoveItem do
defstruct [:cart_id, :item_id]
end
defmodule Checkout do
defstruct [:cart_id]
end
endEvolve (pure function)
defmodule Cart.State do
defstruct items: %{}, checked_out: false
def evolve(state, %Cart.Events.ItemAdded{item_id: id, quantity: qty, price: price}) do
put_in(state.items[id], %{quantity: qty, price: price})
end
def evolve(state, %Cart.Events.ItemRemoved{item_id: id}) do
update_in(state.items, &Map.delete(&1, id))
end
def evolve(state, %Cart.Events.CartCheckedOut{}) do
%{state | checked_out: true}
end
endDecide (effectful - uses Command + EventAccumulator)
defmodule Cart.Handler do
use Skuld.Syntax
alias Cart.{Events, Commands, State}
def handle(%Commands.AddItem{} = cmd) do
comp do
state <- State.get()
_ <- if state.checked_out, do: Throw.throw(:already_checked_out)
event = %Events.ItemAdded{
cart_id: cmd.cart_id,
item_id: cmd.item_id,
quantity: cmd.quantity,
price: cmd.price
}
_ <- EventAccumulator.emit(event)
new_state = State.evolve(state, event)
_ <- State.put(new_state)
{:ok, new_state}
end
end
def handle(%Commands.RemoveItem{} = cmd) do
comp do
state <- State.get()
_ <- if state.checked_out, do: Throw.throw(:already_checked_out)
_ <- if not Map.has_key?(state.items, cmd.item_id),
do: Throw.throw(:item_not_found)
event = %Events.ItemRemoved{
cart_id: cmd.cart_id,
item_id: cmd.item_id
}
_ <- EventAccumulator.emit(event)
new_state = State.evolve(state, event)
_ <- State.put(new_state)
{:ok, new_state}
end
end
def handle(%Commands.Checkout{cart_id: cart_id}) do
comp do
state <- State.get()
_ <- if state.checked_out, do: Throw.throw(:already_checked_out)
_ <- if map_size(state.items) == 0, do: Throw.throw(:empty_cart)
total = state.items
|> Map.values()
|> Enum.reduce(0, fn %{quantity: q, price: p}, acc ->
acc + q * p
end)
event = %Events.CartCheckedOut{cart_id: cart_id, total: total}
_ <- EventAccumulator.emit(event)
new_state = State.evolve(state, event)
_ <- State.put(new_state)
{:ok, %{state: new_state, total: total}}
end
end
endRunning it
{result, events} =
comp do
{:ok, _} <- Command.execute(%AddItem{
cart_id: "c1", item_id: "widget", quantity: 2, price: 1000
})
{:ok, _} <- Command.execute(%AddItem{
cart_id: "c1", item_id: "gadget", quantity: 1, price: 2500
})
{:ok, checkout} <- Command.execute(%Checkout{cart_id: "c1"})
checkout
end
|> Command.with_handler(&Cart.Handler.handle/1)
|> EventAccumulator.with_handler(output: fn r, evts -> {r, evts} end)
|> State.with_handler(%Cart.State{})
|> Throw.with_handler()
|> Comp.run!()
# result is %{state: ..., total: 4500}
# events is [%ItemAdded{...}, %ItemAdded{...}, %CartCheckedOut{...}]Persisting events
The output function on EventAccumulator is where you'd publish
events to an event store, message bus, or database:
|> EventAccumulator.with_handler(
output: fn result, events ->
# Persist to event store
MyApp.EventStore.append(events)
# Publish to subscribers
MyApp.EventBus.publish(events)
result
end
)Testing
The entire decide/evolve pipeline is pure and testable without a database:
test "checkout calculates correct total" do
{result, events} =
comp do
{:ok, _} <- Command.execute(%AddItem{
cart_id: "c1", item_id: "a", quantity: 3, price: 100
})
{:ok, checkout} <- Command.execute(%Checkout{cart_id: "c1"})
checkout
end
|> Command.with_handler(&Cart.Handler.handle/1)
|> EventAccumulator.with_handler(output: fn r, e -> {r, e} end)
|> State.with_handler(%Cart.State{})
|> Throw.with_handler()
|> Comp.run!()
assert %{total: 300} = result
assert [%ItemAdded{}, %CartCheckedOut{total: 300}] = events
end
test "cannot checkout empty cart" do
result =
Command.execute(%Checkout{cart_id: "c1"})
|> Command.with_handler(&Cart.Handler.handle/1)
|> EventAccumulator.with_handler(output: fn r, e -> {r, e} end)
|> State.with_handler(%Cart.State{})
|> Throw.with_handler()
|> Comp.run()
assert {:thrown, :empty_cart} = result
endWhen to use this
The Decider pattern is a good fit when:
- You need an audit trail of domain events
- Business rules depend on the history of what happened
- Multiple projections (read models) are derived from the same events
- You want to test domain logic without persistence
It's overkill for simple CRUD - use Port contracts directly for that.
< Hexagonal Architecture | Up: Patterns & Recipes | Index | Handler Stacks >