Skip to main content

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​

domain/Cargo.toml
[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​

service/Cargo.toml
[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.

domain/src/domain.rs
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:

  • S is the store type parameter on TodoDomain<S>, for example PgDataStore. The spawn block is emitted inside impl<S: DataStoreInit> TodoDomain<S>, so it can call S::init_migrated.
  • Self::engine_context(runtime) builds the domain's EngineContext from runtime realm and audit settings.
  • S::init_migrated runs the domain's migrations and returns the store that command and query engines share.
  • service: is optional. A query-only domain can omit command:; the macro then fills the slot with a disabled engine, and you build the domain with Self::from_query(ctx, query).

The store spec​

The domain declares which resources it owns, which Postgres entities back them, and how to migrate:

domain/src/postgres/mod.rs
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:

service/src/main.rs
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​

MethodCommand and query routesCustom routes from domain!Registers on the invoker
mount_coupled(state)YesNoNo
mount_domain(domain)YesYesYes, 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​

service/riverbase.toml
[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
  • DomainStoreSpec with the resource registry, entities, and migrations
  • Service binary: RiverbaseApp::init, DomainRuntime::from_config, spawn, into_arc
  • mount_domain, not bare mount_coupled
  • Database URL set through riverbase.toml or RIVERBASE_DB_URL

Next: 3. Commands.