cqrs-rust-lib
A pragmatic CQRS / Event Sourcing library for Rust with pluggable storage backends, structured domain errors, and REST integration.
Features
- Split
Aggregate/CommandHandlertraits (Single Responsibility) - Structured domain errors —
CqrsError+define_domain_errors!macro - Pluggable storage backends: InMemory, MongoDB, PostgreSQL, SurrealDB
- Unified
Querytrait — auto-derives filter from struct fields (RSQL under the hood) - HTTP Codex convention —
CqrsHttpQuery<Q>extracts_q,skip/limit,page/page_size,sortfrom HTTP params - RFC 9457
application/problem+jsonerror responses (feature:problem-json) - Backend prelude pattern — swap the entire backend with one
useline - REST routers with Axum and auto-generated OpenAPI/Swagger (feature:
rest) - Audit log router for event history
- Snapshot support
- WASM-compatible core (no Tokio in production deps)
Installation
[]
= { = "0.7", = ["postgres"] }
Feature flags
| Feature | Description |
|---|---|
mongodb |
MongoDB event store + read storage |
postgres |
PostgreSQL event store + read storage |
surrealdb |
SurrealDB event store + read storage |
utoipa |
OpenAPI schema derives only (WASM-compatible) |
rest |
Axum routers + OpenAPI (implies utoipa, native only) |
problem-json |
Serve errors as RFC 9457 application/problem+json |
all |
rest + mongodb + postgres + surrealdb |
Quick Start
1. Define your domain
use ;
use ;
2. Execute commands
use ;
use ;
let store = new;
let engine = new;
let ctx = default;
let id = engine.execute_create.await?;
engine.execute_update.await?;
Domain Error Codes
use ;
use StatusCode;
define_domain_errors!
Response shape (default):
CqrsError::from_status never degrades a status: any code without a dedicated
GenericErrorCode variant keeps its value through GenericErrorCode::Other
(GENERIC_HTTP_418, internal code 1418). 402, 405, 406, 408, 412, 413, 415,
422, 423, 428, 429, 501, 503 and 504 have dedicated variants whose internal code
is 1000 + status.
RFC 9457 problem details (feature: problem-json)
With the problem-json feature the REST layer serves
application/problem+json documents instead:
The type member defaults to urn:cqrs-error:{domain}:{code}. Point it at your
own documentation with a base URI, or override it per error:
use set_problem_type_base_uri;
set_problem_type_base_uri.unwrap;
// -> "type": "https://api.example.com/errors/ACCOUNT_INSUFFICIENT_FUNDS"
conflict.with_type_uri;
CqrsError::to_problem() is available without the feature, for hand-rolled
routes. See docs/migration_guide/problem_json.md.
Backend Preludes
Each backend exposes canonical type aliases under cqrs_rust_lib::prelude::<backend>.
Swapping the backend requires changing a single import line — the rest of the wiring is identical.
// Change only this line to swap backends:
use postgres as db;
// use cqrs_rust_lib::prelude::mongodb as db;
// use cqrs_rust_lib::prelude::surrealdb as db;
// Everything below stays the same:
let es = new;
let repo = new;
// Reads the event store's own snapshot table, so it takes the table, not a view storage.
let snap = new;
| Alias | inmemory | postgres | mongodb | surrealdb |
|---|---|---|---|---|
EventStorePersist |
✓ | ✓ | ✓ | ✓ |
ReadStorage |
— | ✓ | ✓ | ✓ |
FromSnapshotStorage |
— | ✓ | ✓ | ✓ |
The connection setup (client, pool, URI) is necessarily backend-specific and stays outside the prelude.
FromSnapshotStorage reads the event store's snapshot table directly — its layout differs from a view table on every backend — and defaults to a mapper naming where the aggregate actually sits: data->>'field' on Postgres, data.field on SurrealDB, state.field on MongoDB. See docs/migration_guide/snapshot_read_storage.md.
Query Trait (Read Side)
Query is the unified read-side filter/pagination/sort interface. It requires Serialize (supertrait) so that equality filters are auto-derived from struct fields — no boilerplate needed in most cases.
use Query;
use ;
// Empty impl: filter auto-derived, no pagination override, no sort
Override only what you need:
use ;
use ;
HTTP Codex convention (feature: rest)
CqrsHttpQuery<Q> is an Axum extractor that adds _q (RSQL), pagination and sort on top of any typed Q. Use it directly with CQRSCodexReadRouter:
use ;
// GET /games?_q=available==true&skip=20&limit=20&sort=-title
routes
Filter priority: _q (RSQL) AND Q::filter() — combined. Sort priority: HTTP sort → Q::sort() → Q::default_sort().
The typed params of Q and the RSQL _q string are one set of filterable fields in two syntaxes — RSQL exists because a flat ?field=value cannot express >=, =in=, or or a range. So _q may only name fields of the query struct: a field not reachable as a query param has no reason to be reachable from _q. The set is derived from Q's Deserialize impl, so there is no second list to keep in step and every filterable field is a typed OpenAPI parameter by construction. A field the struct does not declare is rejected with 422 naming it; a query struct with no fields offers no filter at all.
Both constrain the caller, not Query::default_sort() — a view can order its own results while offering the caller no say. Note what this does and does not do: the fields are still returned in the response body, so it stops a listing being used as a lookup by an unoffered field — it does not hide it. See ADR-0002, ADR-0003 and docs/migration_guide/queryable_fields.md.
A query parameter the extractor cannot read is rejected with 422 Unprocessable Entity, not silently dropped: a _q that fails to parse (the response carries rest-sql's positioned error, caret included), and a skip/limit/page/page_size that is not a non-negative integer. An empty value — ?_q=&limit=10 — means the parameter is unset, not unreadable, and is accepted. See docs/migration_guide/codex_query_rejection.md.
A sort field name must be one or more .-separated segments matching [A-Za-z_][A-Za-z0-9_]* — the dot addresses a nested path on MongoDB and SurrealDB. The name is interpolated into the generated ORDER BY, never bound as a parameter, so anything else (a space, a quote, a hyphen, a non-ASCII letter) is rejected with 400 Validation failed naming the field. The check runs in the storage layer, so it applies to a Sorter built in Rust and handed to Storage::filter just as much as to the HTTP sort param. A view whose stored keys do not fit that grammar needs a FieldMapper translating a legal logical name to it.
Pagination accepts both vocabularies; skip/limit wins when both are present:
| Params | Meaning |
|---|---|
skip, limit |
Offset based, maps straight to Pagination. skip alone is honoured (backend default limit applies). |
page, page_size (alias pageSize) |
Page based, translated to skip = page * page_size. |
Paged<T> reports both forms, so skip/limit stay exact even when skip is not a multiple of limit:
Storage Backends
PostgreSQL
use postgres as db;
use NoTls;
let = connect.await?;
spawn;
let client = new;
client.batch_execute.await?;
let es = from_client;
let views = new;
Connection pooling
Both the event store and the read storage acquire connections through the same
PgPool trait. The default SharedClient wraps a single Arc<Client>; plug a
real pool (deadpool-postgres, bb8, …) by implementing the two traits — no extra
dependency is pulled into the library:
use ;
use ;
;
;
cqrs_async_trait!
let es = with_pool;
let views = with_pool;
MongoDB
use mongodb as db;
let options = parse.await?;
let db_client = with_options?;
let database = db_client.database;
let es = new;
SurrealDB
use surrealdb as db;
use connect;
let surreal = connect.await?;
surreal.use_ns.use_db.await?;
surreal.query.await?.check?;
let es = new;
REST Routers (feature: rest)
use ;
// Standard router — typed query params only
routes
// Codex router — adds _q, page, page_size, sort HTTP params
routes
// Write + audit
routes
routes
See example/todolist/src/api.rs for complete wiring with Swagger UI.
Architecture
Aggregate (state + events) CommandHandler (commands → events)
\ /
CqrsCommandEngine ────── EventStore (persist)
│ │
Dispatchers Storage backends
(projections) (InMemory / PG / Mongo / Surreal)
│
ReadStorage ← Query (filter + sort + pagination)
Key Types
| Type | Description |
|---|---|
Aggregate |
Domain state, event application, identity |
CommandHandler |
Command processing, business validation |
CqrsCommandEngine |
Orchestrates command execution |
EventStore / EventStoreImpl |
Event persistence abstraction |
CqrsError |
Unified structured error type |
CqrsContext |
Carries user, request ID, correlation ID |
Dispatcher |
Reacts to persisted events (projections / views) |
View |
Read model projection |
Query |
Read-side filter / pagination / sort interface |
CqrsHttpQuery<Q> |
HTTP Codex extractor wrapping a typed Q |
Examples
| Example | Storage | Highlights |
|---|---|---|
example/bank |
MongoDB | Domain errors (prefix 10), views, movements sub-resource |
example/todolist |
PostgreSQL | REST API, Swagger UI, snapshots, integration tests |
example/ludotheque |
SurrealDB | Full pipeline: event store + view + filter + sort |
Migration Guides
License
MIT — see LICENSE.