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.entities,
Some(&seaorm_output), // optional SeaORM metadata
&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(),
},
)?;
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.

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, workout: &mut Workout) {
if let Some(name) = &self.name {
workout.name.clone_from(name);
}
if let Some(date) = &self.date {
workout.date.clone_from(date);
}
if let Some(duration_minutes) = &self.duration_minutes {
workout.duration_minutes.clone_from(duration_minutes);
}
if let Some(notes) = &self.notes {
workout.notes.clone_from(notes);
}
if let Some(tags) = &self.tags {
workout.tags.clone_from(tags);
}
if let Some(created_at) = &self.created_at {
workout.created_at.clone_from(created_at);
}
}
}

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) -> Result<Vec<Workout>, AppError> {
let models = workout::Entity::find()
.all(self.db())
.await
.map_err(|e| AppError::DbError(e.to_string()))?;
let mut entities: Vec<Workout> = models.iter()
.map(Workout::from_model)
.collect();
for entity in &mut entities {
self.populate_workout_relations(entity).await?;
}
Ok(entities)
}

For entities without relations, the population loop is omitted and the method returns directly from the map.

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,
workout: &mut crate::schema::Workout,
) -> Result<(), crate::schema::AppError> {
workout.tags = self
.load_junction_ids("workout_tags", "workout_id", "tag_id", &workout.id)
.await?;
Ok(())
}

For has_many fields, the population uses a SeaORM query:

// has_many: query target table filtered by FK
node.contains = {
use crate::persistence::db::entities::node;
let children = node::Entity::find()
.filter(node::Column::ParentId.eq(&node.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
node.fulfills = self
.load_junction_ids("node_fulfills", "node_id", "requirement_id", &node.id)
.await?;

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 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 method is provided by your Store implementation. Ontogen generates the calls with the correct table and column names.

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 five standard methods per entity are:

Method NameKindReturn Type
list_{entities}Crud(List)Vec<Entity>
get_{entity}Crud(Get)Entity
create_{entity}Crud(Create)Entity
update_{entity}Crud(Update)Entity
delete_{entity}Crud(Delete)()

The seaorm parameter to gen_store is optional:

// With SeaORM metadata (preferred)
let store = ontogen::gen_store(&entities, Some(&seaorm_output), &config)?;
// Without -- infers junction table names from conventions
let store = ontogen::gen_store(&entities, None, &config)?;

When SeaOrmOutput is provided, the store generator uses the exact junction table names and FK column names from the SeaORM layer. When absent, it derives them using the same naming conventions ({entity}_{field} for junction tables, {entity}_id for FK columns). In practice, both paths produce the same names, but passing the output makes the dependency explicit and catches naming mismatches early.

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