Packages
ex_sorcery
0.1.0
A toolkit for building event-driven applications in Elixir with event sourcing and CQRS patterns
Current section
Files
Jump to
Current section
Files
lib/sorcery/domain/domain_server.ex
defmodule Sorcery.Domain.Server do
@moduledoc """
The server component that manages domain instances and processes commands.
The DomainServer is responsible for:
1. Managing domain process lifecycles
2. Processing commands and generating events
3. Maintaining domain state by applying events
4. Handling event subscriptions
## Types of Domains
Sorcery supports two types of domains:
1. **ID-Based Domains** - Multiple instances of the same domain type, each with a unique ID
2. **Singleton Domains** - Single instance domains that manage global state
## Auto-Starting
Domains can be configured to auto-start 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
Configure auto-starting in your config:
config :sorcery, :auto_start,
domains: [
MyApp.UserDomain,
MyApp.SettingsDomain
]
Domains not configured for auto-start will be started on-demand when they receive their first command.
## ID-Based Domains
Use these when you need multiple instances of the same domain type:
```elixir
# Start and execute a command on a user domain
Sorcery.DomainServer.execute(
MyApp.UserDomain,
"user-123",
%MyApp.UserDomain.Commands.RegisterUser{
name: "John Doe",
email: "john@example.com"
}
)
```
## Singleton Domains
Use these for global state or configuration:
```elixir
# Start and execute a command on a settings domain
Sorcery.DomainServer.execute(
MyApp.SettingsDomain,
%MyApp.SettingsDomain.Commands.UpdateTheme{
theme: "dark"
}
)
```
## Process Management
Each domain instance is a separate process that:
- Subscribes to the event store
- Maintains its own state
- Processes commands
- Generates and applies events
## Event Handling
When events occur:
1. The event is stored in the event store
2. All domain processes receive the event
3. Each domain filters events relevant to it
4. Relevant events are applied to update state
## Process Registry
Domain processes are registered with unique names:
- ID-based domains: `{DomainModule, "id"}`
- Singleton domains: `DomainModule`
This ensures:
- Only one process per domain instance
- Easy lookup of existing processes
- Automatic process cleanup on termination
"""
use GenServer
@doc """
Starts a singleton instance of a domain.
This is used for auto-starting domains at application boot.
"""
def start_singleton(domain_module) do
name = via_tuple(domain_module, nil)
case GenServer.start_link(__MODULE__, {domain_module, "singleton"}, name: name) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
error -> error
end
end
@doc """
Starts a new DomainServer process.
## Arguments
Can be called in two ways:
1. With module and ID:
`start_link(domain_module, id_or_command, maybe_command)`
2. With keyword options:
`start_link(domain: domain_module, id: id)`
"""
def start_link(opts) when is_list(opts) do
domain_module = Keyword.fetch!(opts, :domain)
id = Keyword.get(opts, :id)
start_link(domain_module, id)
end
def start_link(domain_module, id_or_command, maybe_command \\ nil) do
case {id_or_command, maybe_command} do
{id, _command} when is_binary(id) and not is_nil(maybe_command) ->
# Case 1: ID-based domain
name = via_tuple(domain_module, id)
GenServer.start_link(__MODULE__, {domain_module, id}, name: name)
{_command, nil} ->
# Case 2: Singleton domain
start_singleton(domain_module)
end
end
@doc """
Gets the current state of a domain instance.
## Arguments
* `domain_module` - The domain module
* `id` - The instance ID (or nil for singleton domains)
## Returns
* `{:ok, state}` - The current state of the domain
* `{:error, :not_found}` - If the domain instance doesn't exist
"""
def get_state(domain_module, id) do
name = via_tuple(domain_module, id)
try do
state = GenServer.call(name, :get_state)
{:ok, state}
catch
:exit, {:noproc, _} -> {:error, :not_found}
end
end
@doc """
Initializes a domain process with its initial state.
The process will:
1. Subscribe to the event store
2. Load historical events
3. Build initial state by applying events
"""
def init({domain_module, id}) do
Sorcery.EventStore.subscribe()
# Load history and build initial state
{:ok, events} = Sorcery.EventStore.get_events()
state =
events
|> Enum.filter(fn event ->
if id do
event.metadata["instance_id"] == id
else
event.metadata["domain"] == domain_module
end
end)
|> Enum.reduce(domain_module.new(), fn event, state ->
# Event Store -> Domain Event
try do
# Convert the event data to the proper struct
event_module = Module.concat([domain_module, Events, Macro.camelize(event.type)])
# Convert string keys to atoms in event data
event_data = for {key, val} <- event.data, into: %{} do
{String.to_existing_atom(key), val}
end
event_struct = struct(event_module, event_data)
# Apply the event using the domain module
domain_module.apply_event(state, event_struct)
rescue
e ->
IO.inspect(e, label: "Error applying event")
state
end
end)
{:ok, {domain_module, id, state}}
end
def handle_call(:get_state, _from, {_domain_module, _id, state} = full_state) do
{:reply, state, full_state}
end
def handle_call({:command, command}, _from, {domain_module, id, state}) do
case domain_module.handle_command(state, command) do
{:ok, events} ->
# Domain Event -> Event Store
# Only append events, don't apply them directly
events
|> Enum.map(&Sorcery.EventConverter.domain_to_event_store(&1, domain_module, id))
|> Sorcery.EventStore.append()
{:reply, :ok, {domain_module, id, state}}
{:error, reason} ->
{:reply, {:error, reason}, {domain_module, id, state}}
end
end
def handle_info({:event, event}, {domain_module, id, state} = current_state) do
# For ID-based domains, only apply events that belong to this instance
# For singleton domains, apply all events for this domain
should_apply = if id do
event.metadata.instance_id == id
else
event.metadata.domain == domain_module
end
if should_apply do
# Event Store -> Domain Event
event_struct = Sorcery.EventConverter.event_store_to_domain(event)
new_state = domain_module.apply_event(state, event_struct)
{:noreply, {domain_module, id, new_state}}
else
{:noreply, current_state}
end
end
defp via_tuple(domain_module, id) do
registry_key = if id, do: {domain_module, id}, else: domain_module
{:via, Registry, {Sorcery.Domain.Registry, registry_key}}
end
@doc """
Executes a command on a domain.
This is the main entry point for changing domain state. It will:
1. Start the domain process if it doesn't exist
2. Send the command to the process
3. Handle command validation
4. Generate and store events
5. Apply events to update state
## Examples
For ID-based domains:
Sorcery.DomainServer.execute(
MyApp.UserDomain,
"user-123",
%MyApp.UserDomain.Commands.RegisterUser{
name: "John Doe",
email: "john@example.com"
}
)
For singleton domains:
Sorcery.DomainServer.execute(
MyApp.SettingsDomain,
%MyApp.SettingsDomain.Commands.UpdateTheme{
theme: "dark"
}
)
## Returns
* `:ok` - Command succeeded
* `{:error, reason}` - Command failed with reason
"""
def execute(domain_module, id_or_command, maybe_command \\ nil) do
case {id_or_command, maybe_command} do
{id, command} when is_binary(id) and not is_nil(command) ->
# Case 1: ID-based domain
case start_link(domain_module, id, command) do
{:ok, pid} ->
GenServer.call(pid, {:command, command})
{:error, {:already_started, pid}} ->
GenServer.call(pid, {:command, command})
error ->
error
end
{command, nil} ->
# Case 2: Singleton domain
case start_link(domain_module, command) do
{:ok, pid} ->
GenServer.call(pid, {:command, command})
{:error, {:already_started, pid}} ->
GenServer.call(pid, {:command, command})
error ->
error
end
end
end
end