Read models are query-optimized projection state stored as relational rows with
table, column, key, index, relationship, and schema metadata. Whole-view state
belongs in a declared table too: use an id column and JSON/JSONB columns for
semistructured fields.
The current implementation keeps these paths explicit:
| Path | API | Use when |
|---|---|---|
| Relational write mapping | RelationalReadModel, ReadModelWritePlanBuilder, ReadModelWritePlan |
Normalized tables, composite keys, foreign keys, JSONB columns |
| Explicit relationship includes | store.workspace().load(...).include(...).one(), sync |
Internal primary-key reads with declared one-level relationships |
| Schema lifecycle | ReadModelSchemaRegistry, ReadModelSchemaAdapter |
Migration artifact generation, startup verification, explicit dev/test bootstrap |
A model opts into relational metadata with #[readmodel(table = "...")] and
field attributes. Common collection, table, column, id, index, and unique
metadata also have direct helper attributes:
use serde::{Deserialize, Serialize};
use distributed::ReadModel;
#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)]
#[table("players")]
pub struct PlayerView {
#[id("player_id")]
pub id: String,
pub display_name: String,
#[readmodel(jsonb)]
pub counters_by_game: std::collections::HashMap<String, i64>,
#[readmodel(has_many = "PlayerWeaponView", foreign_key = "player_id")]
pub weapons: Vec<PlayerWeaponView>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)]
#[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])]
#[index(
name = "idx_player_weapons_player_acquired",
columns = ["player_id", "acquired_at"]
)]
pub struct PlayerWeaponView {
#[readmodel(foreign_key = "players.player_id", delegated_from = "PlayerView.player_id")]
pub player_id: String,
pub weapon_id: String,
#[index]
pub acquired_at: String,
}The derive emits RelationalReadModel metadata, row conversion, primary-key
metadata, JSONB column metadata, indexes, and an adapter-owned version column.
Composite and delegated keys are represented in the schema and in write-plan row
mutations.
Use #[index] or #[index("index_name")] for a secondary field index. Use
#[unique] or #[unique("index_name")] for a unique field index.
For compound indexes, put #[index(columns = ["field_a", "field_b"])] or
#[unique(columns = ["field_a", "field_b"])] on the struct. Add
name = "..." when the storage index name must be fixed.
Relationship includes are primary-key anchored and opt-in. Register the relational schemas with an adapter, load one root row, ask for each relationship explicitly, mutate the hydrated struct, then sync the tracked workspace:
use distributed::{InMemoryReadModelStore, ReadModelWorkspaceExt, RowKey, RowValue};
let store = InMemoryReadModelStore::new();
store.register_schema::<PlayerView>()?;
store.register_schema::<PlayerWeaponView>()?;
let mut workspace = store.workspace();
let mut player = workspace
.load::<PlayerView>(RowKey::new([("player_id", RowValue::String("player-1".into()))]))
.include("weapons")
.one()?
.expect("player should exist")
.data;
player.display_name = "Ada Lovelace".into();
player.weapons.push(PlayerWeaponView {
player_id: String::new(),
weapon_id: "sword".into(),
acquired_at: "2026-05-23".into(),
});
workspace.sync(player)?;
workspace.commit()?;has_many relationships hydrate Vec<T> fields. belongs_to relationships
hydrate Option<T> fields.
sync makes storage match the struct: added items are inserted, changed
items updated, and removed items deleted. For an included has_many
collection, dropping a child from the Vec<T> deletes that child row (the loaded
collection is the complete owned set, so the struct is the source of truth).
Added children have their delegated foreign keys filled before the write plan is
staged. Every change — including the delete — lowers to an explicit mutation in
the ReadModelWritePlan; nothing cascades to rows you did not load.
Clearing a belongs_to field to None is a no-op on the target: it never
deletes the owner, since other rows may reference it. To delete a whole root,
use delete::<Root>(key).
This API is for command handlers, projectors, tests, admin tools, and adapter
conformance that need typed internal includes. It is not a public query DSL.
A query gateway (Hasura, PostgREST, custom GraphQL, …) remains a common
choice for the public query API over normalized Postgres read models —
command/write traffic still goes through microsvc handlers.
Use ReadModelWritePlanBuilder when a command or projector stages multiple
row mutations:
use distributed::{ReadModelWritePlanBuilder, ReadModelWritePlanCommitExt};
let mut read_models = ReadModelWritePlanBuilder::new();
read_models.upsert(&player)?;
read_models.upsert_related(&player, "weapons", &weapon)?;
repo.read_models(read_models)
.commit(&mut aggregate)
.await?;Builder ordering is semantic staging only. These forms are equivalent:
repo.read_models(read_models)
.outbox(message)
.commit(&mut aggregate)
.await?;
repo.outbox(message)
.read_models(read_models)
.commit(&mut aggregate)
.await?;
repo.aggregate(&mut aggregate)
.read_models(read_models)
.outbox(message)
.commit()
.await?;A read-model service can commit a write plan without owning an aggregate repository:
use distributed::{ReadModelError, ReadModelWritePlanBuilder, ReadModelWritePlanStore};
async fn project_message(
store: &impl ReadModelWritePlanStore,
event_id: &str,
view: &PlayerView,
) -> Result<(), ReadModelError> {
let mut read_models = ReadModelWritePlanBuilder::new();
read_models
.upsert(view)?
.mark_processed("game-view-projector", event_id);
let outcome = read_models.commit(store).await?;
if outcome.was_applied() || outcome.was_skipped() {
// Ack the broker message after commit returns.
}
Ok(())
}Processed-message marks are committed in the same adapter transaction as read-model writes when the adapter advertises that capability. Duplicate messages return a skipped outcome and do not apply the staged mutations again.
Register relational models once and pass the registry to adapters:
use distributed::ReadModelSchemaRegistry;
let mut registry = ReadModelSchemaRegistry::new();
registry
.register::<PlayerView>()?
.register::<PlayerWeaponView>()?;
registry.validate()?;Adapters implement ReadModelSchemaAdapter to generate migration artifacts,
verify startup schema, or explicitly bootstrap dev/test schemas. Production
schema changes should be generated or user-authored migrations plus
verification; normal repository construction and command handling should not
silently sync production schemas.
SQL repositories expose the same lifecycle through neutral table-schema APIs so read models and operational tables, such as the outbox table, use one metadata path:
let mut registry = TableSchemaRegistry::new();
registry.register_schema(distributed::outbox_message_schema())?;
let artifacts = repo.generate_table_migration_artifacts(®istry)?;
let bootstrap = repo.bootstrap_table_schema_for_dev(®istry).await?;Use generated artifacts as migration input for production tooling such as Atlas.
Reserve bootstrap_table_schema_for_dev for tests and local development.
Bomberman BoardView is a read-model table row. It stores a whole game board
view with nested players, bombs, explosions, tiles, turn state, and counters in
declared columns, using JSON/JSONB for nested state.
If a command projection needs this shape, define a table with an id column and
JSON/JSONB columns for the board state:
#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)]
#[readmodel(table = "boards")]
pub struct BoardView {
#[readmodel(id)]
pub game_id: String,
#[readmodel(jsonb)]
pub players: Vec<PlayerState>,
#[readmodel(jsonb)]
pub bombs: Vec<BombState>,
}It is still a relational row write. There is no generic document table hidden behind the SQL repositories.
The relational ORM slice is a persistence mapper, not a business layer. It does not own business logic, authorization policy, aggregate invariants, domain event selection, public query APIs, lifecycle hooks, hidden cascades, generic document-table mutation APIs, or broad SQL query DSLs.