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 36 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 36 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. - Checks the id (iron-log’s id strategy is the default,
IdStrategy::Provided, so a missing id isAppError::WorkoutIdRequiredand a taken oneAppError::WorkoutAlreadyExists) and 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 that speak JSON:API: resource routes for each entity, relationship and related-resource endpoints for workouts and workout sets, the /api/stats/workout custom op, and the /api/events/… streams. The IPC file generates Tauri command handlers. Both call through the same API layer. Iron Log: the wire shows the HTTP requests and responses, captured from a running server.
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 (3 files)
Section titled “TypeScript client (3 files)”../src-nuxt/app/generated/transport.ts../src-nuxt/app/generated/types.ts../src-nuxt/app/admin/generated/admin-registry.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 75 lines; here it is, verbatim from examples/iron-log/src-tauri/build.rs minus one long explanatory comment:
use std::path::PathBuf;
use ontogen::clients::ClientGenerator;use ontogen::servers::{NamingConfig, ServerGenerator};use ontogen::{ClientsConfig, IdStrategy, 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(), }, ], sse_route_overrides: Default::default(), route_prefix: None, store_type: Some("Store".into()), store_import: Some("crate::store::Store".into()), pagination: None, extra_surfaces: vec![], // The pipeline scans its schema directory for `AppError`. error_source_dir: None, };
let clients_config = ClientsConfig { 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(), }, ], store_type: Some("Store".into()), store_import: Some("crate::store::Store".into()), ..ClientsConfig::new("src/api/v1", "AppState", "crate::api::v1", "crate::schema", "crate::AppState") };
let pipeline = 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())) // Workout.name is Option<String>, so SlugFromField is out; the // caller supplies every id. .store_id_strategy(IdStrategy::Provided) .api("src/api/v1/generated", "AppState") .servers(servers_config) .clients(clients_config); pipeline.build().unwrap_or_else(|e| { e.emit_cargo_warning(); panic!("ontogen pipeline failed: {e}"); });
tauri_build::build();}Two things worth noticing:
..ClientsConfig::new(...)— the constructor takes the five inputs client generation cannot infer and sets every other field to an inert default (no TypeScript formatter, no route prefix, no pagination, no label overrides). The struct literal names only what Iron Log changes, so a field added toClientsConfiglater does not break thisbuild.rs.error_source_dir— leftNonebecausePipelinefills it with the schema directory. The generated HTTP server scans that directory forenum AppErrorand maps each variant to an HTTP status andcode. A directgen_serverscaller sets it, and passes&schemaas the first argument ofgen_serversandgen_clients.
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 passes schema.entities to the servers and clients stages, so the HTTP resources and the admin-registry field metadata come from the parsed schema.
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, inserting them in list order.load_junction_ids()— loads related IDs from a junction table withORDER BY rowid, so a list comes back in the order it was written.
The generated CRUD methods are implemented as impl Store blocks, so they have access to all of this.
The AppError in src/schema/mod.rs declares {Entity}NotFound, {Entity}IdRequired and {Entity}AlreadyExists for each of the four entities, plus DbError. It depends on ontogen-core (see Creating records).
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 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 36 files before compilation. The Nuxt dev server picks up the generated TypeScript client.
To serve the HTTP API without Tauri, run the headless binary. It creates its SQLite tables from the generated SeaORM entities, in memory unless IRON_LOG_DB names a file:
cd src-tauri && PORT=39104 cargo run --bin iron-log-http # without PORT it serves on 3004SQLite enforces the entities’ foreign keys, so deleting a row that another row still references (a tagged workout, a workout with sets, a tag in use) answers 500 db_error (backlog item B-DLFK tracks the fix).
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 JSON:API handlers, five IPC commands, and five TypeScript client functions. Multiply by four entities, and you have a complete backend from 80 lines of schema code.