Current section

Files

Jump to
x3m_system lib aggregate.ex
Raw

lib/aggregate.ex

defmodule X3m.System.Aggregate do
@moduledoc """
Behaviour and macros for defining an aggregate: the unit that decides how a command
is handled and how events change its state.
An aggregate is a plain module that `use`s this one. It declares its starting state
with `c:initial_state/0`, one `handle_msg/2` (or `handle_msg/3`) clause per command,
and an `apply_event/2` clause per event:
defmodule MyApp.Accounts.Aggregate do
use X3m.System.Aggregate
alias X3m.System.Message, as: SysMsg
defmodule State, do: defstruct [:id, balance: 0]
@impl X3m.System.Aggregate
def initial_state, do: %State{}
# validate with Command.new/2, then run Handler.process/2 on success
handle_msg :open_account, &Command.Open.new/2, &Handler.Open.process/2
# or a single function that returns {:block | :noblock, message, state}
handle_msg :deposit, fn %SysMsg{} = msg, %State{} = state ->
event = %Events.Deposited{id: state.id, amount: msg.request.amount}
msg =
msg
|> SysMsg.add_event(event)
|> SysMsg.ok()
{:block, msg, state}
end
def apply_event(%Events.Opened{} = e, %State{} = state),
do: %State{state | id: e.id}
def apply_event(%Events.Deposited{} = e, %State{} = state),
do: %State{state | balance: state.balance + e.amount}
end
A command handler returns one of:
* `{:block, message, state}` - there are events to persist; the message handler
saves them and replies once the commit succeeds.
* `{:noblock, message, state}` - nothing to persist; the response is returned as-is.
The macros wrap your functions with idempotency (a message whose id was already
processed returns `:ok` without re-running) and telemetry. State is rebuilt from the
event stream by replaying `apply_event/2`, so aggregates are event-sourced by default;
override `processed_message_id/1`, `commit/2` and `rollback/2` for finer control.
See the "Aggregates & Event Sourcing" guide for the full flow.
"""
defmodule State do
@moduledoc """
The wrapper state of an aggregate: your aggregate's own state (`client_state`) plus
its event-stream `version` and the set of already-processed message ids.
You meet it mostly in tests — `X3m.System.Aggregate.initial_state/1` returns it and
`apply_events/3` produces it — where you read `client_state` to assert on your
aggregate's state.
"""
@type t :: %__MODULE__{version: integer, client_state: any}
defstruct version: -1, client_state: nil, processed_messages: MapSet.new()
end
@doc """
Returns the aggregate's starting client state (before any events are applied).
"""
@callback initial_state :: map()
@doc """
Rebuilds the aggregate's client state from `loaded_state` — the state-based
(non-event-sourced) analogue of `c:initial_state/0`: "for this loaded data, give me back
your state".
Called by `X3m.System.GenAggregate.set_state/2` when a message handler hydrates a process
from saved state instead of replaying events (see the `when_pid_is_not_registered/3` override
in `X3m.System.MessageHandler`). Defaults to the identity — `loaded_state` is taken as the
client state — so override it only when the persisted shape differs from your client state.
"""
@callback set_state(loaded_state :: term()) :: client_state :: map()
@doc """
Builds the initial wrapped `State` for `aggregate_mod`, seeding `client_state` from its
`c:initial_state/0`.
Used by the aggregate process when it spawns, and handy in tests as the starting state
you pass to a command function.
"""
@spec initial_state(aggregate_mod :: module()) :: State.t()
def initial_state(aggregate_mod),
do: %State{client_state: apply(aggregate_mod, :initial_state, [])}
@doc """
Defines a command handler `msg_name/2` from a single `fun`.
`fun` receives the `X3m.System.Message` and the current client state and must return
`{:block | :noblock, message, state}`.
"""
defmacro handle_msg(msg_name, fun) do
quote do
@spec unquote(msg_name)(X3m.System.Message.t(), X3m.System.Aggregate.State.t()) ::
{:block | :noblock, X3m.System.Message.t(), X3m.System.Aggregate.State.t()}
def unquote(msg_name)(%X3m.System.Message{} = message, %State{} = state) do
if MapSet.member?(state.processed_messages, message.id) do
Logger.warning("This message was already processed by aggregate. Returning :ok")
message = X3m.System.Message.ok(message)
{:noblock, message, state}
else
X3m.System.Instrumenter.execute(:handle_msg, %{}, %{
aggregate: __MODULE__,
message: unquote(msg_name)
})
client_state = state.client_state || raise("Aggregate state wasn't set")
{label, %X3m.System.Message{} = message, client_state} =
unquote(fun).(message, client_state)
{label, message, %State{state | client_state: client_state}}
|> _post_processor()
end
end
end
end
@doc """
Defines a command handler `msg_name/2` that first validates, then processes.
`validate_fun` receives the message and client state. If it returns a halted message
(e.g. via `X3m.System.Message.put_request/2` on an invalid changeset) processing
stops and that message is returned. Otherwise `on_success` runs and must return
`{:block | :noblock, message, state}`.
"""
defmacro handle_msg(msg_name, validate_fun, on_success) do
quote do
@spec unquote(msg_name)(X3m.System.Message.t(), X3m.System.Aggregate.State.t()) ::
{:block | :noblock, X3m.System.Message.t(), X3m.System.Aggregate.State.t()}
def unquote(msg_name)(%X3m.System.Message{} = message, %State{} = state) do
if MapSet.member?(state.processed_messages, message.id) do
Logger.warning("This message was already processed by aggregate. Returning :ok")
message = X3m.System.Message.ok(message)
{:noblock, message, state}
else
X3m.System.Instrumenter.execute(:handle_msg, %{}, %{
aggregate: __MODULE__,
message: unquote(msg_name)
})
client_state = state.client_state || raise("Aggregate state wasn't set")
case unquote(validate_fun).(message, client_state) do
%X3m.System.Message{halted?: true} = message ->
{:noblock, message, state}
%X3m.System.Message{} = message ->
{label, %X3m.System.Message{} = message, client_state} =
unquote(on_success).(message, client_state)
{label, message, %State{state | client_state: client_state}}
end
|> _post_processor()
end
end
end
end
defmacro __using__(_opts) do
quote do
alias X3m.System.Aggregate
alias X3m.System.Aggregate.State, as: AggregateState
require Aggregate
require Logger
import Aggregate
@behaviour Aggregate
def apply_events(events, last_version, %AggregateState{} = state),
do: apply_events(events, last_version, nil, state)
@doc false
def apply_events([event | tail], last_version, metadata, %AggregateState{} = state) do
new_client_state = apply_event(event, state.client_state)
processed_messages =
if id = apply(__MODULE__, :processed_message_id, [metadata]) do
MapSet.put(state.processed_messages, id)
else
state.processed_messages
end
apply_events(tail, last_version, metadata, %AggregateState{
state
| processed_messages: processed_messages,
client_state: new_client_state
})
end
def apply_events([], last_version, _, %AggregateState{} = state),
do: %AggregateState{state | version: last_version}
def processed_message_id(nil), do: nil
def set_state(loaded_state), do: loaded_state
defoverridable set_state: 1
@before_compile X3m.System.Aggregate
end
end
defmacro __before_compile__(_env) do
quote do
def apply_event(_event, state),
do: state
def commit(%X3m.System.Message{}, _client_state),
do: :ok
def rollback(%X3m.System.Message{}, _client_state),
do: :ok
@spec _post_processor(
{:block | :noblock, X3m.System.Message.t(), X3m.System.Aggregate.State.t()}
) ::
{:block | :noblock, X3m.System.Message.t(), X3m.System.Aggregate.State.t()}
def _post_processor(response),
do: response
def processed_message_id(_catch_all), do: nil
end
end
end