Domain and Integration Artifacts
View Sourceevoq distinguishes between domain artifacts (internal to a bounded context) and integration artifacts (crossing boundaries). This guide explains the 4 artifact types and when to use each.
The Problem
Without formal contracts, commands and events are untyped maps. This leads to:
- Inconsistent APIs across modules
- Missing callbacks discovered at runtime, not compile time
- No distinction between internal events and external-facing payloads
- No type discovery -- you cannot programmatically ask "what commands exist?"
The 4 Artifact Types
| Artifact | Level | Key Type | Serialization | Direction |
|---|---|---|---|---|
| Command | Domain | atom | Erlang terms | Inbound (API to aggregate) |
| Event | Domain | atom | Erlang terms | Internal (aggregate to handlers) |
| Fact | Integration | binary | JSON | Outbound (event to pg/mesh) |
| Hope | Integration | binary | JSON | Outbound RPC (agent to agent) |
Domain Artifacts: Commands and Events
Domain artifacts stay inside the bounded context. They use atom keys because they never leave the BEAM VM.
Commands represent intentions. They are validated before execution.
Events represent facts. They are immutable once stored. Event construction does not return {ok, _} -- if the handler validated the command, the event is always valid.
Integration Artifacts: Facts and Hopes
Integration artifacts cross boundaries -- between bounded contexts, between BEAM nodes, or between agents over the network. They use binary keys because they must be JSON-serializable.
Facts are fire-and-forget. A domain event happens, a fact is published. Nobody waits for a response.
Hopes are requests. An agent sends a hope and hopes for a response (distributed systems -- no guarantees). Think RPC, but honest about reliability.
evoq_command
Commands are the entry point to the domain. They carry user intent.
Callbacks
%% Required
-callback command_type() -> atom().
-callback new(Params :: map()) -> {ok, Command} | {error, Reason}.
-callback to_map(Command) -> map().
%% Optional
-callback validate(Command) -> ok | {ok, Command} | {error, Reason}.
-callback from_map(Map :: map()) -> {ok, Command} | {error, Reason}.Example
-module(initiate_venture_v1).
-behaviour(evoq_command).
-record(initiate_venture_v1, {
venture_id :: binary(),
name :: binary(),
brief :: binary() | undefined,
initiated_by :: binary() | undefined
}).
command_type() -> initiate_venture_v1.
new(#{name := Name} = Params) ->
{ok, #initiate_venture_v1{
venture_id = maps:get(venture_id, Params, generate_id()),
name = Name,
brief = maps:get(brief, Params, undefined),
initiated_by = maps:get(initiated_by, Params, undefined)
}};
new(_) ->
{error, missing_required_fields}.
validate(#initiate_venture_v1{name = Name}) when
not is_binary(Name); byte_size(Name) =:= 0 ->
{error, invalid_name};
validate(Cmd) ->
{ok, Cmd}.
to_map(#initiate_venture_v1{} = Cmd) ->
#{
command_type => initiate_venture_v1,
venture_id => Cmd#initiate_venture_v1.venture_id,
name => Cmd#initiate_venture_v1.name,
brief => Cmd#initiate_venture_v1.brief,
initiated_by => Cmd#initiate_venture_v1.initiated_by
}.Key Points
command_type/0returns an atom, typically the module nameto_map/1MUST include thecommand_typekeyvalidate/1is optional -- use it for domain validation beyond field presencefrom_map/1is optional -- use it at API boundaries for deserialization
evoq_event
Events are the source of truth. They record what happened.
Callbacks
%% Required
-callback event_type() -> atom().
-callback new(Params :: map()) -> Event.
-callback to_map(Event) -> map().
%% Optional
-callback from_map(Map :: map()) -> {ok, Event} | {error, Reason}.Example
-module(venture_initiated_v1).
-behaviour(evoq_event).
-record(venture_initiated_v1, {
venture_id :: binary(),
name :: binary(),
brief :: binary() | undefined,
initiated_by :: binary() | undefined,
initiated_at :: integer()
}).
event_type() -> venture_initiated_v1.
new(#{venture_id := VentureId, name := Name} = Params) ->
#venture_initiated_v1{
venture_id = VentureId,
name = Name,
brief = maps:get(brief, Params, undefined),
initiated_by = maps:get(initiated_by, Params, undefined),
initiated_at = erlang:system_time(millisecond)
}.
to_map(#venture_initiated_v1{} = E) ->
#{
event_type => venture_initiated_v1,
venture_id => E#venture_initiated_v1.venture_id,
name => E#venture_initiated_v1.name,
brief => E#venture_initiated_v1.brief,
initiated_by => E#venture_initiated_v1.initiated_by,
initiated_at => E#venture_initiated_v1.initiated_at
}.Key Points
new/1returns the event directly (no{ok, _}wrapper) -- event construction from a validated handler should not failto_map/1MUST include theevent_typekeyevent_typevalues can be atoms -- evoq auto-converts to binary for storagefrom_map/1is optional -- use it for typed deserialization during replay/rebuild
Atom event_type in aggregates
When to_map/1 returns an atom event_type, evoq handles the conversion:
- Storage:
evoq_aggregateconverts atom to binary viaresolve_event_type/1before appending to the event store - Replay: Events from the store have binary
event_type(as stored) - Aggregate apply: Your
apply/2should handle both atom (fresh execution) and binary (replay). A normalization clause at the top works well:
apply_event(#{event_type := Type} = E, S) when is_atom(Type) ->
apply_event(E#{event_type := atom_to_binary(Type, utf8)}, S);
apply_event(#{event_type := <<"venture_initiated_v1">>} = E, S) ->
%% handle event...evoq_fact
Facts translate domain events into integration payloads for external consumers.
Why Facts Are Separate From Events
Domain events are implementation details. They may change frequently, use internal identifiers, or contain data that external consumers should not see. Facts are explicit public contracts:
- Different name (event:
venture_initiated_v1, fact:<<"hecate.venture.initiated">>) - Different structure (subset of event data, binary keys)
- Different versioning lifecycle
Callbacks
%% Required
-callback fact_type() -> binary().
-callback from_event(EventType :: atom(), EventData :: map(), Metadata :: map()) ->
{ok, Payload :: map()} | skip.
%% Optional
-callback serialize(Payload :: map()) -> {ok, binary()} | {error, Reason}.
-callback deserialize(Binary :: binary()) -> {ok, map()} | {error, Reason}.
-callback schema() -> map().Example
-module(venture_initiated_fact_v1).
-behaviour(evoq_fact).
fact_type() -> <<"hecate.venture.initiated">>.
from_event(venture_initiated_v1, Data, _Metadata) ->
{ok, #{
<<"venture_id">> => maps:get(venture_id, Data),
<<"name">> => maps:get(name, Data),
<<"brief">> => maps:get(brief, Data, null),
<<"initiated_by">> => maps:get(initiated_by, Data, null),
<<"initiated_at">> => maps:get(initiated_at, Data)
}};
from_event(_Other, _Data, _Metadata) ->
skip.
serialize(Payload) -> evoq_fact:default_serialize(Payload).
deserialize(Binary) -> evoq_fact:default_deserialize(Binary).Key Points
fact_type/0returns a binary topic string (dot-separated namespace)from_event/3can returnskipif a particular event should not produce a fact- All map keys MUST be binaries (JSON-safe)
- Default
serialize/1anddeserialize/1use OTP 27jsonmodule -- callevoq_fact:default_serialize/1andevoq_fact:default_deserialize/1 - Fact modules live in the same desk directory as the event they translate (vertical slicing)
Where Facts Live
Per the vertical slicing principle, fact modules belong in the same desk as the event:
apps/guide_venture_lifecycle/src/initiate_venture/
initiate_venture_v1.erl # command
venture_initiated_v1.erl # event
venture_initiated_fact_v1.erl # fact (translates event for mesh/pg)
venture_initiated_v1_to_mesh.erl # emitter (publishes fact to mesh)
venture_initiated_v1_to_pg.erl # emitter (publishes fact to pg)evoq_hope
Hopes are outbound RPC requests. They represent one agent asking another to do something.
Callbacks
%% Required
-callback hope_type() -> binary().
-callback new(Params :: map()) -> {ok, Hope} | {error, Reason}.
-callback to_payload(Hope) -> map().
-callback from_payload(Payload :: map()) -> {ok, Hope} | {error, Reason}.
%% Optional
-callback validate(Hope) -> ok | {error, Reason}.
-callback serialize(Payload :: map()) -> {ok, binary()} | {error, Reason}.
-callback deserialize(Binary :: binary()) -> {ok, map()} | {error, Reason}.
-callback schema() -> map().Key Points
- Uses
to_payload/from_payload(notto_map/from_map) to distinguish from domain serialization new/1returns{ok, Hope}(unlike events) because RPC requests may fail validation- No implementations exist yet -- the behaviour is defined for when RPC use cases arise
- Default serialization via
evoq_hope:default_serialize/1anddefault_deserialize/1
Backward Compatibility
All behaviours are opt-in:
- Existing command/event modules without
-behaviour(evoq_command)or-behaviour(evoq_event)continue to work exactly as before - Adding the behaviour declaration gives you compile-time enforcement (warnings for missing callbacks)
- evoq does not require modules to implement these behaviours -- the framework accepts plain maps
Migration Path
To adopt the behaviours in existing code:
- Add
-behaviour(evoq_command)or-behaviour(evoq_event)to the module - Export
command_type/0orevent_type/0 - Ensure
new/1andto_map/1exist with the expected signatures - Add atom normalization to your aggregate's
apply_event/2if switchingevent_typefrom binary to atom - Optionally create
evoq_factmodules for events that need external publication