Skip to content

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.

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,
},
)?;
FieldTypeDescription
output_dirPathBufDirectory for generated store modules
hooks_dirOption<PathBuf>Directory for scaffolded hook files. None skips hook scaffolding.
schema_module_pathStringRust import path for the schema module in generated code. Use ontogen::DEFAULT_SCHEMA_MODULE_PATH ("crate::schema") for the canonical default.
backendBackendThe 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.

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.rs

And 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.rs

Each entity module contains four things. Let’s trace through what gets generated for a Workout entity with a many_to_many tags relation.

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) become Option<T> — None means “don’t update”
  • Nullable types (Option<String>) become Option<Option<T>> — outer None means “don’t update”, Some(None) means “set to null”
  • Vec types (Vec<String>) become Option<Vec<T>> — None means “don’t update”

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.

Two From implementations bridge DTO input types to domain types:

// Create: DTO -> domain entity
impl 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 struct
impl 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,
}
}
}

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.

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.
  • None sorts 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.0 equal to 0.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.

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 FK
record.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 table
record.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.

When creating or updating an entity, many_to_many fields are synced via sync_junction. This method:

  1. Deletes all existing junction rows for the entity
  2. Inserts new junction rows for each ID in the field, in list order
// In create
self.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, &current.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).

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 .; not index or log (those two are reserved for the vault’s own files); and not a Windows device name (con, prn, aux, nul, com0 to com9, lpt0 to lpt9), 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 (DbError on SeaORM, Md on 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 (é to e, ß to ss, æ to ae, ł to l), and turns every run of anything else into one -, trimmed from both ends: Crème brûlée à Łódź slugs to creme-brulee-a-lodz. A slug is cut to 190 bytes (and a - left at the cut trimmed), so the probes base-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: Provided fails with AppError::{Entity}IdRequired(reason); SlugFromField(f) slugifies the entity’s f field (an empty slug is {Entity}IdRequired); Uuid generates a v4 UUID. A derived id that is taken, or reserved (index, con), is probed as base-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
// ...
}

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 to NULL after the record is written.
  • If it is a plain String, a child cannot be left without a parent, so the update fails with AppError::{Child}ParentRequired(child_id) before anything is written. {Child} is the has_many target entity. Declare the variant in your AppError for such an entity; it is constructed only for a non-Option foreign 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).

The generator produces different code depending on whether an entity has relations:

Simple entities (no has_many or many_to_many):

  • No populate_relations method
  • 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_relations method generated
  • List and get call populate_relations after conversion
  • Create extracts relation field values before converting to active model
  • Update tracks which relation fields changed and conditionally syncs

The generated CRUD methods call hook functions at six points:

HookTimingReceivesCan
before_createBefore insert&mut EntityModify entity, reject with Err
after_createAfter insert + relations&EntitySide effects
before_updateBefore apply + persist&Entity, &EntityUpdateValidate, reject with Err
after_updateAfter persist + relations&EntitySide effects
before_deleteBefore delete&str (id)Reject with Err
after_deleteAfter delete&str (id)Side effects

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.

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>,
}

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 NameKindReturn 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)()

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 names follow a consistent pattern:

OperationPatternExample
Listlist_{plural_snake}list_workouts
Getget_{snake}get_workout
Createcreate_{snake}create_workout
Updateupdate_{snake}update_workout
Deletedelete_{snake}delete_workout

The pluralization uses a simple English rule set: add s, handle es for sibilants, handle ies for consonant + y.

EntityPlural Method
Workoutlist_workouts
Exerciselist_exercises
WorkoutSetlist_workout_sets
Capabilitylist_capabilities