<!-- generated-by: gsd-doc-writer -->
# Orkestra

A lightweight CQRS/ES toolkit for Elixir. Pluggable message bus, event store, and OpenTelemetry tracing built in.

Orkestra gives you the building blocks without the framework lock-in. Define commands and events with a declarative DSL, wire up handlers that auto-subscribe, and swap between in-process PubSub and distributed RabbitMQ with a single config change.

## Installation

```elixir
def deps do
  [
    {:orkestra, "~> 0.1.0"}
  ]
end
```

## Quick start

### Define a command

```elixir
defmodule MyApp.Commands.PlaceOrder do
  use Orkestra.Command

  param :product_id, :string, required: true
  param :quantity, :integer, required: true
  param :notes, :string, default: ""
end
```

### Define an event

```elixir
defmodule MyApp.Events.OrderPlaced do
  use Orkestra.Event

  field :order_id, :string, required: true
  field :product_id, :string, required: true
  field :quantity, :integer, required: true
  field :placed_at, :string, required: true
end
```

### Handle the command

```elixir
defmodule MyApp.Handlers.PlaceOrderHandler do
  use Orkestra.CommandHandler,
    command: MyApp.Commands.PlaceOrder

  @impl true
  def execute(command, metadata) do
    order_id = Ecto.UUID.generate()

    # ... your business logic ...

    {:ok, %{order_id: order_id}}
  end
end
```

### React to the event

```elixir
defmodule MyApp.Handlers.SendOrderConfirmation do
  use Orkestra.EventHandler,
    event: MyApp.Events.OrderPlaced

  @impl true
  def handle_event(event, _metadata) do
    MyApp.Mailer.send_confirmation(event.data.order_id)
    :ok
  end
end
```

### Dispatch

```elixir
alias Orkestra.{CommandEnvelope, MessageBus}

{:ok, cmd} = MyApp.Commands.PlaceOrder.new(%{
  product_id: "sku_42",
  quantity: 3
}, actor_id: "user_123", source: "web")

bus = MessageBus.impl()
:ok = bus.dispatch(CommandEnvelope.wrap(cmd, max_retries: 2))
```

### Supervision tree

```elixir
children = [
  {Phoenix.PubSub, name: MyApp.PubSub},
  Orkestra.MessageBus.PubSub,
  MyApp.Handlers.PlaceOrderHandler,
  MyApp.Handlers.SendOrderConfirmation
]
```

## Configuration

### Message bus adapter

```elixir
# In-process (dev, test, single-node)
config :orkestra, Orkestra.MessageBus,
  adapter: Orkestra.MessageBus.PubSub,
  app_prefix: MyApp

config :orkestra, Orkestra.MessageBus.PubSub,
  pubsub: MyApp.PubSub

# Distributed (production, multi-node)
config :orkestra, Orkestra.MessageBus,
  adapter: Orkestra.MessageBus.RabbitMQ,
  app_prefix: MyApp

config :orkestra, Orkestra.MessageBus.RabbitMQ,
  channel_provider: fn -> MyApp.RabbitMQ.Connection.channel() end
```

### Topic derivation

Topics are derived automatically from module names. The `app_prefix` is stripped:

```
MyApp.Orders.Commands.PlaceOrder  ->  "orders.commands.place_order"
MyApp.Orders.Events.OrderPlaced   ->  "orders.events.order_placed"
```

### Event store

```elixir
# EventStoreDB (production)
config :orkestra, Orkestra.EventStore,
  adapter: Orkestra.EventStore.EventStoreDB

config :orkestra, Orkestra.EventStore.EventStoreDB,
  connection_string: "esdb://localhost:2113?tls=false"

# In-memory (test)
config :orkestra, Orkestra.EventStore,
  adapter: Orkestra.EventStore.InMemory
```

## Core concepts

### Commands

Commands represent an intent to change the system. They are validated, dispatched to a single handler, and either succeed or fail.

```elixir
defmodule CreateAccount do
  use Orkestra.Command

  param :email, :string, required: true
  param :name, :string, required: true
  param :plan, :string, default: "free"

  @impl true
  def validate(%{email: email}) do
    if String.contains?(email, "@"), do: :ok, else: {:error, :invalid_email}
  end
end

{:ok, cmd} = CreateAccount.new(%{email: "a@b.com", name: "Alice"})
{:error, {:missing_params, [:email]}} = CreateAccount.new(%{name: "Bob"})
{:error, :invalid_email} = CreateAccount.new(%{email: "nope", name: "Eve"})
```

