CQRS in Elixir with Arc on Phoenix and Plug
CQRS (Command Query Responsibility Segregation) means commands change state and queries read it, each with its own model. In Elixir, Cratis Arc is an open-source (MIT) CQRS framework for Phoenix and Plug, published to Hex as cratis_arc. A command is a module that declares its fields and decides in handle/2; a query is an annotated function on a read model. Arc binds, validates, authorizes and runs them, records the events a command returns in Chronicle, and serves everything over the Arc HTTP contract, so React, LiveView or any HTTP client can use it.
This page is for Elixir developers who want CQRS without writing a controller per operation. If you are still deciding whether CQRS fits, read CQRS explained first.
What you get in Elixir
Section titled “What you get in Elixir”| Capability | How it looks in Arc |
|---|---|
| Commands | use Arc.Command, a schema, and handle/2 returning {:append, event} |
| Queries and read models | use Arc.ReadModel; @query and @observe functions |
| Live queries | @observe re-runs when a read model changes; WebSocket and Server-Sent Events |
| Validation | Declared on field, plus constraint violations from Chronicle |
| Authorization | authorize on a command or read model: roles, :authenticated or a named policy |
| Hosting | Arc.Plug for Plug and Phoenix; in-process calls and forms for LiveView |
| Typed frontend | Opt-in TypeScript proxies from mix arc.gen.proxies |
| Event sourcing | Chronicle through cratis_chronicle; returned events are appended |
Arc for Elixir serves the same HTTP contract as Arc on .NET, with the same CommandResult and QueryResult envelopes. It does not claim full parity with Arc on .NET; limits and gaps records the differences.
A command and a query
Section titled “A command and a query”A library application registers authors and lists them. The command carries its input as fields and its decision in handle/2:
defmodule Library.Authors.Registration.RegisterAuthor do @moduledoc "Registers a new author."
use Arc.Command
schema do field :id, Library.Authors.AuthorId, key: true, required: true field :name, :string, required: true, min_length: 2, max_length: 50 end
def handle(%__MODULE__{name: name}, _ctx), do: {:append, %Library.Authors.AuthorRegistered{name: name}}endThe key: true field is the event source: whatever handle/2 appends goes to that id. The command never writes a read model itself.
The query side is a read model with an annotated function. Chronicle keeps the model current from the events, and Arc serves the query over HTTP:
defmodule Library.Authors.Listing.Authors do @moduledoc "Queries over the author read model."
use Arc.ReadModel, model: Library.Authors.Author
@doc "All authors." @query many: true def all_authors(_args, ctx), do: Chronicle.all(Library.Authors.Author, ctx.chronicle)endThese are excerpts; a runnable project also needs the event type, the Chronicle read model, an Arc.Application module and Arc.Plug in the endpoint. Get started builds the whole feature end to end. Routes come from module names, so the command above is POST /api/authors/registration/register-author. The command never returns the author list and the query never changes anything, so you can test, secure and change each on its own.
Validation, authorization and live queries
Section titled “Validation, authorization and live queries”Validation and authorization are declared on the model, not inside handle/2. Field options such as required, min_length and max_length are checked by the server and, with generated proxies, by the browser. A rule that fails answers status 400 with a validation result for that field, and nothing is recorded.
use Arc.Command
authorize roles: ["editor"]authorize :authenticated requires any signed-in caller, and authorize policy: :name names a policy module that decides from the whole context. On a read model, authorize applies to every query, and a single query can override it.
An observable query is the same function, run again whenever its inputs change:
@observe many: true, watch: [Library.Authors.Author]def all_authors(_args, ctx), do: Chronicle.all(Library.Authors.Author, ctx.chronicle)Arc then serves the current value and each change over WebSocket or Server-Sent Events, and a LiveView page can assign it directly. See Queries and LiveView.
Events and Chronicle
Section titled “Events and Chronicle”Every command in Arc for Elixir is event sourced. handle/2 returns {:append, event}, and Arc appends it to Chronicle with the caller’s identity and correlation id. A Chronicle constraint, such as a unique name, is enforced at append time, and a violation reaches the client as a validation result instead of an error. A Chronicle projection fills the read model, so the query keeps its shape. Chronicle projects asynchronously, so a new author appears a moment after the command returns. For the event store itself, see event sourcing in Elixir.
When CQRS in Elixir is worth it
Section titled “When CQRS in Elixir is worth it”Use it when reads and writes have different shapes, load or rules, when a frontend needs typed and live data, or when you want events as the source of truth and a validation, authorization and HTTP layer you do not write by hand.
It is not worth it for a single form over a single table. Phoenix contexts with Ecto are simpler there. Arc for Elixir is also a Phoenix and Plug framework on Chronicle, so if you are not using Chronicle it does not apply. It needs Elixir 1.18 or later.
Frequently asked questions
Section titled “Frequently asked questions”Is there a CQRS framework for Phoenix?
Section titled “Is there a CQRS framework for Phoenix?”Yes. Arc for Elixir provides commands, queries, validation, authorization, live queries and opt-in generated TypeScript clients for Phoenix and Plug, on Chronicle. It does not claim full parity with Arc on .NET.
Do I need Commanded for CQRS in Elixir?
Section titled “Do I need Commanded for CQRS in Elixir?”No. Arc runs on Chronicle, which stores the events, and it does not use aggregate processes: Chronicle constraints and decision reads cover concurrency. If you already run Commanded, you can still call a Commanded router from handle/2.
Can I use CQRS in Elixir without event sourcing?
Section titled “Can I use CQRS in Elixir without event sourcing?”Not with Arc today. Arc for Elixir works with Chronicle only, and there is no Ecto integration. CQRS without event sourcing shows that shape with the other Arc backends.
Does it work with LiveView?
Section titled “Does it work with LiveView?”Yes. LiveView calls the same commands and queries in process, with forms that show pipeline errors on the right field, and observable queries update the page. See LiveView.
How does a React frontend call it?
Section titled “How does a React frontend call it?”Arc serves the Arc HTTP contract, so @cratis/arc and @cratis/arc.react work unchanged. Typed proxies are opt-in: add the :arc compiler or run mix arc.gen.proxies. See React and generated proxies.