Store Layer
The store layer sits between your persistence layer and your API. For each entity, it generates list/get/create/update/delete methods with lifecycle hooks, an Update struct with an apply() method, From impls for DTO types, and relation population logic. This is the layer where junction tables get synced, has_many fields get populated, and your custom business logic hooks into the lifecycle.
Configuration
Section titled “Configuration”let store_output = ontogen::gen_store( &schema, &ontogen::StoreConfig { output_dir: PathBuf::from("src/store/generated"), hooks_dir: Some(PathBuf::from("src/store/hooks")), schema_module_path: ontogen::DEFAULT_SCHEMA_MODULE_PATH.into(), backend: ontogen::Backend::Seaorm(Some(seaorm_output)), wikilink_policy: None, id_strategy: ontogen::IdStrategy::Provided, },)?;StoreConfig Fields
Section titled “StoreConfig Fields”| Field | Type | Description |
|---|---|---|
output_dir | PathBuf | Directory for generated store modules |
hooks_dir | Option<PathBuf> | Directory for scaffolded hook files. None skips hook scaffolding. |
schema_module_path | String | Rust import path for the schema module in generated code. Use ontogen::DEFAULT_SCHEMA_MODULE_PATH ("crate::schema") for the canonical default. |
backend | Backend | The persistence backend the generated CRUD bodies are wired to (ADR 0001): Backend::Seaorm(Option<SeaOrmOutput>) or Backend::Markdown(MarkdownIoOutput) — see the markdown backend guide. Everything above the store is byte-identical between backends. |
When hooks_dir is Some, the generator creates hook files for new entities (never overwrites existing ones). When None, hook scaffolding is skipped entirely, but the generated CRUD code still calls hook functions — you’re responsible for providing the modules yourself.
What Gets Generated
Section titled “What Gets Generated”For each entity, you get one file in output_dir:
src/store/generated/ mod.rs workout.rs // WorkoutUpdate, apply(), From impls, CRUD methods workout_set.rs exercise.rs tag.rsAnd if hooks_dir is set, scaffolded hook files:
src/store/hooks/ mod.rs // regenerated each build to track entities workout.rs // scaffolded once, never overwritten workout_set.rs exercise.rs tag.rsThe Generated Store Module
Section titled “The Generated Store Module”Each entity module contains four things. Let’s trace through what gets generated for a Workout entity with a many_to_many tags relation.
1. The Update Struct
Section titled “1. The Update Struct”Every field except id and skip fields gets an Option-wrapped version:
/// Partial update for a Workout. Only `Some` values are applied.#[derive(Debug, Clone, Default)]pub struct WorkoutUpdate { pub name: Option<Option<String>>, // nullable field -> double Option pub date: Option<String>, // required field -> single Option pub duration_minutes: Option<Option<i32>>, pub notes: Option<Option<String>>, pub tags: Option<Vec<String>>, // vec field -> Option<Vec> pub created_at: Option<String>,}The wrapping rules are:
- Required types (
String,i32) becomeOption<T>—Nonemeans “don’t update” - Nullable types (
Option<String>) becomeOption<Option<T>>— outerNonemeans “don’t update”,Some(None)means “set to null” - Vec types (
Vec<String>) becomeOption<Vec<T>>—Nonemeans “don’t update”
2. The apply() Method
Section titled “2. The apply() Method”A method on the Update struct that patches an entity in place:
impl WorkoutUpdate { fn apply(&self, record: &mut Workout) { if let Some(value) = &self.name { record.name.clone_from(value); } if let Some(value) = &self.date { record.date.clone_from(value); } if let Some(value) = &self.duration_minutes { record.duration_minutes.clone_from(value); } if let Some(value) = &self.notes { record.notes.clone_from(value); } if let Some(value) = &self.tags { record.tags.clone_from(value); } if let Some(value) = &self.created_at { record.created_at.clone_from(value); } }}The apply() method uses clone_from for each present field. This is the central mechanism for partial updates — the store fetches the current entity, calls apply(), then persists the result.
3. From Impls for DTOs
Section titled “3. From Impls for DTOs”Two From implementations bridge DTO input types to domain types:
// Create: DTO -> domain entityimpl From<crate::schema::CreateWorkoutInput> for Workout { fn from(input: crate::schema::CreateWorkoutInput) -> Self { Self { id: input.id, name: input.name, date: input.date, duration_minutes: input.duration_minutes, notes: input.notes, tags: strip_wikilinks_vec(input.tags), created_at: input.created_at, } }}
// Update: DTO -> update structimpl From<crate::schema::UpdateWorkoutInput> for WorkoutUpdate { fn from(input: crate::schema::UpdateWorkoutInput) -> Self { Self { name: input.name, date: input.date, duration_minutes: input.duration_minutes, notes: input.notes, tags: input.tags.map(strip_wikilinks_vec), created_at: input.created_at, } }}4. CRUD Methods on Store
Section titled “4. CRUD Methods on Store”The core of the generated module is an impl Store block with five methods:
pub async fn list_workouts( &self, order: &[OrderBy<WorkoutSortField>], limit: Option<u64>, offset: Option<u64>,) -> Result<Vec<Workout>, AppError> { let mut query = order_workouts_query(workout::Entity::find(), order); if let Some(l) = limit { query = query.limit(l); } if let Some(o) = offset { query = query.offset(o); } let models = query.all(self.db()) .await .map_err(|e| AppError::DbError(e.to_string()))?;
let mut records: Vec<Workout> = models.iter() .map(Workout::from_model) .collect::<Result<_, _>>()?; for record in &mut records { self.populate_workout_relations(record).await?; } Ok(records)}A list is ordered by order, then by id, on both backends, so a limit/offset walk visits every record once. Pass &[] for id order. See Ordering. For entities without relations, the population loop is omitted.
pub async fn get_workout(&self, id: &str) -> Result<Workout, AppError> { let model = workout::Entity::find_by_id(id) .one(self.db()) .await .map_err(|e| AppError::DbError(e.to_string()))? .ok_or_else(|| AppError::WorkoutNotFound(id.to_string()))?;
let mut record = Workout::from_model(&model)?; self.populate_workout_relations(&mut record).await?; Ok(record)}The NotFound error variant is derived from the entity name: {Entity}NotFound. On the markdown backend an id the vault can never hold (for example one containing /) is also {Entity}NotFound.
pub async fn create_workout(&self, mut record: Workout) -> Result<Workout, AppError>{ hooks::before_create(self, &mut record).await?;
// Id resolution (see "Creating records"); with IdStrategy::Provided: let id = if record.id.trim().is_empty() { return Err(AppError::WorkoutIdRequired( "this store requires the caller to supply an id".to_string(), )); } else { ontogen_core::id::validate_id(&record.id) .map_err(|e| AppError::DbError(e.to_string()))?; if !self.try_insert_workout(&record).await? { return Err(AppError::WorkoutAlreadyExists(record.id)); } record.id.clone() };
// Sync junction table self.sync_junction( "workout_tags", "workout_id", "tag_id", &id, &record.tags ).await?;
let created = self.get_workout(&id).await?; self.emit_change(ChangeOp::Created, EntityKind::Workout, id);
hooks::after_create(self, &created).await?; Ok(created)}The create flow: hook -> resolve the id and insert -> sync junctions from the record -> re-fetch -> emit event -> hook -> return.
pub async fn update_workout(&self, id: &str, updates: WorkoutUpdate) -> Result<Workout, AppError>{ let existing_model = workout::Entity::find_by_id(id) .one(self.db()).await .map_err(|e| AppError::DbError(e.to_string()))? .ok_or_else(|| AppError::WorkoutNotFound(id.to_string()))?;
let mut current = Workout::from_model(&existing_model)?; self.populate_workout_relations(&mut current).await?;
hooks::before_update(self, ¤t, &updates).await?;
let tags_changed = updates.tags.is_some();
updates.apply(&mut current);
let active = current.to_active_model()?; active.update(self.db()).await .map_err(|e| AppError::DbError(e.to_string()))?;
if tags_changed { self.sync_junction( "workout_tags", "workout_id", "tag_id", id, ¤t.tags ).await?; }
let result = self.get_workout(id).await?; self.emit_change( ChangeOp::Updated, EntityKind::Workout, id.to_string() );
hooks::after_update(self, &result).await?; Ok(result)}Junction sync is conditional — it only runs when the relation field was actually included in the update. This avoids unnecessary DELETE+INSERT cycles.
pub async fn delete_workout(&self, id: &str) -> Result<(), AppError> { hooks::before_delete(self, id).await?;
let existing = workout::Entity::find_by_id(id) .one(self.db()).await .map_err(|e| AppError::DbError(e.to_string()))? .ok_or_else(|| AppError::WorkoutNotFound(id.to_string()))?;
let active: workout::ActiveModel = existing.into(); active.delete(self.db()).await .map_err(|e| AppError::DbError(e.to_string()))?;
self.emit_change( ChangeOp::Deleted, EntityKind::Workout, id.to_string() );
hooks::after_delete(self, id).await?; Ok(())}Ordering
Section titled “Ordering”list_{plural} takes an order, &[OrderBy<{Entity}SortField>], as its first argument after &self. count_{plural} takes none. The store module also holds the sortable fields and an in-memory sort, emitted identically for both backends:
// crate::store::workout#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum WorkoutSortField { Id, Name, Intensity, Started }
pub fn sort_workouts(items: &mut [Workout], order: &[OrderBy<WorkoutSortField>]);{Entity}SortField implements ontogen_core::order::SortField. Its fields are the id (as Id, whatever the Rust field is called) and each plain scalar field in declaration order: strings, integers, floats and bools, their Option forms, schema enums, and integer primitives held as an OptionEnum or Other field. A body, a relation, a Vec and a skipped field are not sortable.
Use OrderBy::asc(field) or OrderBy::desc(field). ontogen_core::order::parse_sort reads transport keys (name, -name) into an order and rejects an unknown, repeated or empty key.
The same rules hold on both backends:
- Keys apply in the order given. The id ascending is the last key unless the order names the id, so every order is total.
Nonesorts before every value ascending and after every value descending.- Strings compare by byte, and an enum by its stored string.
- A float compares by
f64::total_cmp, with-0.0equal to0.0. A NaN write is refused, so none is stored.
The markdown list_* reads every record, sorts with sort_{plural}, then cuts the page. The SeaORM list_* orders in SQL through order_{plural}_query(query, order), a free function beside the entity that takes a Select and returns it ordered. It is SeaORM-only. A hand-written list that filters in SQL calls it, and one that filters in memory calls sort_{plural}. See Filtered lists and ADR 0006.
Relation Population
Section titled “Relation Population”Entities with has_many or many_to_many fields get a populate_{entity}_relations method. This method fills in the relation fields that from_model() left as Vec::new().
pub(crate) async fn populate_workout_relations( &self, record: &mut crate::schema::Workout,) -> Result<(), crate::schema::AppError> { record.tags = self .load_junction_ids("workout_tags", "workout_id", "tag_id", &record.id) .await?; Ok(())}For has_many fields, the population uses a SeaORM query:
// has_many: query target table filtered by FKrecord.contains = { use crate::persistence::db::entities::node; let children = node::Entity::find() .filter(node::Column::ParentId.eq(&record.id)) .order_by_asc(node::Column::Id) .all(self.db()) .await .map_err(|e| AppError::DbError(e.to_string()))?; children.into_iter().map(|m| m.id).collect()};For many_to_many fields, it queries the junction table:
// many_to_many: load IDs from junction tablerecord.fulfills = self .load_junction_ids("node_fulfills", "node_id", "requirement_id", &record.id) .await?;A has_many list is ordered by child id (byte order) on both backends. A many_to_many list is in the order it was written (see Junction Sync).
Both list_* and get_* methods call populate_relations after converting from the database model. This means every entity returned by the store has its relation fields fully populated.
Junction Sync
Section titled “Junction Sync”When creating or updating an entity, many_to_many fields are synced via sync_junction. This method:
- Deletes all existing junction rows for the entity
- Inserts new junction rows for each ID in the field, in list order
// In createself.sync_junction("workout_tags", "workout_id", "tag_id", &id, &tags).await?;
// In update (conditional)if tags_changed { self.sync_junction("workout_tags", "workout_id", "tag_id", id, ¤t.tags).await?;}The sync_junction and load_junction_ids methods are provided by your Store implementation. Ontogen generates the calls with the correct table and column names.
The order of a many_to_many list is the order it was written, on both backends. The markdown backend keeps it in frontmatter. On SeaORM that order lives in your two helpers, so they have to honor it: sync_junction inserts the rows in the order of target_ids, and load_junction_ids selects them back with ORDER BY rowid:
// sqlite-only: `rowid` is SQLite's implicit insertion-order column.let sql = format!( "SELECT {target_col} FROM {table} WHERE {source_col} = ? ORDER BY rowid");Without the ORDER BY, SQLite is free to return the rows in any order and a list would not round-trip. The junction table must keep its implicit rowid (do not declare it WITHOUT ROWID).
Creating records
Section titled “Creating records”create_* runs before_create, then checks that every child a has_many list names exists (a missing one is {Child}NotFound, see has_many writes), then settles the id:
- An id that is empty or whitespace-only counts as absent. Any other id, whether it came from the client or from
before_create, is used as given under every strategy. - A given id must satisfy the id rule: only lowercase ASCII letters, digits,
.,_,~and-; 1 to 200 bytes; no leading or trailing.; notindexorlog(those two are reserved for the vault’s own files); and not a Windows device name (con,prn,aux,nul,com0tocom9,lpt0tolpt9), in any case, whole or before its first.(con,nul.x). Such an id is a filename on Linux, macOS and Windows and needs no escaping in a URL. An invalid id fails with the backend’s error (DbErroron SeaORM,Mdon markdown), whose message names the clause it broke and then states the whole rule. The rule applies only to creates: a lookup of any id is answered, so a record stored under an id the rule now refuses (Draft,café) stays readable, updatable and deletable. - A slug lowercases ASCII letters, folds the Latin letters with diacritics of Latin-1 and Latin Extended-A to ASCII (
étoe,ßtoss,ætoae,łtol), and turns every run of anything else into one-, trimmed from both ends:Crème brûlée à Łódźslugs tocreme-brulee-a-lodz. A slug is cut to 190 bytes (and a-left at the cut trimmed), so the probesbase-2,base-3, … stay within 200 bytes. - A given id that is already taken fails with
AppError::{Entity}AlreadyExists(id). - An absent id is derived from the entity’s id strategy:
Providedfails withAppError::{Entity}IdRequired(reason);SlugFromField(f)slugifies the entity’sffield (an empty slug is{Entity}IdRequired);Uuidgenerates a v4 UUID. A derived id that is taken, or reserved (index,con), is probed asbase-2,base-3, and so on. On SeaORM, an insert of a derived id that loses a race to another writer moves on to the next suffix.
The strategy is chosen at build time, in two places. The store-wide default is required: Pipeline::store_id_strategy or StoreConfig::id_strategy (see Configuration); a Pipeline with a store stage and no strategy fails to build. An entity overrides it in the schema with #[ontology(entity, id = "provided" | "uuid" | "slug(<field>)")] (see Annotations):
// build.rs: every entity derives ids from its title....store_id_strategy(IdStrategy::SlugFromField("title".into()))
// src/schema/event.rs: ...except Event, which gets UUIDs.#[ontology(entity, directory = "events", id = "uuid")]pub struct Event { /* ... */ }Both apply to both backends. The SeaORM store calls ontogen_core::id at runtime, with the uuid feature for IdStrategy::Uuid; every generated store depends on ontogen-core anyway, for its ordering. (A markdown consumer needs the uuid feature of markdown-store instead.)
Your AppError declares {Entity}AlreadyExists(String) for every entity, and {Entity}IdRequired(String) for every entity whose store can return it: every markdown entity, and a SeaORM entity whose id strategy is Provided or SlugFromField (a SeaORM entity under Uuid always gets an id):
pub enum AppError { WorkoutNotFound(String), WorkoutIdRequired(String), // carries the reason WorkoutAlreadyExists(String), // carries the id // ...}has_many writes
Section titled “has_many writes”A has_many list lives on the children, as their foreign keys. An update that sets the list points every listed child at the record and clears the foreign key of every child the list drops. The generated update computes the dropped children first, from the stored list:
- If the child’s foreign-key field is an
Option<String>, the dropped children are set toNULLafter the record is written. - If it is a plain
String, a child cannot be left without a parent, so the update fails withAppError::{Child}ParentRequired(child_id)before anything is written.{Child}is thehas_manytarget entity. Declare the variant in yourAppErrorfor such an entity; it is constructed only for a non-Optionforeign key.
A listed child that does not exist fails the create or update with AppError::{Child}NotFound(child_id), the first missing id in list order, before anything is written. It is checked before {Child}ParentRequired.
Only the self-referential shape is supported: the has_many target must be the declaring entity, as in a tree of nodes. A has_many whose target is another entity fails the build with a CodegenError. To list another entity’s records that point at this one, declare the belongs_to on that entity and query its records by the foreign key in a hand-written API function (see Custom API endpoints).
Simple vs Complex Entities
Section titled “Simple vs Complex Entities”The generator produces different code depending on whether an entity has relations:
Simple entities (no has_many or many_to_many):
- No
populate_relationsmethod - List and get return directly from
from_model - Create and update have no junction sync
- Simpler, faster code
Complex entities (any has_many or many_to_many):
populate_relationsmethod generated- List and get call
populate_relationsafter conversion - Create extracts relation field values before converting to active model
- Update tracks which relation fields changed and conditionally syncs
Lifecycle Hooks
Section titled “Lifecycle Hooks”The generated CRUD methods call hook functions at six points:
| Hook | Timing | Receives | Can |
|---|---|---|---|
before_create | Before insert | &mut Entity | Modify entity, reject with Err |
after_create | After insert + relations | &Entity | Side effects |
before_update | Before apply + persist | &Entity, &EntityUpdate | Validate, reject with Err |
after_update | After persist + relations | &Entity | Side effects |
before_delete | Before delete | &str (id) | Reject with Err |
after_delete | After delete | &str (id) | Side effects |
Scaffolded Hook Files
Section titled “Scaffolded Hook Files”When hooks_dir is set, the generator creates one file per entity:
//! Lifecycle hooks for Workout.//!//! This file was scaffolded by ontogen. It is yours to edit.//! Fill in hook bodies with custom logic (validation, side effects, etc.).//! This file is NEVER overwritten by the generator.
#![allow(unused_variables, clippy::unnecessary_wraps, clippy::unused_async)]
use crate::schema::{Workout, AppError};use crate::store::Store;use crate::store::generated::workout::WorkoutUpdate;
/// Called before a workout is inserted.pub async fn before_create( _store: &Store, _workout: &mut Workout) -> Result<(), AppError> { Ok(())}
/// Called after a workout is successfully created.pub async fn after_create( _store: &Store, _workout: &Workout) -> Result<(), AppError> { Ok(())}
/// Called before a workout is updated.pub async fn before_update( _store: &Store, _current: &Workout, _updates: &WorkoutUpdate,) -> Result<(), AppError> { Ok(())}
/// Called after a workout is successfully updated.pub async fn after_update( _store: &Store, _workout: &Workout) -> Result<(), AppError> { Ok(())}
/// Called before a workout is deleted.pub async fn before_delete( _store: &Store, _id: &str) -> Result<(), AppError> { Ok(())}
/// Called after a workout is successfully deleted.pub async fn after_delete( _store: &Store, _id: &str) -> Result<(), AppError> { Ok(())}These files are scaffolded once and never overwritten. You own them. Fill in the function bodies with validation, status transitions, cascade deletes, audit logging — whatever your domain requires.
The hooks/mod.rs file is regenerated each build to track new entities, but it only contains pub mod declarations.
The StoreOutput IR
Section titled “The StoreOutput IR”gen_store returns a StoreOutput struct for downstream generators:
pub struct StoreOutput { pub methods: Vec<StoreMethodMeta>, pub scaffolded_hooks: Vec<ScaffoldMeta>, pub change_channels: Vec<ChannelMeta>,}StoreMethodMeta
Section titled “StoreMethodMeta”Each generated CRUD method is captured as metadata:
pub struct StoreMethodMeta { pub entity_name: String, // "Workout" pub name: String, // "create_workout" pub kind: StoreMethodKind, // Crud(Create) pub params: Vec<ParamMeta>, pub return_type: String, // "Workout" pub source: Source, // Generated { module_path: "..." }}The six standard methods per entity are:
| Method Name | Kind | Return Type |
|---|---|---|
list_{entities} | Crud(List) | Vec<Entity> |
count_{entities} | Crud(Count) | u64 |
get_{entity} | Crud(Get) | Entity |
create_{entity} | Crud(Create) | Entity |
update_{entity} | Crud(Update) | Entity |
delete_{entity} | Crud(Delete) | () |
Backend Selection
Section titled “Backend Selection”The persistence backend is carried by StoreConfig::backend (ADR 0001):
// SeaORM, with metadata (the payload is reserved for future enrichment;// today both forms derive junction/FK names by the same conventions):backend: ontogen::Backend::Seaorm(Some(seaorm_output)),backend: ontogen::Backend::Seaorm(None),
// Markdown: always carries the metadata gen_markdown_io returned —// vault layout, id strategy, and per-entity directory mapping.backend: ontogen::Backend::Markdown(markdown_output),Pipeline users rarely set this directly: with exactly one persistence
stage configured the backend is inferred, and .store_backend(...)
disambiguates when both are enabled. See the markdown backend
guide for everything that differs below the
store boundary — and the one thing that doesn’t: gen_api, gen_servers,
and gen_clients output is byte-identical between backends.
Method Naming
Section titled “Method Naming”Method names follow a consistent pattern:
| Operation | Pattern | Example |
|---|---|---|
| List | list_{plural_snake} | list_workouts |
| Get | get_{snake} | get_workout |
| Create | create_{snake} | create_workout |
| Update | update_{snake} | update_workout |
| Delete | delete_{snake} | delete_workout |
The pluralization uses a simple English rule set: add s, handle es for sibilants, handle ies for consonant + y.
| Entity | Plural Method |
|---|---|
Workout | list_workouts |
Exercise | list_exercises |
WorkoutSet | list_workout_sets |
Capability | list_capabilities |