### Events

Events represent something that happened. They are immutable facts, never rejected. Events can be derived from commands or other events, preserving the correlation chain.

```elixir
defmodule AccountCreated do
  use Orkestra.Event

  field :account_id, :string, required: true
  field :email, :string, required: true
  field :plan, :string, required: true
end

# From a command (preserves correlation, sets causation)
{:ok, event} = AccountCreated.from_command(cmd, %{
  account_id: "acc_123",
  email: "a@b.com",
  plan: "free"
})

event.metadata.correlation_id == cmd.metadata.correlation_id  # true
event.metadata.causation_id == cmd.id                         # true
```

### Metadata

Every command and event carries metadata that flows through the pipeline:

```elixir
%Orkestra.Metadata{
  correlation_id: "abc123",    # links an entire chain of commands/events
  causation_id: "cmd_456",     # what directly caused this
  actor_id: "user_789",        # who initiated it
  actor_type: :user,           # :user | :system | :expert | :scheduler
  source: "web",               # where it originated
  issued_at: ~U[2026-03-27 12:00:00Z]
}

# Derive child metadata (preserves correlation, sets causation)
child = Orkestra.Metadata.derive(parent_metadata, "parent_id")
```

### Envelopes

Envelopes wrap commands and events with dispatch context.

**Command envelopes** track dispatch lifecycle and retries:

```elixir
env = CommandEnvelope.wrap(cmd, max_retries: 3)
env.status    # :pending -> :dispatched -> :succeeded | :failed | :rejected

CommandEnvelope.retryable?(env)  # true if failed and attempts <= max_retries
```

**Event envelopes** track delivery to multiple handlers:

```elixir
env = EventEnvelope.wrap(event) |> EventEnvelope.mark_published()
env = EventEnvelope.register_handler(env, "SendEmail")
env = EventEnvelope.register_handler(env, "UpdateIndex")
env = EventEnvelope.mark_handler_succeeded(env, "SendEmail")
env = EventEnvelope.mark_handler_failed(env, "UpdateIndex")
env.status  # :partially_handled
```

### Aggregates

Aggregates encapsulate domain invariants as pure functions. `Aggregate.Root` is the imperative shell that handles all I/O.

```elixir
defmodule MyApp.BankAccount do
  @behaviour Orkestra.Aggregate

  @impl true
  def init_state, do: %{status: :new, balance: 0}

  @impl true
  def stream_id(command), do: "bank_account-#{command.params.account_id}"

  @impl true
  def evolve(state, %AccountOpened{} = e), do: %{state | status: :open, owner: e.data.owner}
  def evolve(state, %MoneyDeposited{} = e), do: %{state | balance: state.balance + e.data.amount}
  def evolve(state, _), do: state

  @impl true
  def decide(%{status: :new}, %OpenAccount{} = cmd) do
    {:ok, [AccountOpened.new!(%{account_id: cmd.params.account_id, owner: cmd.params.owner})]}
  end
  def decide(%{status: :open}, %Deposit{} = cmd) do
    {:ok, [MoneyDeposited.new!(%{amount: cmd.params.amount})]}
  end
  def decide(%{status: :new}, _), do: {:error, :account_not_opened}
end

# Execute via the imperative shell
{:ok, events, new_state} = Orkestra.Aggregate.Root.execute(MyApp.BankAccount, open_cmd)
```

The `Root.execute/3` pipeline: load events from store → fold via `evolve/2` → call `decide/2` → append events → publish to bus → snapshot (if configured). Optimistic concurrency is handled automatically with retries on version conflicts.

### Event handlers

Subscribe to one event, multiple events, or wildcard patterns:

```elixir
# Single event
use Orkestra.EventHandler,
  event: MyApp.Events.OrderPlaced

# Multiple events
use Orkestra.EventHandler,
  events: [MyApp.Events.OrderPlaced, MyApp.Events.OrderCancelled]

# Wildcard pattern
use Orkestra.EventHandler,
  topic: "orders.events.#"

# With retry config
use Orkestra.EventHandler,
  event: MyApp.Events.OrderPlaced,
  max_retries: 5
```

## Message bus adapters

### PubSub (in-process)

Synchronous dispatch. Commands go to one handler, events broadcast to all subscribers. Retries are immediate (recursive). Dead-lettered messages are broadcast on `"orkestra:deadletter"`.

