Packages
x3m_system
0.9.1
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
retired
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
retired
0.7.7
0.7.6
retired
0.7.5
0.7.4
retired
0.7.3
retired
0.7.2
0.7.1
0.7.0
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
retired
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.1.1
0.1.0
Building blocks for distributed and/or CQRS/ES systems
Current section
Files
Jump to
Current section
Files
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