# SPDX-License-Identifier: MIT
defmodule Domainex.Aggregate do
@moduledoc """
Aggregate is a module provide base Aggregate module functions.
It provide base structure for the aggregate. An aggregate itself
means, a cluster of objects that treat as a single unit of domain business
logic, although it's possible too to contain only a single object.
This module should not provide any functions that possible to limiting
scope of some domain business, or it should be designed to be generic,
and only provide some helpers
"""
alias Domainex, as: BaseType
alias Domainex.Common, as: Common
@error_invalid_aggregate_type "given data is not aggregate type"
@error_invalid_data_type "unknown given data type"
@spec error_invalid_aggregate_type() :: binary()
def error_invalid_aggregate_type, do: @error_invalid_aggregate_type
@spec error_invalid_data_type() :: binary()
def error_invalid_data_type, do: @error_invalid_data_type
defmodule Structure do
@moduledoc """
An aggregate objects may using this structure as their
base data structure, although the function implementation it
will always depends to some specific requirements and logics.
This structure provides only four possible keys, which are:
- :name
- :contains
- :events
- :processors
An aggregate may contains a single entity object or a group of entities.
An aggregate also should responsible to emit an event for each domain activities
Specific for `:processor`, it must be a module which implement behaviour `Event.Processor`
"""
@enforce_keys [:name, :contains, :events, :processors]
defstruct [:name, :contains, :events, :processors]
@type t :: %__MODULE__{
name: BaseType.aggregate_name(),
contains: BaseType.aggregate_payload() | %{atom() => BaseType.aggregate_payload()},
events: list(BaseType.event()),
processors: list(module())
}
end
@doc """
A `new/2` has two possibilities depends on given parameter.
If it give a single entity which is a `struct()` it will generate a
`BaseType.aggregate()` that contains a main aggregate object with single entity,
or people common said as aggregate root.
If it give a list of entities, it will generate a `BaseType.aggregate()` that
contains a list of entities.
All generated structure will always generated with an empty `:events`
"""
@spec new(name :: BaseType.aggregate_name(), entity :: BaseType.aggregate_payload(), processors :: list(module())) :: BaseType.aggregate()
def new(name, entity, processors) when is_struct(entity) and is_atom(name) or is_binary(name) and is_list(processors) do
aggregate = %Structure{
name: name,
contains: entity,
events: [],
processors: processors
}
{:aggregate, aggregate}
end
@spec new(name :: BaseType.aggregate_name(), entities :: %{atom() => BaseType.aggregate_payload()}, processors :: list(module())) :: BaseType.aggregate()
def new(name, entities, processors) when is_map(entities) and is_atom(name) or is_binary(name) and is_list(processors) do
aggregate = %Structure{
name: name,
contains: entities,
events: [],
processors: processors
}
{:aggregate, aggregate}
end
@doc """
`is_aggregate?/1` used to check if given tuple is an `:aggregate` or not. For any types
which not a tuple, it will return false
"""
@spec is_aggregate?(given :: BaseType.aggregate()) :: boolean()
def is_aggregate?(given) when is_tuple(given), do: Common.is_tuple_has_context?(given, :aggregate)
def is_aggregate?(_), do: false
@doc """
`aggregate/1` used to extract a main aggregate data structure from the tuple of:
```elixir
# used to extract structure
{:aggregate, {name, structure}}
```
It will return an error of `:aggregate` if given parameter is not tuple
"""
@spec aggregate(data :: BaseType.aggregate()) :: BaseType.result()
def aggregate(data) when is_tuple(data) do
with true <- is_aggregate?(data),
{:ok, aggregate} <- Common.extract_element_from_tuple(data, 1)
do
{:ok, aggregate}
else
false -> {:error, {:aggregate, @error_invalid_aggregate_type}}
{:error, {error_type, error_message}} -> {:error, {error_type, error_message}}
end
end
def aggregate(_), do: {:error, {:aggregate, @error_invalid_data_type}}
@doc """
`entity/1` used to load current aggregate's `:contains` property, which expected result
is a single entity.
"""
@spec entity(data :: BaseType.aggregate()) :: BaseType.result()
def entity(data) when is_tuple(data) do
case data |> aggregate do
{:ok, aggregate} ->
{:ok, aggregate.contains}
error ->
error
end
end
def entity(_), do: {:error, {:aggregate, @error_invalid_data_type}}
@doc """
`entities/1` used to load multiple entities. It just using `entity/1` under the hood.
If your current aggregate contains multiple entities, then it will all of that entities
in `map()` format.
"""
@spec entities(data :: BaseType.aggregate()) :: BaseType.result()
def entities(data) when is_tuple(data) do
data |> entity
end
def entities(_), do: {:error, {:aggregate, @error_invalid_data_type}}
@doc """
`update_entity/2` used to update internal aggregate's entity. This function used
only for an aggregate with a single entity.
Usage:
```elixir
aggregate2 = aggregate |> Aggregate.update_entity(fake_entity_updated)
```
"""
@spec update_entity(data :: BaseType.aggregate(), entity :: struct()) :: BaseType.result()
def update_entity(data, entity) when is_tuple(data) and is_struct(entity) do
with true <- is_aggregate?(data),
{:ok, aggregate} <- Common.extract_element_from_tuple(data, 1)
do
{:ok, {:aggregate, %{aggregate | contains: entity}}}
else
false -> {:error, {:aggregate, "given data is not aggregate type"}}
{:error, {error_type, error_msg}} -> {:error, {error_type, error_msg}}
end
end
def update_entity(_, _), do: {:error, {:aggregate, @error_invalid_data_type}}
@doc """
`update_entity/3` used to update one of available entities. This function used for an aggregate
with multiple entities.
Usage:
```elixir
{:ok, aggregate2} = aggregate |> Aggregate.update_entity(:fake1, fake_entity_1_updated)
```
The `update_entity/3` need a `key` to update some entity, since an aggregate that contains multiple
entities will be mapped based on some unique key
"""
@spec update_entity(data :: BaseType.aggregate(), key :: atom() , entity :: struct()) :: BaseType.result()
def update_entity(data, key, entity) when is_tuple(data) and is_atom(key) and is_struct(entity) do
with true <- is_aggregate?(data),
{:ok, aggregate} <- Common.extract_element_from_tuple(data, 1)
do
{:ok, {:aggregate, %{aggregate | contains: Map.put(aggregate.contains, key, entity)}}}
else
false -> {:error, {:aggregate, "given data is not aggregate type"}}
{:error, {error_type, error_msg}} -> {:error, {error_type, error_msg}}
end
end
def update_entity(_, _, _), do: {:error, {:aggregate, @error_invalid_data_type}}
@doc """
`add_event/2` used to adding an event to current available events in some aggregate
"""
@spec add_event(data :: BaseType.aggregate(), event :: BaseType.event()) :: BaseType.result()
def add_event(data, event) when is_tuple(data) and is_tuple(event) do
case data |> aggregate do
{:ok, agg} ->
{:ok, {:aggregate, %{agg | events: agg.events ++ [event]}}}
{:error, {error_type, error_msg}} ->
{:error, {error_type, error_msg}}
end
end
def add_event(_, _), do: {:error, {:aggregate, @error_invalid_data_type}}
@doc """
`emit_events/1` used to emit all current available events from an aggregate. All of available
event will send to all registered processors, and after emitting events, we need to reset current
events to empty list.
"""
@spec emit_events(data :: BaseType.aggregate()) :: BaseType.result()
def emit_events(data) when is_tuple(data) do
case data |> aggregate() do
{:ok, agg} ->
Enum.map(agg.processors, fn processor -> agg.events |> processor.process end)
data |> reset_events
{:error, {error_type, error_msg}} ->
{:error, {error_type, error_msg}}
end
end
def emit_events(_), do: {:error, {:aggregate, @error_invalid_data_type}}
defp reset_events(data) when is_tuple(data) do
case data |> aggregate do
{:ok, agg} ->
{:ok, {:aggregate, %{agg | events: []}}}
{:error, {error_type, error_msg}} ->
{:error, {error_type, error_msg}}
end
end
end