Iron Log
Iron Log is a weight-lifting tracker built as a Tauri desktop app with a Nuxt frontend. It’s the reference example for Ontogen — a small but complete application that exercises the full pipeline from schema definitions through generated TypeScript client code.
Four entity definitions produce 39 generated files. The entire backend — persistence, CRUD, API, HTTP handlers, and IPC commands — comes from those four annotated structs plus a build.rs.
The source lives at examples/iron-log/ in the Ontogen repository.
The domain
Section titled “The domain”A weight-lifting tracker needs a few things:
- Exercises: the movements you do (bench press, squat, deadlift). Each has a name, muscle group, and equipment type.
- Workouts: a training session on a specific date. Can have a name, duration, and notes.
- Workout Sets: individual sets within a workout — weight, reps, RPE. Each set belongs to a workout and an exercise.
- Tags: labels you can attach to workouts (e.g., “push day”, “deload”, “PR”).
The relationships: workout sets belong to both a workout and an exercise (two belongs_to relations). Workouts have a many-to-many relationship with tags through a junction table.
The four entities
Section titled “The four entities”Exercise
Section titled “Exercise”The simplest entity. No relations, just data fields.
#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]#[ontology(entity, table = "exercises")]pub struct Exercise { #[ontology(id)] pub id: String,
pub name: String, pub muscle_group: String, pub equipment: String,
#[serde(default)] pub notes: Option<String>,}Straightforward: a string ID, required fields for name/muscle group/equipment, and an optional notes field. No annotations beyond id needed — everything else is plain data.
Even simpler. Just a name.
#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]#[ontology(entity, table = "tags")]pub struct Tag { #[ontology(id)] pub id: String,
pub name: String,}Workout
Section titled “Workout”This one has a relation — workouts can have tags.
#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]#[ontology(entity, table = "workouts")]pub struct Workout { #[ontology(id)] pub id: String,
#[serde(default)] pub name: Option<String>,
pub date: String,
#[serde(default)] pub duration_minutes: Option<i32>,
#[serde(default)] pub notes: Option<String>,
#[serde(default)] #[ontology(relation(many_to_many, target = "Tag"))] pub tags: Vec<String>,
pub created_at: String,}The tags field is annotated with relation(many_to_many, target = "Tag"). This triggers:
- A
workout_tagsjunction table entity in SeaORM generation. sync_junction()calls in the store’screate_workoutandupdate_workoutmethods.populate_workout_relations()that loads tag IDs from the junction table on reads.
WorkoutSet
Section titled “WorkoutSet”Two belongs_to relations — every set references both a workout and an exercise.
#[derive(Debug, Clone, Serialize, Deserialize, OntologyEntity)]#[ontology(entity, table = "workout_sets")]pub struct WorkoutSet { #[ontology(id)] pub id: String,
#[ontology(relation(belongs_to, target = "Workout"))] pub workout_id: String,
#[ontology(relation(belongs_to, target = "Exercise"))] pub exercise_id: String,
pub set_number: i32, pub weight_grams: i32, pub reps: i32,
#[serde(default)] pub rpe: Option<i32>,
#[serde(default)] pub notes: Option<String>,}The workout_id and exercise_id fields are foreign keys. The belongs_to annotations tell Ontogen to generate SeaORM relation definitions pointing to the Workout and Exercise entities.
Notice weight_grams: i32 instead of a float. Storing weight as integer grams avoids floating-point precision issues — a deliberate domain modeling choice, not an Ontogen requirement.
What gets generated
Section titled “What gets generated”From these four structs, cargo build produces 39 files across the project. Here’s the breakdown.
SeaORM entities (6 files)
Section titled “SeaORM entities (6 files)”src/persistence/db/entities/generated/ mod.rs exercise.rs tag.rs workout.rs workout_set.rs workout_tags.rs # junction table for Workout <-> TagEach entity file has a Model struct with SeaORM column annotations, a Relation enum, and ActiveModelBehavior. The workout.rs entity has a Related<tag::Entity> implementation that routes through the workout_tags junction table.
The junction table (workout_tags.rs) is a full SeaORM entity with BelongsTo relations to both workout and tag.
Conversions (5 files)
Section titled “Conversions (5 files)”src/persistence/db/conversions/generated/ mod.rs exercise.rs tag.rs workout.rs workout_set.rsEach contains from_model() (SeaORM model to schema type) and to_active_model() (schema type to SeaORM active model). For workout, from_model() initializes tags: Vec::new() because the junction data is loaded separately by populate_workout_relations().
DTOs (5 files)
Section titled “DTOs (5 files)”src/schema/dto/ mod.rs exercise.rs tag.rs workout.rs workout_set.rsEach contains CreateEntityInput and UpdateEntityInput. The create input has all fields. The update input wraps every non-ID field in Option for partial updates. Both have Deserialize, JsonSchema, and specta::Type derives.
Store CRUD (5 files)
Section titled “Store CRUD (5 files)”src/store/generated/ mod.rs exercise.rs tag.rs workout.rs workout_set.rsEach contains the five CRUD methods (list_*, get_*, create_*, update_*, delete_*) plus a EntityUpdate struct with an apply() method and From implementations for DTO-to-entity conversion.
The workout.rs store file is the most interesting because of the many-to-many relation. Its create_workout method:
- Calls
hooks::before_create. - Inserts the workout row.
- Calls
sync_junction("workout_tags", ...)to populate the junction table. - Reloads the workout (to get populated relations).
- Emits a change event.
- Calls
hooks::after_create.
Hooks (5 files)
Section titled “Hooks (5 files)”src/store/hooks/ mod.rs exercise.rs tag.rs workout.rs workout_set.rsScaffolded once, never overwritten. Each has before_create, after_create, before_update, after_update, before_delete, after_delete — all with empty bodies that return Ok(()). In Iron Log, they’re left empty because the app doesn’t need pre/post-processing hooks.
API modules (5 files)
Section titled “API modules (5 files)”src/api/v1/generated/ mod.rs exercise.rs tag.rs workout.rs workout_set.rsThin forwarding layer. Each function (e.g., list, get_by_id, create) takes a &Store reference and delegates to the corresponding store method.
Transport handlers (2 files)
Section titled “Transport handlers (2 files)”src/api/transport/http/generated.rssrc/api/transport/ipc/generated.rsThe HTTP file generates Axum route handlers. The IPC file generates Tauri command handlers. Both call through the same API layer.
The IPC file contains 20 commands (5 per entity x 4 entities). Each command extracts State<'_, Arc<AppState>>, gets the store, calls the API function, and maps errors to strings.
TypeScript client (1 file)
Section titled “TypeScript client (1 file)”../src-nuxt/app/generated/transport.tsA split transport layer that provides typed functions for every CRUD operation. Detects at runtime whether Tauri IPC is available and routes accordingly.
The build.rs
Section titled “The build.rs”Iron Log’s build script uses the Pipeline builder. The full file is around 70 lines and looks like this (verbatim from examples/iron-log/src-tauri/build.rs):
use std::path::PathBuf;
use ontogen::servers::{ClientGenerator, NamingConfig, ServerGenerator};use ontogen::{Pipeline, ServersConfig};
fn main() { println!("cargo:rerun-if-changed=build.rs"); ontogen::emit_rerun_directives_excluding( &PathBuf::from("src/api/v1"), &["generated"], );
let servers_config = ServersConfig { api_dir: "src/api/v1".into(), state_type: "AppState".into(), service_import_path: "crate::api::v1".into(), types_import_path: "crate::schema".into(), state_import: "crate::AppState".into(), naming: NamingConfig::default(), generators: vec![ ServerGenerator::HttpAxum { output: "src/api/transport/http/generated.rs".into(), }, ServerGenerator::TauriIpc { output: "src/api/transport/ipc/generated.rs".into(), }, ], client_generators: vec![ ClientGenerator::HttpTauriIpcSplit { output: "../src-nuxt/app/generated/transport.ts".into(), bindings_path: "../src-nuxt/app/generated/types.ts".into(), }, ClientGenerator::AdminRegistry { output: "../src-nuxt/app/admin/generated/admin-registry.ts".into(), }, ], rustfmt_edition: "2024".into(), sse_route_overrides: Default::default(), ts_skip_commands: vec![], route_prefix: None, store_type: Some("Store".into()), store_import: Some("crate::store::Store".into()), pagination: None, // Pipeline auto-fills this from parsed schema entities. schema_entities: Vec::new(), };
Pipeline::new("src/schema") .seaorm( "src/persistence/db/entities/generated", "src/persistence/db/conversions/generated", ) .dtos("src/schema/dto") .store("src/store/generated", Some::<PathBuf>("src/store/hooks".into())) .api("src/api/v1/generated", "AppState") .servers(servers_config) .build() .unwrap_or_else(|e| { e.emit_cargo_warning(); panic!("ontogen pipeline failed: {e}"); });
tauri_build::build();}Pipeline runs every stage in dependency order regardless of method-call order. It also threads each stage’s typed output (SchemaOutput → SeaOrmOutput → ApiOutput → ServersOutput) into the next, applies schema_module_path defaults to StoreConfig/ApiConfig, and forwards schema.entities into ServersConfig.schema_entities so the admin-registry generator gets the field metadata it needs.
The Store
Section titled “The Store”The hand-written Store type provides the infrastructure that generated CRUD code relies on:
pub struct Store { pub db: Arc<DatabaseConnection>, change_tx: broadcast::Sender<EntityChange>,}It exposes:
db()— returns the database connection for SeaORM queries.emit_change()— broadcasts change events (created/updated/deleted) that the UI can subscribe to.sync_junction()— replaces junction table rows for many-to-many updates.load_junction_ids()— loads related IDs from a junction table.
The generated CRUD methods are implemented as impl Store blocks, so they have access to all of this.
Project layout
Section titled “Project layout”iron-log/ src-tauri/ build.rs # Ontogen pipeline Cargo.toml src/ schema/ exercise.rs # 4 entity definitions tag.rs # (you write these) workout.rs workout_set.rs mod.rs dto/ # generated input types persistence/ db/ entities/generated/ # generated SeaORM entities conversions/generated/ # generated model conversions fs_markdown/ # markdown parser (for frontmatter) store/ generated/ # generated CRUD methods hooks/ # scaffolded lifecycle hooks mod.rs # Store struct (you write this) api/ v1/ generated/ # generated API modules mod.rs transport/ http/generated.rs # generated Axum handlers ipc/generated.rs # generated Tauri IPC commands lib.rs # AppState definition main.rs # Tauri app entry point src-nuxt/ app/ generated/ transport.ts # generated TypeScript clientThe pattern is consistent: you write schema files and infrastructure code (Store, AppState, mod.rs files). Ontogen fills in the generated/ directories and scaffolds hooks. Your code and generated code live side by side but never overlap.
Running the example
Section titled “Running the example”From the examples/iron-log/ directory:
# Install frontend dependenciescd src-nuxt && npm install && cd ..
# Build and run the Tauri appcd src-tauri && cargo tauri devThe Tauri build triggers build.rs, which runs the Ontogen pipeline and generates all 39 files before compilation. The Nuxt dev server picks up the generated TypeScript client.
What to look at
Section titled “What to look at”If you’re exploring the example to understand Ontogen, start here:
src/schema/workout.rs— the most interesting entity because of the many-to-many tag relation.src/store/generated/workout.rs— see how the relation drives junction table sync in create/update.src/persistence/db/entities/generated/workout_tags.rs— the generated junction table entity.src/api/transport/ipc/generated.rs— 20 Tauri IPC commands, all generated from 4 entities.build.rs— the full pipeline in one file, six stages, explicit data flow.
The progression from schema definition to transport handler is the point. One annotated struct becomes a SeaORM entity, a conversion layer, a DTO pair, five CRUD store methods, five API forwarding functions, five HTTP routes, five IPC commands, and five TypeScript client functions. Multiply by four entities, and you have a complete backend from 80 lines of schema code.