Domains
riverbase_core::domain composes engines into a bounded context and holds the shared runtime they
spawn against. For the design rationale see Domains and namespacing.
domain!
riverbase_core::domain! {
domain TodoDomain {
meta {
namespace: "riverbase.todo",
title: "Todo List",
description: "Reference vertical slice.",
}
command: TodoCommandEngine, // optional for query-only domains
query: TodoQueryEngine,
service: TodoServiceEngine, // optional
routes(api_base) { /* -> axum::Router; needs the `http` feature */ }
spawn(runtime: &DomainRuntime) {
let ctx = Self::engine_context(runtime);
let store = S::init_migrated(&runtime.dbconn, crate::postgres::todo_store_spec())?;
/* spawn engines */
Ok(Self::from_engines(command, query, service))
}
}
}
The generated TodoDomain<S>:
| Item | What it is |
|---|---|
NAMESPACE, TITLE, DESCRIPTION | Compile-time identity constants |
spawn(&DomainRuntime) | Your constructor, emitted in impl<S: DataStoreInit> |
engine_context(&DomainRuntime) | The domain's base EngineContext |
from_engines(…) / from_query(ctx, query) | Assemble the struct from spawned engines |
into_arc() | Registers the routes block and returns Arc<Self> for mount_domain |
command(), query(), service() | Typed access to each engine |
execute_command, execute_query | Call the domain directly, for tests and workers |
impl Domain | Metadata, discovery, and the dyn engine slots |
DomainRuntime
Build it once per process and share it across domains:
let runtime = DomainRuntime::from_config(app.config()).await?;
from_config connects Postgres, builds the audit log bundle according to [riverbase.audit_log],
connects the message and stream buses from [riverbase.bus], starts the outbox publisher and
process-manager worker, and resolves the configured tenant. Its fields (dbconn, logstore,
msgbus, stream_bus, invoker, query_invoker, process_manager, process_registry, …) are
public, so spawn blocks can pass them to engines.
DomainStoreSpec
A spec declares what one domain owns in the shared database:
DomainStoreSpec::new(ResourceRegistry::new(["todo", "todo_item"]))
.with_entities(vec![Arc::new(TodoEntity) as Arc<dyn ErasedEntity>])
.with_migrate(run_todo_migrations)
.with_migration_name("todo_examples")
S::init(&runtime.dbconn, spec) builds the store; S::init_migrated also runs with_migrate.
Migrations are tracked per crate, in that crate's own schema, so domains never contend over one
migrations table.
Engine traits
| Trait | Implemented by | Used by |
|---|---|---|
DomainCommandEngine | command_engine! output (or by hand with domain_trait: manual) | HTTP command routes, the command invoker, transports |
DomainQueryEngine | query_engine! output | HTTP query routes, the query invoker |
DomainServiceEngine | Your service type | Capability dispatch by item key |
Engine | All of the above | Discovery: kind() and items() |
Invokers
CommandInvoker and QueryInvoker map namespaces to engines inside one process.
RiverbaseApp::mount_domain registers every mounted domain when the app owns a runtime. Calls
carry an invocation mode (delegated user or service capability) and a bounded depth, and always
evaluate the target's policy.
Service engines
A service engine exposes non-CQRS capabilities, such as a liveness ping:
#[async_trait]
impl DomainServiceEngine for TodoServiceEngine {
fn context(&self) -> &EngineContext { &self.ctx }
async fn dispatch(&self, item_key: &str, payload: Value) -> RiverbaseResult<Value> {
match item_key {
"ping" => Ok(json!({ "pong": true, "echo": payload })),
other => Err(NotFoundError::with(
"TDO-SVC-001", "Unknown todo service capability.", format!("unknown service item: {other}"),
)),
}
}
}
Its spawn takes the same arguments as a command engine, so domain! can start it the same way.