2. Scaffold and domain
In this chapter you create the two crates, declare TodoDomain with domain!, and mount it on a
RiverbaseApp.
The Quick start builds the same domain in a single crate. Here it is split the way a growing service should be: a domain crate and a service crate.
Target layout
my-app/
domain/ # todo-domain: commands, queries, aggregate, persistence
Cargo.toml
migrations/
src/{lib.rs, domain.rs, command.rs, aggregate.rs, query.rs, postgres/}
service/ # todo-service: the binary
Cargo.toml
riverbase.toml
src/main.rs
Add both crates to the workspace members. See Project layout
for what lives where.
Domain crate dependencies
[package]
name = "todo-domain"
edition = "2021"
[features]
default = []
# Custom routes and RxDB collections need axum; keep the kernel graph free of it by default.
http = ["dep:riverbase_http", "riverbase_core/http"]
[dependencies]
riverbase_core = { workspace = true }
riverbase_proc = { workspace = true }
riverbase_http = { workspace = true, optional = true }
async-trait = { workspace = true }
chrono = { workspace = true }
diesel = { workspace = true }
diesel-async = { workspace = true }
diesel_migrations = { workspace = true }
garde = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
Service crate dependencies
[dependencies]
riverbase_core = { workspace = true }
riverbase_http = { workspace = true } # `auth` is on by default
todo-domain = { workspace = true, features = ["http"] }
tokio = { workspace = true }
tracing = { workspace = true }
Declare the domain
domain.rs is the composition root of the bounded context. It names the namespace, lists the engine
types, and says how to start them against one shared store.
use std::sync::Arc;
use riverbase_core::domain::DomainRuntime;
use crate::command::TodoCommandEngine;
use crate::query::TodoQueryEngine;
use crate::service::TodoServiceEngine;
riverbase_core::domain! {
domain TodoDomain {
meta {
namespace: "riverbase.todo",
title: "Todo List",
description: "Reference vertical slice: coupled command and query engines, an optional service engine, Postgres persistence.",
}
command: TodoCommandEngine,
query: TodoQueryEngine,
service: TodoServiceEngine,
spawn(runtime: &DomainRuntime) {
let ctx = Self::engine_context(runtime);
let store = S::init_migrated(&runtime.dbconn, crate::postgres::todo_store_spec())?;
let command = Arc::new(
TodoCommandEngine::spawn(
ctx.clone(),
runtime.logstore.clone(),
runtime.msgbus.clone(),
store.clone(),
)
.await?,
);
let query = Arc::new(
TodoQueryEngine::spawn(ctx.clone(), store.clone(), runtime.logstore.clone()).await?,
);
let service = Arc::new(
TodoServiceEngine::spawn(ctx, runtime.logstore.clone(), runtime.msgbus.clone(), store)
.await?,
);
Ok(Self::from_engines(command, query, service))
}
}
}
What the pieces do:
Sis the store type parameter onTodoDomain<S>, for examplePgDataStore. Thespawnblock is emitted insideimpl<S: DataStoreInit> TodoDomain<S>, so it can callS::init_migrated.Self::engine_context(runtime)builds the domain'sEngineContextfrom runtime realm and audit settings.S::init_migratedruns the domain's migrations and returns the store that command and query engines share.service:is optional. A query-only domain can omitcommand:; the macro then fills the slot with a disabled engine, and you build the domain withSelf::from_query(ctx, query).
The store spec
The domain declares which resources it owns, which Postgres entities back them, and how to migrate:
use std::sync::Arc;
use riverbase_core::datastore::{ErasedEntity, ResourceRegistry};
use riverbase_core::domain::DomainStoreSpec;
pub fn todo_store_spec() -> DomainStoreSpec {
DomainStoreSpec::new(ResourceRegistry::new(["todo", "todo_item"]))
.with_entities(vec![Arc::new(entity::TodoEntity) as Arc<dyn ErasedEntity>])
.with_migrate(run_todo_migrations)
.with_migration_name("todo_examples")
}
The entity and migrations are covered in Datastore.
Wire the service
The service binary loads config, builds the shared runtime once, spawns the domain, and mounts it:
use riverbase_core::datastore::PgDataStore;
use riverbase_core::domain::DomainRuntime;
use riverbase_http::RiverbaseApp;
use todo_domain::TodoDomain;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut app = RiverbaseApp::init(
"Riverbase Todo Service",
"Coupled command and query API for the todo vertical slice",
)?;
// Connects Postgres and runs the framework migrations.
let runtime = DomainRuntime::from_config(app.config()).await?;
// Runs the domain's migrations (through its store spec) and starts the engines.
let domain = TodoDomain::<PgDataStore>::spawn(&runtime).await?.into_arc();
// `--migrate-only`: everything is migrated now, so stop before serving.
if RiverbaseApp::migrate_only() {
return Ok(());
}
app.mount_domain(domain).await?;
app.serve().await?;
Ok(())
}
DomainRuntime::from_config connects Postgres and applies the framework migrations, builds the audit
log bundle from [riverbase.audit_log], opens the message bus from [riverbase.bus], and starts the
outbox publisher. S::init_migrated in spawn then applies the domain's own migrations.
mount_domain vs mount_coupled
| Method | Command and query routes | Custom routes from domain! | Registers on the invoker |
|---|---|---|---|
mount_coupled(state) | Yes | No | No |
mount_domain(domain) | Yes | Yes | Yes, when the app owns a runtime |
Use mount_domain. It also validates the domain's query engine and discovery manifest before
exposing any route, so misconfiguration fails at startup. Reach for mount_coupled only when you
assemble the command and query state by hand.
Configuration
[riverbase]
db_url = "postgresql://USER:PASSWORD@127.0.0.1:5432/riverbase"
bind_addr = "0.0.0.0:8080"
log_level = "info"
service_mode = "coupled"
Override anything with RIVERBASE_* variables. See Configuration.
Checklist
- Domain crate with
domain!, a command engine module, and a query engine module -
DomainStoreSpecwith the resource registry, entities, and migrations - Service binary:
RiverbaseApp::init,DomainRuntime::from_config,spawn,into_arc -
mount_domain, not baremount_coupled - Database URL set through
riverbase.tomlorRIVERBASE_DB_URL
Next: 3. Commands.