### RabbitMQ (distributed)

Commands use exchange `orkestra.commands` with one queue per command type (competing consumers). Events use exchange `orkestra.events` with one queue per handler (fan-out).

Features:
- Retry tracking via `x-death` headers (native RabbitMQ)
- Max retries via `x-max-retries` header
- Dead letter exchange `orkestra.deadletter` with catch-all queue
- All queues declared with DLX configuration
- W3C trace context propagation in AMQP headers

## Observability

Orkestra is instrumented with OpenTelemetry out of the box.

### Span hierarchy

```
orkestra.command.dispatch       (message bus)
  orkestra.command.handle       (command handler)
    orkestra.event.publish      (message bus)
      orkestra.event.handle     (event handler)
```

Additional spans: `orkestra.retry`, `orkestra.rabbitmq.publish` (kind: producer), `orkestra.rabbitmq.consume` (kind: consumer).

Aggregate spans: `orkestra.aggregate.execute` → `orkestra.aggregate.load` → `orkestra.aggregate.fold` → `orkestra.aggregate.decide` → `orkestra.aggregate.append` → `orkestra.aggregate.publish` → `orkestra.aggregate.snapshot`.

### Span attributes

All spans include: `orkestra.command.type`, `orkestra.command.id`, `orkestra.correlation_id`, `orkestra.causation_id`, `orkestra.actor_id`, `orkestra.handler`.

### Structured logging

All log messages use Logger metadata instead of string interpolation:

```
[info] Command handler subscribed  handler=MyApp.HandleOrder  topic=orders.commands.place_order  orkestra=command_handler
[warning] Handler nack, requeuing  handler=MyApp.HandleOrder  attempt=2  max_retries=3  orkestra=rabbitmq
[error] Dead letter recorded  handler=MyApp.HandleOrder  reason=:timeout  orkestra=pubsub
```

Logger metadata set during handler execution: `correlation_id`, `causation_id`, `actor_id`, `trace_id`, `span_id`.

### Distributed tracing (RabbitMQ)

Trace context is injected into AMQP message headers on publish and extracted on consume, creating linked spans across nodes. Uses `OpentelemetryProcessPropagator` for context propagation across BEAM processes.

## MCP server (orkestra_mcp)

`orkestra_mcp` is a companion MCP server and CLI that scaffolds and introspects Orkestra projects. It exposes tools, resources, and prompts to any MCP-compatible AI assistant.

### Installation

Build the escript from the `orkestra_mcp/` directory:

```bash
cd orkestra_mcp
mix deps.get
mix escript.build
```

### Usage

Run the server, pointing it at your project:

```bash
./orkestra_mcp --project-dir /path/to/your/app
```

Register it in your MCP client configuration (e.g., `.mcp.json`):

```json
{
  "mcpServers": {
    "orkestra": {
      "command": "/path/to/orkestra_mcp",
      "args": ["--project-dir", "/path/to/your/app"]
    }
  }
}
```

### Tools (code generators)

| Tool | Description |
|------|-------------|
| `gen_command` | Scaffold a Command module with typed params |
| `gen_event` | Scaffold an Event module with typed fields |
| `gen_command_handler` | Scaffold a CommandHandler wired to a command |
| `gen_event_handler` | Scaffold an EventHandler (single, multi, or wildcard) |
| `gen_aggregate` | Scaffold an Aggregate with `decide`/`evolve` clauses |

### Resources (project introspection)

| Resource URI | Description |
|--------------|-------------|
| `orkestra://commands` | JSON list of all Command modules discovered in the project |
| `orkestra://events` | JSON list of all Event modules discovered in the project |
| `orkestra://handlers` | JSON list of all handler modules discovered in the project |
| `orkestra://aggregates` | JSON list of all Aggregate modules discovered in the project |
| `orkestra://domain-map` | Cross-referenced domain map of commands, events, handlers, and aggregates |

### Prompts

| Prompt | Description |
|--------|-------------|
| `conventions` | Orkestra CQRS/ES conventions and best practices |
| `new_bounded_context` | Guided step-by-step workflow for adding a new bounded context |

## Testing

```bash
mix test
```

Tests use ExUnit with `async: false` for bus-related tests (shared GenServer state). The in-memory PubSub and event store adapters are used throughout the test suite — no external services required.

## License

MIT — see [LICENSE](LICENSE).
