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

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

Quick start

Define a command

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

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

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

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

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

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

Configuration

Message bus adapter

# 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

# 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.

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.

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:

%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:

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:

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.

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:

# 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.executeorkestra.aggregate.loadorkestra.aggregate.foldorkestra.aggregate.decideorkestra.aggregate.appendorkestra.aggregate.publishorkestra.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:

cd orkestra_mcp
mix deps.get
mix escript.build

Usage

Run the server, pointing it at your project:

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

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

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

Tools (code generators)

ToolDescription
gen_commandScaffold a Command module with typed params
gen_eventScaffold an Event module with typed fields
gen_command_handlerScaffold a CommandHandler wired to a command
gen_event_handlerScaffold an EventHandler (single, multi, or wildcard)
gen_aggregateScaffold an Aggregate with decide/evolve clauses

Resources (project introspection)

Resource URIDescription
orkestra://commandsJSON list of all Command modules discovered in the project
orkestra://eventsJSON list of all Event modules discovered in the project
orkestra://handlersJSON list of all handler modules discovered in the project
orkestra://aggregatesJSON list of all Aggregate modules discovered in the project
orkestra://domain-mapCross-referenced domain map of commands, events, handlers, and aggregates

Prompts

PromptDescription
conventionsOrkestra CQRS/ES conventions and best practices
new_bounded_contextGuided step-by-step workflow for adding a new bounded context

Testing

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.