Packages

A toolkit for building event-driven applications in Elixir with event sourcing and CQRS patterns

Current section

Files

Jump to
ex_sorcery lib sorcery domain.ex
Raw

lib/sorcery/domain.ex

defmodule Sorcery.Domain do
@moduledoc """
A powerful macro for defining event-sourced domains in a declarative way.
Domains in Sorcery represent your business entities and their rules. Each domain:
- Defines its state shape
- Declares what events can occur
- Specifies how events change state
- Handles commands that generate events
## Domain Structure
A domain consists of:
1. **State** - The current data structure of your domain
2. **Events** - Things that have happened (facts)
3. **Commands** - User intentions that may generate events
4. **Event Handlers** - Functions that update state based on events
5. **Command Handlers** - Functions that validate commands and generate events
## Auto-Starting
Domains are automatically started when your application boots. This ensures that:
1. All domains are ready to handle commands immediately
2. Domains listening for events from other domains don't miss any events
3. Event handlers are always active
## Example
```elixir
defmodule MyApp.UserDomain do
use Sorcery.Domain
domain do
# Define the state shape
state :name, default: nil
state :email, default: nil
state :status, default: :inactive
# Define an event
event :user_registered do
field :name, type: :string
field :email, type: :string
# How this event changes state
apply fn state, event ->
%{state |
name: event.name,
email: event.email,
status: :active
}
end
end
# Define a command
command :register_user do
field :name, type: :string
field :email, type: :string
# Command validation and event generation
handle fn state, command ->
cond do
state.status == :active ->
{:error, :already_registered}
!valid_email?(command.email) ->
{:error, :invalid_email}
true ->
event = %__MODULE__.Events.UserRegistered{
name: command.name,
email: command.email
}
{:ok, [event]}
end
end
end
end
end
```
## State Definition
State fields are defined using the `state/2` macro:
```elixir
state :name, default: nil
state :email, default: nil
state :status, default: :inactive
```
## Event Definition
Events are defined using the `event/2` macro and must include:
- Fields that the event carries
- An apply function that updates state
```elixir
event :user_registered do
field :name, type: :string
field :email, type: :string
apply fn state, event ->
%{state | name: event.name, email: event.email}
end
end
```
## Command Definition
Commands are defined using the `command/2` macro and must include:
- Fields that the command requires
- A handle function that validates and generates events
```elixir
command :register_user do
field :name, type: :string
field :email, type: :string
handle fn state, command ->
# Return {:ok, [events]} on success
# Return {:error, reason} on failure
{:ok, [%Events.UserRegistered{...}]}
end
end
```
## External Events
You can handle events from other domains using `external_event/2`:
```elixir
external_event OtherDomain.Events.SomethingHappened do
apply fn state, event ->
# Update state based on external event
state
end
end
```
## Best Practices
1. **Keep Domains Focused**: Each domain should represent a single business concept
2. **Events are Facts**: Name events in past tense, they represent things that happened
3. **Commands May Fail**: Always handle validation in command handlers
4. **State is Private**: Only expose state changes through commands
5. **Event Handlers are Pure**: They should only transform state, no side effects
"""
defmacro __using__(_opts) do
quote do
import Sorcery.Domain
# Mark this module as a Sorcery domain for auto-discovery
@sorcery_domain true
# Event tracking
Module.register_attribute(__MODULE__, :current_event, accumulate: false)
Module.register_attribute(__MODULE__, :events, accumulate: true)
Module.register_attribute(__MODULE__, :event_fields, accumulate: true)
Module.register_attribute(__MODULE__, :event_applies, accumulate: true)
Module.register_attribute(__MODULE__, :event_handlers, accumulate: true)
# Command tracking
Module.register_attribute(__MODULE__, :current_command, accumulate: false)
Module.register_attribute(__MODULE__, :commands, accumulate: true)
Module.register_attribute(__MODULE__, :command_fields, accumulate: true)
Module.register_attribute(__MODULE__, :command_handlers, accumulate: true)
# State tracking
Module.register_attribute(__MODULE__, :states, accumulate: true)
@before_compile Sorcery.Domain
end
end
@doc """
Defines a domain block where state, events, and commands are declared.
## Example
domain do
state :name, default: nil
event :user_registered do
# ...
end
command :register_user do
# ...
end
end
"""
defmacro domain(do: block) do
quote do
import Sorcery.Domain
unquote(block)
end
end
@doc """
Declares a state field with an optional default value.
## Options
* `:default` - The default value for this field
## Example
state :name, default: nil
state :status, default: :pending
"""
defmacro state(name, opts \\ []) do
quote do
@states {unquote(name), unquote(opts)}
end
end
@doc """
Defines an event type and its structure.
Events must include:
- One or more fields using `field/2`
- An apply function using `apply/1`
## Example
event :user_registered do
field :name, type: :string
field :email, type: :string
apply fn state, event ->
%{state | name: event.name, email: event.email}
end
end
"""
defmacro event(name, do: block) do
event_name = Macro.escape(name)
quote do
Module.put_attribute(__MODULE__, :current_event, unquote(event_name))
Module.put_attribute(__MODULE__, :events, unquote(event_name))
unquote(block)
Module.put_attribute(__MODULE__, :current_event, nil)
end
end
@doc """
Declares a field for an event or command.
## Options
* `:type` - The type of the field (e.g., `:string`, `:integer`)
## Example
field :name, type: :string
field :age, type: :integer
"""
defmacro field(name, opts) do
quote do
current_command = Module.get_attribute(__MODULE__, :current_command)
if current_command do
@command_fields {current_command, {unquote(name), unquote(opts)}}
else
current_event = Module.get_attribute(__MODULE__, :current_event)
if current_event do
@event_fields {current_event, {unquote(name), unquote(opts)}}
else
raise "field/2 must be called within an event or command block"
end
end
end
end
@doc """
Defines how an event changes the domain state.
The function receives the current state and event as arguments and should return the new state.
## Example
apply fn state, event ->
%{state | name: event.name, email: event.email}
end
"""
defmacro apply(func) do
quote do
current_event = Module.get_attribute(__MODULE__, :current_event)
Module.put_attribute(
__MODULE__,
:event_applies,
{current_event, unquote(Macro.escape(func))}
)
end
end
@doc """
Defines a command that can be executed on the domain.
Commands must include:
- One or more fields using `field/2`
- A handle function using `handle/1`
## Example
command :register_user do
field :name, type: :string
field :email, type: :string
handle fn state, command ->
{:ok, [%Events.UserRegistered{...}]}
end
end
"""
defmacro command(name, do: block) do
quote do
Module.put_attribute(__MODULE__, :current_command, unquote(name))
Module.put_attribute(__MODULE__, :commands, unquote(name))
unquote(block)
Module.put_attribute(__MODULE__, :current_command, nil)
end
end
@doc """
Defines how to handle a command or event.
For commands, the function should return:
- `{:ok, events}` on success where events is a list of events to apply
- `{:error, reason}` on failure
## Example
handle fn state, command ->
if valid?(command) do
{:ok, [%Events.SomethingHappened{...}]}
else
{:error, :invalid_command}
end
end
"""
defmacro handle(func) do
quote do
cond do
@current_command != nil ->
@command_handlers {@current_command, unquote(Macro.escape(func))}
@current_event != nil ->
@event_handlers {@current_event, unquote(Macro.escape(func))}
true ->
raise "handle/1 must be called within a command or event block"
end
end
end
@doc """
Defines handling of events from other domains.
This is useful when your domain needs to react to events from other parts
of your system.
## Example
external_event OtherDomain.Events.SomethingHappened do
apply fn state, event ->
# Update state based on external event
state
end
end
"""
defmacro external_event(event_struct, do: block) do
quote do
# Get the module name from the struct
event_module = unquote(event_struct).__struct__
event_name = Module.split(event_module)
|> List.last()
|> Macro.underscore() # Convert from CamelCase to snake_case
|> String.to_atom()
Module.put_attribute(__MODULE__, :current_event, event_name)
Module.put_attribute(__MODULE__, :events, event_name)
# Mark this event as external and store the full module path
Module.put_attribute(__MODULE__, :event_fields, {event_name, {:external, true}})
Module.put_attribute(__MODULE__, :event_fields, {event_name, {:module, event_module}})
unquote(block)
Module.put_attribute(__MODULE__, :current_event, nil)
end
end
defmacro __before_compile__(env) do
states = Module.get_attribute(env.module, :states)
events = Module.get_attribute(env.module, :events)
event_fields = Module.get_attribute(env.module, :event_fields)
event_applies = Module.get_attribute(env.module, :event_applies)
commands = Module.get_attribute(env.module, :commands)
command_fields = Module.get_attribute(env.module, :command_fields)
command_handlers = Module.get_attribute(env.module, :command_handlers)
event_handlers = Module.get_attribute(env.module, :event_handlers)
parent_module = env.module
state_fields =
for {name, opts} <- states do
{name, Keyword.get(opts, :default)}
end
state_fields_map = Map.new(state_fields)
# Create event structs
event_structs =
for event_name <- events do
is_external = Enum.any?(event_fields, fn
{^event_name, {:external, true}} -> true
_ -> false
end)
if is_external do
# For external events, get the existing module
{^event_name, {:module, existing_module}} =
Enum.find(event_fields, fn
{^event_name, {:module, _}} -> true
_ -> false
end)
# Don't generate a new struct, use the existing one
quote do
alias unquote(existing_module)
end
else
# Regular events remain the same
fields =
for {^event_name, {field_name, _opts}} <- event_fields,
field_name not in [:external, :module] do
{field_name, nil}
end
capitalized_name = event_name
|> to_string()
|> Macro.camelize() # Use Macro.camelize instead of String.capitalize
|> String.to_atom()
quote do
defmodule unquote(Module.concat([parent_module, Events, capitalized_name])) do
@moduledoc false
defstruct unquote(Macro.escape(fields))
end
end
end
end
# Create command structs
command_structs =
for command_name <- commands do
fields =
command_fields
|> Enum.filter(fn {cmd, _} ->
cmd == command_name
end)
|> Enum.map(fn {_cmd, {field_name, _opts}} ->
{field_name, nil}
end)
|> Enum.uniq()
capitalized_name = command_name |> to_string() |> Macro.camelize()
quote do
defmodule unquote(Module.concat([parent_module, Commands, capitalized_name])) do
@moduledoc false
defstruct unquote(Macro.escape(fields))
end
end
end
# Generate apply_event functions
apply_event_clauses =
for event_name <- events do
{^event_name, func_ast} =
Enum.find(event_applies, fn
{^event_name, _} -> true
_ -> false
end)
# Check if it's an external event
is_external = Enum.any?(event_fields, fn
{^event_name, {:external, true}} -> true
_ -> false
end)
if is_external do
# For external events, use the stored module directly
{^event_name, {:module, event_module}} =
Enum.find(event_fields, fn
{^event_name, {:module, _}} -> true
_ -> false
end)
quote do
def apply_event(state, %unquote(event_module){} = event) do
unquote(func_ast).(state, event)
end
end
else
# For internal events, use the generated module path
capitalized_name = event_name |> to_string() |> Macro.camelize()
event_module = Module.concat([parent_module, Events, capitalized_name])
quote do
def apply_event(state, %unquote(event_module){} = event) do
unquote(func_ast).(state, event)
end
end
end
end
# Generate handle_command functions
handle_command_clauses =
for {command_name, handler} <- command_handlers do
capitalized_name = command_name |> to_string() |> Macro.camelize()
command_module = Module.concat([parent_module, Commands, capitalized_name])
quote do
def handle_command(state, %unquote(command_module){} = command) do
unquote(handler).(state, command)
end
end
end
# Generate handle_event functions
handle_event_clauses =
for {event_name, handler} <- event_handlers do
# Check if it's an external event
is_external = Enum.any?(event_fields, fn
{^event_name, {:external, true}} -> true
_ -> false
end)
if is_external do
# For external events, use the stored module directly
{^event_name, {:module, event_module}} =
Enum.find(event_fields, fn
{^event_name, {:module, _}} -> true
_ -> false
end)
quote do
def handle_event(state, %unquote(event_module){} = event) do
unquote(handler).(state, event)
end
end
else
# For internal events, use the generated module path
capitalized_name = event_name |> to_string() |> Macro.camelize()
event_module = Module.concat([parent_module, Events, capitalized_name])
quote do
def handle_event(state, %unquote(event_module){} = event) do
unquote(handler).(state, event)
end
end
end
end
default_handle_event = quote do
def handle_event(_state, _event) do
{:ok, []}
end
end
quote do
defstruct unquote(Macro.escape(state_fields))
def new do
struct(__MODULE__, unquote(Macro.escape(state_fields_map)))
end
def new(params) when is_map(params) do
struct(__MODULE__, Map.merge(unquote(Macro.escape(state_fields_map)), params))
end
# Create the Events module structure
defmodule Events do
@moduledoc false
end
# Create the Commands module structure
defmodule Commands do
@moduledoc false
end
# Generate all the modules and functions
unquote_splicing(event_structs)
unquote_splicing(command_structs)
unquote_splicing(apply_event_clauses)
unquote_splicing(handle_command_clauses)
unquote_splicing(handle_event_clauses)
unquote(default_handle_event)
end
end
end