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