%% @doc Command behavior for evoq. %% %% Commands represent intentions to change state. They are: %% - Imperative (present tense): open_account, deposit_money %% - Targeted at a specific aggregate %% - Validated before dispatch %% - Domain artifacts: atom keys, Erlang terms, stay inside bounded context %% %% == Required Callbacks == %% %% - command_type() -> atom() %% - new(Params) -> {ok, Command} | {error, Reason} %% - to_map(Command) -> map() %% %% == Optional Callbacks == %% %% - validate(Command) -> ok | {error, Reason} %% - from_map(Map) -> {ok, Command} | {error, Reason} %% %% @author rgfaber -module(evoq_command). -include("evoq.hrl"). %% Required callbacks -callback command_type() -> atom(). -callback new(Params :: map()) -> {ok, Command :: term()} | {error, Reason :: term()}. -callback to_map(Command :: term()) -> map(). %% Optional callbacks -callback validate(Command :: term()) -> ok | {ok, Command :: term()} | {error, Reason :: term()}. -callback from_map(Map :: map()) -> {ok, Command :: term()} | {error, Reason :: term()}. -optional_callbacks([validate/1, from_map/1]). %% API -export([new/4, new/5]). -export([validate/1]). -export([ensure_id/1]). -export([get_id/1, get_type/1, get_aggregate_id/1, get_aggregate_type/1]). -export([get_payload/1, get_metadata/1]). -export([get_idempotency_key/1, set_idempotency_key/2]). -export([set_causation_id/2, set_correlation_id/2]). %%==================================================================== %% API %%==================================================================== %% @doc Create a new command. -spec new(atom(), atom(), binary(), map()) -> #evoq_command{}. new(CommandType, AggregateType, AggregateId, Payload) -> new(CommandType, AggregateType, AggregateId, Payload, #{}). %% @doc Create a new command with metadata. -spec new(atom(), atom(), binary(), map(), map()) -> #evoq_command{}. new(CommandType, AggregateType, AggregateId, Payload, Metadata) -> #evoq_command{ command_id = generate_id(), command_type = CommandType, aggregate_type = AggregateType, aggregate_id = AggregateId, payload = Payload, metadata = Metadata, causation_id = undefined, correlation_id = generate_id() }. %% @doc Validate a command using its module's validate/1 callback. -spec validate(#evoq_command{}) -> ok | {error, term()}. validate(Command) -> %% First validate required fields case validate_required_fields(Command) of ok -> %% Then try module-specific validation validate_with_module(Command); Error -> Error end. %% @private validate_required_fields(#evoq_command{command_type = undefined}) -> {error, missing_command_type}; validate_required_fields(#evoq_command{aggregate_type = undefined}) -> {error, missing_aggregate_type}; validate_required_fields(#evoq_command{aggregate_id = undefined}) -> {error, missing_aggregate_id}; validate_required_fields(_Command) -> ok. %% @private validate_with_module(#evoq_command{command_type = CommandType, payload = Payload}) -> %% Try to find a command module with validate/1 case code:ensure_loaded(CommandType) of {module, CommandType} -> case erlang:function_exported(CommandType, validate, 1) of true -> CommandType:validate(Payload); false -> ok end; _ -> ok end. %% @doc Get the command ID. -spec get_id(#evoq_command{}) -> binary(). get_id(#evoq_command{command_id = Id}) -> Id. %% @doc Get the command type. -spec get_type(#evoq_command{}) -> atom(). get_type(#evoq_command{command_type = Type}) -> Type. %% @doc Get the aggregate ID. -spec get_aggregate_id(#evoq_command{}) -> binary(). get_aggregate_id(#evoq_command{aggregate_id = Id}) -> Id. %% @doc Get the aggregate type. -spec get_aggregate_type(#evoq_command{}) -> atom(). get_aggregate_type(#evoq_command{aggregate_type = Type}) -> Type. %% @doc Get the command payload. -spec get_payload(#evoq_command{}) -> map(). get_payload(#evoq_command{payload = Payload}) -> Payload. %% @doc Get the command metadata. -spec get_metadata(#evoq_command{}) -> map(). get_metadata(#evoq_command{metadata = Metadata}) -> Metadata. %% @doc Set the causation ID. -spec set_causation_id(binary(), #evoq_command{}) -> #evoq_command{}. set_causation_id(CausationId, Command) -> Command#evoq_command{causation_id = CausationId}. %% @doc Set the correlation ID. -spec set_correlation_id(binary(), #evoq_command{}) -> #evoq_command{}. set_correlation_id(CorrelationId, Command) -> Command#evoq_command{correlation_id = CorrelationId}. %% @doc Get the idempotency key (may be undefined). -spec get_idempotency_key(#evoq_command{}) -> binary() | undefined. get_idempotency_key(#evoq_command{idempotency_key = Key}) -> Key. %% @doc Set a caller-provided idempotency key for deduplication. -spec set_idempotency_key(binary(), #evoq_command{}) -> #evoq_command{}. set_idempotency_key(Key, Command) -> Command#evoq_command{idempotency_key = Key}. %% @doc Ensure the command has a command_id. If undefined, auto-generates one. -spec ensure_id(#evoq_command{}) -> #evoq_command{}. ensure_id(#evoq_command{command_id = undefined} = Command) -> Command#evoq_command{command_id = generate_id()}; ensure_id(Command) -> Command. %%==================================================================== %% Internal functions %%==================================================================== %% @private generate_id() -> Bytes = crypto:strong_rand_bytes(16), binary:encode_hex(Bytes).