State Modules
View SourceState modules separate the aggregate's data shape and event folding from its command validation and business rules. The aggregate decides WHAT happens; the state module decides HOW the data changes.
The "Default Read Model"
The aggregate state IS the complete truth about one process instance. It is the "default read model" -- the authoritative representation of everything that has happened to a single aggregate, built by replaying every event in order.
This matters because:
- Event payloads are a subset of aggregate state plus new data from the command
- Command payloads are a subset of aggregate state plus enrichment from outside (read models, user input)
- Data from outside the bounded context enters ONLY through enriched command payloads
- Inside the CMD flow (aggregate state, events, process managers) there are NO external lookups
The practical test: "Can I delete all read models, replay events, and get the same aggregate state?" If yes, the state module is correctly self-contained.
Why Separate State?
Without separation, the aggregate handles three concerns:
- Command validation and business rules
- Data shape and field access
- Event folding and serialization
Pulling the last two into a dedicated state module gives you:
| Concern | Aggregate | State Module |
|---|---|---|
| Command validation | Yes | No |
| Business rules | Yes | No |
| Data shape (record) | No | Yes |
| Field access | No | Yes |
| Event folding | No | Yes |
| Serialization | No | Yes |
The aggregate becomes a pure decision-maker. The state module becomes a pure data transformer.
The evoq_state Behaviour
The behaviour is defined in evoq_state and provides three required callbacks plus one optional.
Required Callbacks
new/1 -- Create initial (empty) state for a new aggregate.
-callback new(AggregateId :: binary()) -> State :: term().Called when an aggregate is first created. Returns the initial state before any events have been applied.
apply_event/2 -- Apply a single event to update state.
-callback apply_event(State :: term(), Event :: map()) -> State :: term().Must be pure and deterministic. Called for each event produced by execute/2, and again for every event each time the aggregate is loaded (replay, in stream version order, possibly starting from a snapshot's state in the middle of the stream). Same input must always produce same output, with no side effects.
to_map/1 -- Serialize state to a map.
-callback to_map(State :: term()) -> map().Used for session consistency feedback (dispatch_with_state/2), snapshot serialization, and programmatic state inspection.
Optional Callbacks
from_map/1 -- Deserialize state from a map.
-callback from_map(Map :: map()) -> {ok, State :: term()} | {error, Reason :: term()}.Used for snapshot loading and state reconstruction from external sources. If not implemented, snapshot loading is not available.
Declaring the State Module
An aggregate that uses a state module names it with the optional state_module/0 callback. evoq never calls it: it records the pairing for whoever reads the aggregate, and an aggregate without a state module leaves it out.
-module(counter_aggregate).
-behaviour(evoq_aggregate).
state_module() -> counter_state.The aggregate then delegates state operations to the state module:
init(AggregateId) ->
{ok, counter_state:new(AggregateId)}.
apply(State, Event) ->
counter_state:apply_event(State, Event).Complete Example
A counter aggregate that supports increment and decrement operations.
The State Record
%% include/counter_state.hrl
-record(counter_state, {
id :: binary(),
value = 0 :: integer(),
status = 0 :: non_neg_integer() %% bit flags
}).The State Module
-module(counter_state).
-behaviour(evoq_state).
%% The record lives in include/counter_state.hrl in your own project; it is
%% written out here so the example compiles as it stands.
-record(counter_state, {
id :: binary(),
value = 0 :: integer(),
status = 0 :: non_neg_integer() %% bit flags
}).
-export([new/1, apply_event/2, to_map/1, from_map/1]).
%% --- evoq_state callbacks ---
new(AggregateId) ->
#counter_state{id = AggregateId}.
apply_event(State, #{event_type := <<"CounterInitiated">>, data := Data}) ->
State#counter_state{
value = maps:get(initial_value, Data, 0),
status = evoq_bit_flags:set(0, 1) %% flag 1 = initiated
};
apply_event(#counter_state{value = V} = State, #{event_type := <<"CounterIncremented">>, data := #{amount := Amount}}) ->
State#counter_state{value = V + Amount};
apply_event(#counter_state{value = V} = State, #{event_type := <<"CounterDecremented">>, data := #{amount := Amount}}) ->
State#counter_state{value = V - Amount}.
to_map(#counter_state{id = Id, value = Value, status = Status}) ->
#{
id => Id,
value => Value,
status => Status
}.
from_map(#{id := Id, value := Value, status := Status}) ->
{ok, #counter_state{id = Id, value = Value, status = Status}};
from_map(_) ->
{error, invalid_state_map}.The Aggregate
-module(counter_aggregate).
-behaviour(evoq_aggregate).
%% include/counter_state.hrl, written out so the example compiles as it
%% stands.
-record(counter_state, {
id :: binary(),
value = 0 :: integer(),
status = 0 :: non_neg_integer() %% bit flags
}).
-export([state_module/0, init/1, execute/2, apply/2]).
state_module() -> counter_state.
init(AggregateId) ->
{ok, counter_state:new(AggregateId)}.
%% --- Command validation and business rules ---
execute(#counter_state{status = 0}, #{command_type := initiate_counter, initial_value := Value}) ->
{ok, [#{event_type => <<"CounterInitiated">>, data => #{initial_value => Value}}]};
execute(#counter_state{status = 0}, _Command) ->
{error, counter_not_initiated};
execute(#counter_state{}, #{command_type := increment, amount := Amount}) when Amount > 0 ->
{ok, [#{event_type => <<"CounterIncremented">>, data => #{amount => Amount}}]};
execute(#counter_state{}, #{command_type := increment, amount := Amount}) when Amount =< 0 ->
{error, {invalid_amount, Amount}};
execute(#counter_state{value = V}, #{command_type := decrement, amount := Amount})
when Amount > 0, V >= Amount ->
{ok, [#{event_type => <<"CounterDecremented">>, data => #{amount => Amount}}]};
execute(#counter_state{value = V}, #{command_type := decrement, amount := Amount})
when Amount > 0, V < Amount ->
{error, {would_go_negative, V, Amount}};
execute(_State, _Command) ->
{error, unknown_command}.
%% --- Delegate event folding to state module ---
apply(State, Event) ->
counter_state:apply_event(State, Event).Notice how the aggregate focuses entirely on command validation (business rules, guards, preconditions) while the state module handles data transformation. The aggregate pattern-matches on state to enforce rules but never directly modifies the record.
Conventions
Naming
State modules follow the pattern: the noun that the aggregate manages, suffixed with _state.
| Aggregate | State Module | Header |
|---|---|---|
| venture_aggregate | venture_state | venture_state.hrl |
| order_aggregate | order_state | order_state.hrl |
| counter_aggregate | counter_state | counter_state.hrl |
Record and Header
The state record is defined in a header file named after the state module. Both the aggregate and the state module include it:
%% In counter_state.erl
-include("counter_state.hrl").
%% In counter_aggregate.erl
-include("counter_state.hrl").This allows the aggregate to pattern-match on state fields for command validation without duplicating the record definition.
Purity
apply_event/2 MUST be pure and deterministic:
- No side effects (no I/O, no process messaging, no ETS writes)
- No calls to erlang:system_time or other non-deterministic functions: decide the time in the handler (execute/2), put it in the event, and copy it here
- Same event applied to same state always produces same result
- This guarantees correct replay from the event store, whether from the first event or from a snapshot
Serialization
to_map/1 serves three purposes:
- Session consistency --
dispatch_with_state/2returns the aggregate's state to the caller as well as its events, so a session sees the result of its own write without waiting for a projection. This is a deliberate departure from the reference CQRS model, where a command returns only a status; seeintegration_actors.md#session-consistency. - Snapshot serialization -- Snapshots store the aggregate state for faster loading
- Programmatic inspection -- Debugging, monitoring, and tooling can inspect state without knowing the record structure
Status as Bit Flags
Status fields in state records MUST be integers treated as bit flags. Use evoq_bit_flags for all status manipulation:
-define(INITIATED, 1). %% 2^0
-define(ACTIVE, 2). %% 2^1
-define(SUSPENDED, 4). %% 2^2
-define(ARCHIVED, 8). %% 2^3
%% include/counter_state.hrl, written out so the example compiles as it
%% stands.
-record(counter_state, {id :: binary(), value = 0 :: integer(), status = 0 :: non_neg_integer()}).
apply_event(State, #{event_type := <<"CounterInitiated">>} = _Event) ->
State#counter_state{
status = evoq_bit_flags:set(0, ?INITIATED)
}.Testing State Modules
State modules are easy to test in isolation since they are pure functions:
-module(counter_state_tests).
-include_lib("eunit/include/eunit.hrl").
%% include/counter_state.hrl, written out so the example compiles as it
%% stands.
-record(counter_state, {id :: binary(), value = 0 :: integer(), status = 0 :: non_neg_integer()}).
new_test() ->
State = counter_state:new(<<"counter-018f6a7b8c9d4e5f60718293a4b5c6dd">>),
?assertEqual(<<"counter-018f6a7b8c9d4e5f60718293a4b5c6dd">>, State#counter_state.id),
?assertEqual(0, State#counter_state.value).
apply_increment_test() ->
State0 = counter_state:new(<<"counter-018f6a7b8c9d4e5f60718293a4b5c6dd">>),
Event = #{event_type => <<"CounterIncremented">>, data => #{amount => 5}},
State1 = counter_state:apply_event(State0, Event),
?assertEqual(5, State1#counter_state.value).
roundtrip_serialization_test() ->
State0 = counter_state:new(<<"counter-018f6a7b8c9d4e5f60718293a4b5c6dd">>),
Map = counter_state:to_map(State0),
{ok, State1} = counter_state:from_map(Map),
?assertEqual(State0, State1).Because apply_event/2 is pure, you can test every event type without standing up any infrastructure. Compose events to test complex state transitions:
-include_lib("eunit/include/eunit.hrl").
%% include/counter_state.hrl, written out so the example compiles as it
%% stands.
-record(counter_state, {id :: binary(), value = 0 :: integer(), status = 0 :: non_neg_integer()}).
full_lifecycle_test() ->
State0 = counter_state:new(<<"counter-018f6a7b8c9d4e5f60718293a4b5c6dd">>),
Events = [
#{event_type => <<"CounterInitiated">>, data => #{initial_value => 10}},
#{event_type => <<"CounterIncremented">>, data => #{amount => 5}},
#{event_type => <<"CounterDecremented">>, data => #{amount => 3}}
],
Final = lists:foldl(fun counter_state:apply_event/2, State0, Events),
?assertEqual(12, Final#counter_state.value).Next Steps
- Aggregates -- Command validation and business rules
- Projections -- Build read models from events
- Integration Actors -- Move facts across boundaries
- Bit Flags -- Status field conventions