DSL macro for defining Orkestra projectors.
A projector consumes domain events and maintains a queryable read model.
Supports two backends: :postgres (default) and :elasticsearch.
Postgres projector (default)
A Postgres-backed projector uses an Ecto.Repo for both the read model
and the projection checkpoint. Define event handlers with the project/2
macro; the module generates the dispatch, config, and OTP child_spec
boilerplate automatically.
defmodule MyApp.OrderProjector do
use Orkestra.Projector,
repo: MyApp.OrderProjection.Repo,
event_store: Orkestra.EventStore.InMemory
project MyApp.Events.OrderPlaced, fn event, multi ->
order = %{id: event.data.order_id, status: "placed"}
Ecto.Multi.insert(multi, :read_model_insert, order)
end
project MyApp.Events.OrderCancelled, fn event, multi ->
Ecto.Multi.update_all(multi, :read_model_update, ...)
end
endThe multi parameter is a pre-built empty Ecto.Multi.new() that the
handler chains operations onto. Step names must use the :read_model_
prefix to avoid name collisions with the GenServer's reserved steps
(:checkpoint, :halted_checkpoint, :dead_letter).
Elasticsearch projector
An Elasticsearch-backed projector writes documents to an ES/OpenSearch
index. The checkpoint is still stored in Postgres (:repo is always
required). Use project_es/2 to declare event handlers; the handler must
return {:ok, doc, id}, :skip, or {:error, reason}.
defmodule MyApp.OrderESProjector do
use Orkestra.Projector,
backend: :elasticsearch,
repo: MyApp.OrderProjection.Repo,
cluster: MyApp.ESCluster,
index: "orders",
event_store: Orkestra.EventStore.InMemory
@impl true
def index_mapping do
%{
"mappings" => %{
"properties" => %{
"order_id" => %{"type" => "keyword"},
"status" => %{"type" => "keyword"}
}
}
}
end
project_es MyApp.Events.OrderPlaced, fn event, _position ->
{:ok, %{"order_id" => event.data.order_id, "status" => "placed"},
event.data.order_id}
end
endThe GenServer calls Storage.Elasticsearch.init/1 at startup (via the
:init_adapter message) to detect the engine and create the index before
processing any events.
Elasticsearch projector with a schema
Instead of a raw index: name plus a hand-written index_mapping/0, an ES
projector may declare an Orkestra.ES.Schema module. The projector then
writes to the schema's alias (compatible with the alias + versioning index
lifecycle) and index_mapping/0 is generated from the schema:
defmodule MyApp.OrderESProjector do
use Orkestra.Projector,
backend: :elasticsearch,
repo: MyApp.OrderProjection.Repo,
cluster: MyApp.ESCluster,
schema: MyApp.Search.Order,
culture: :it,
event_store: Orkestra.EventStore.InMemory
project_es MyApp.Events.OrderPlaced, fn event, _position ->
{:ok, %MyApp.Search.Order{order_id: event.data.order_id, status: "placed"}}
end
endWith schema:, a project_es/2 handler may return either the legacy
{:ok, doc, id} tuple or {:ok, %SchemaStruct{}} — in the latter case the
document and _id are derived from the schema (to_doc/1 and the primary-key
field). :schema and :index are mutually exclusive, and defining
index_mapping/0 manually alongside :schema is a compile error (the schema
is the single source of truth).
:culture is only valid with :schema. For a multi-culture schema it
defaults to the schema's default_culture; for a mono-culture schema it must
be omitted.
Options for use Orkestra.Projector
:repo(required) — theEcto.Repomodule for the projection checkpoint. For ES projectors this is the checkpoint Postgres repo; it does not store the read-model data.:backend(optional) —:postgres(default) or:elasticsearch.:cluster(required for ES) — theSnap.Clustermodule.:index(ES, legacy path) — the Elasticsearch index name string. Mutually exclusive with:schema; requires a user-definedindex_mapping/0.:schema(ES, schema path) — anOrkestra.ES.Schemamodule. The projector writes to the schema alias andindex_mapping/0is generated. Mutually exclusive with:index.:culture(ES, schema path only) — the culture atom; defaults to the schema'sdefault_culturefor multi-culture schemas, must be omitted for mono-culture schemas.:event_store(optional) — event store module; defaults toOrkestra.EventStore.:name(optional) — override the projector name string; defaults toinspect(__MODULE__).:max_retries(optional) — maximum retry attempts before halting; defaults to5.:backoff_base_ms(optional) — base delay for exponential backoff in milliseconds; defaults to500.:backoff_cap_ms(optional) — maximum backoff delay in milliseconds; defaults to30_000.
The project/2 macro (Postgres backend)
Declares a handler for a specific event type:
project EventModule, fn event, multi -> multi endThe handler receives the event struct and an empty Ecto.Multi.new().
It must return an Ecto.Multi.t() — the multi is then wrapped in
{:ok, multi} by the generated __handle__/3 bridge function.
The project_es/2 macro (Elasticsearch backend)
Declares a handler for a specific event type in an ES projector:
project_es EventModule, fn event, position ->
{:ok, %{"field" => value}, document_id}
endThe handler receives (event, position) and must return one of:
{:ok, doc, id}— index the document with deterministic_id:skip— skip this event (no ES write){:error, reason}— signal failure
Generated functions
Postgres backend:
__dispatch__/3— routes by event type string; returns{:ok, Ecto.Multi.t()}for registered events or:skipfor unknown ones.__handle__/3— adapter-facing bridge: calls__dispatch__/3and translates:skipinto{:ok, Ecto.Multi.new()}.
Elasticsearch backend:
__dispatch_es__/3— routes by event type string; returns{:ok, doc, id}for registered events or:skipfor unknown ones.__handle_es__/3— adapter-facing bridge: calls__dispatch_es__/3and passes through{:ok, doc, id},:skip, or{:error, reason}.
Both backends:
__projection_config__/0— returns a map with:repo,:projector_name,:migrations_path, and:migration_source; used by mix tasks for discovery.child_spec/1— returns a supervisor child spec targetingOrkestra.Projector.GenServer. For ES projectors the spec injectsStorage.Elasticsearchand the necessaryadapter_opts.
child_spec/1 and runtime overrides
child_spec/1 accepts a keyword list of overrides for runtime config:
# In your supervision tree
children = [
{Orkestra.Projection.Supervisor, projectors: [
MyApp.OrderProjector,
{MyApp.CustomerProjector, repo: MyApp.CustomerProjection.TestRepo}
]}
]projection_config/0 return shape
Used by mix tasks (e.g. mix projector.migrate, mix orkestra.projection.es.rebuild)
to discover per-projection repos, migration paths, and backend-specific configuration:
%{
repo: MyApp.OrderProjection.Repo,
projector_name: "MyApp.OrderProjector",
migrations_path: "priv/projections/myapp_order_projector/migrations",
migration_source: "projection_myapp_order_projector_schema_migrations",
backend: :postgres,
cluster: nil,
index: nil,
projector_module: MyApp.OrderProjector
}For Elasticsearch projectors the map additionally contains:
%{
...
backend: :elasticsearch,
cluster: MyApp.ESCluster,
index: "orders",
projector_module: MyApp.OrderESProjector
}Per-Projection Repo Configuration
Each projector uses its own isolated Ecto.Repo. This keeps migrations,
tables, and migration history fully independent across projections.
Example config.exs
config :my_app, MyApp.OrderProjection.Repo,
database: "my_app_dev",
hostname: "localhost",
migration_source: "projection_myapp_order_projector_schema_migrations",
priv: "priv/projections/myapp_order_projector"The :migration_source key sets the migrations tracking table name so
each projection's migration history is isolated from the app's main
schema_migrations table. The :priv key points Mix to the correct
migrations directory.
Defining the Repo
defmodule MyApp.OrderProjection.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
endAdd the Repo to your supervision tree:
children = [
MyApp.OrderProjection.Repo,
{Orkestra.Projection.Supervisor, projectors: [MyApp.OrderProjector]}
]
Summary
Functions
Declares a handler for a specific event type.
Declares a handler for a specific event type in an Elasticsearch-backed projector.
Functions
Declares a handler for a specific event type.
The handler_fn receives (event, multi) where multi is a fresh
Ecto.Multi.new(). It should return an Ecto.Multi.t() with all read-model
operations chained using :read_model_-prefixed step names.
Declares a handler for a specific event type in an Elasticsearch-backed projector.
The handler_fn receives (event, position) and must return one of:
{:ok, doc, id}— index the document with deterministic_id:skip— skip this event (no ES write){:error, reason}— signal failure