Rust Modules in Tauri: Organize Your Code Like a Pro! ๐ฆ
Video: Rust Modules in Tauri: Organize Your Code Like a Pro! ๐ฆ by Taught by Celeste AI - AI Coding Coach
One
lib.rsis fine for ten commands. By twenty, split into modules. The standard Rust file layout, applied to a Tauri app.
After a few features, your lib.rs starts to bloat. Notes commands, settings commands, sync commands, all in one file with shared imports at the top. Time to split.
The starting state
// src-tauri/src/lib.rs
use serde::{Deserialize, Serialize};
#[tauri::command]
fn add_note(...) -> Result<i64, String> { /* ... */ }
#[tauri::command]
fn list_notes() -> Result<Vec<Note>, String> { /* ... */ }
#[tauri::command]
fn delete_note(id: i64) -> Result<(), String> { /* ... */ }
#[tauri::command]
fn get_settings() -> Result<Settings, String> { /* ... */ }
#[tauri::command]
fn save_settings(s: Settings) -> Result<(), String> { /* ... */ }
#[tauri::command]
async fn sync_now() -> Result<(), String> { /* ... */ }
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
add_note, list_notes, delete_note,
get_settings, save_settings,
sync_now,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Six commands plus the runner. At ten commands the file is fine. At thirty it's a wall of unrelated code.
Splitting by domain
src-tauri/src/
โโโ main.rs
โโโ lib.rs # entry: declares modules, runs the app
โโโ notes.rs # notes commands
โโโ settings.rs # settings commands
โโโ sync.rs # sync commands
โโโ error.rs # AppError type
โโโ state.rs # AppState struct
Each module owns one domain. The lib.rs becomes a thin module map and runner.
notes.rs
use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Serialize)]
pub struct Note {
pub id: i64,
pub title: String,
pub body: String,
}
#[tauri::command]
pub async fn add_note(title: String, body: String) -> Result<i64, AppError> {
// ...
}
#[tauri::command]
pub async fn list_notes() -> Result<Vec<Note>, AppError> {
// ...
}
#[tauri::command]
pub async fn delete_note(id: i64) -> Result<(), AppError> {
// ...
}
Each command is pub so lib.rs can reference it.
lib.rs after splitting
mod error;
mod state;
mod notes;
mod settings;
mod sync;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(state::AppState::default())
.invoke_handler(tauri::generate_handler![
notes::add_note,
notes::list_notes,
notes::delete_note,
settings::get,
settings::save,
sync::sync_now,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
mod foo; declares a submodule. The compiler looks for src-tauri/src/foo.rs (or src-tauri/src/foo/mod.rs for sub-trees).
The generate_handler![] macro accepts module-qualified names: notes::add_note. JS-side, the call is still just invoke("add_note", ...) โ the module path is stripped.
Subdirectories for deeper structure
For more elaborate features:
src-tauri/src/
โโโ notes/
โโโ mod.rs # declares sub-modules and re-exports
โโโ commands.rs # the #[tauri::command] functions
โโโ model.rs # the Note struct, type definitions
โโโ repo.rs # database queries
โโโ service.rs # business logic between commands and repo
notes/mod.rs:
pub mod commands;
pub mod model;
mod repo;
mod service;
pub use commands::*;
The pub use commands::*; re-exports everything from commands.rs at the notes level, so external code references notes::add_note (not notes::commands::add_note). Internal modules (repo, service) stay private.
Visibility patterns
pubโ visible to anyone (other modules and external crates).pub(crate)โ visible only within this crate. Use for internal API surfaces you want to share across modules but not expose to dependents.pub(super)โ visible only to the parent module.- (no modifier) โ private to the current module.
For an app's internal modules, pub(crate) is often the right default. Functions only used by the runner can stay pub(crate). Commands need to be at least pub(crate) (so lib.rs can reference them in generate_handler!); usually they're pub since the module is private to the crate anyway.
Shared state across modules
// src-tauri/src/state.rs
use std::sync::Mutex;
use sqlx::SqlitePool;
#[derive(Default)]
pub struct AppState {
pub db: Mutex<Option<SqlitePool>>,
pub config: Mutex<Config>,
}
Register with .manage(AppState::default()). Reach it from any command:
#[tauri::command]
pub async fn add_note(state: tauri::State<'_, AppState>, /* ... */) -> Result<...> {
let db_lock = state.db.lock().unwrap();
let pool = db_lock.as_ref().ok_or("DB not initialised")?;
// use pool
}
For an Arc<Mutex<T>> pattern (shared async-friendly state), prefer Tokio's Mutex over std::sync::Mutex to avoid blocking the async runtime.
Cross-module imports
In any sub-module:
use crate::error::AppError;
use crate::state::AppState;
use crate::notes::model::Note;
crate:: is the path from the crate root (in this case, lib.rs). Always relative to the crate, never to the current file.
When to split
The right time to introduce modules is when:
- A single file exceeds ~300โ500 lines.
- You have repeated logic that wants its own home.
- You're tempted to use
// TODO: split this up laterโ that's now. - You want to test a subset in isolation.
Don't pre-split. A 50-line lib.rs is fine. Wait for the file to feel cluttered.
A larger example
For an app with notes, tasks, calendar, and search:
src-tauri/src/
โโโ main.rs
โโโ lib.rs
โโโ error.rs
โโโ state.rs
โโโ db/
โ โโโ mod.rs
โ โโโ migrations.rs
โ โโโ pool.rs
โโโ notes/
โ โโโ mod.rs
โ โโโ commands.rs
โ โโโ model.rs
โ โโโ repo.rs
โโโ tasks/
โ โโโ mod.rs
โ โโโ commands.rs
โ โโโ model.rs
โ โโโ repo.rs
โโโ calendar/
โ โโโ mod.rs
โ โโโ commands.rs
โ โโโ model.rs
โ โโโ ical.rs
โโโ search/
โโโ mod.rs
โโโ commands.rs
โโโ index.rs
Each domain folder has the same shape: commands (the IPC surface), model (data types), repo or domain helpers. The pattern repeats; each domain is independently navigable.
Common mistakes
Pre-emptive splitting. Splitting a 50-line file into 7 files is overhead. Wait for size to demand it.
Circular imports. Module A uses module B, which uses module A. Rust will complain. Resolve by extracting shared types into a third module.
Too-flat structure. 30 commands in one commands.rs. Split by domain.
Inconsistent visibility. Some commands pub, others pub(crate). Pick a convention and apply uniformly.
Forgetting to add the module to lib.rs. A file in the project that isn't declared with mod foo; is invisible to the compiler.
What's next
Next lesson: sharing state between Rust and React. The Tauri-specific patterns for keeping the two halves in sync.
Recap
Start with lib.rs. Split into modules at ~300 lines or by domain. Use sub-directories for deeper structure (notes/{mod, commands, model, repo}.rs). Keep lib.rs thin โ module declarations, state setup, and generate_handler!. pub for command functions; pub(crate) for internal helpers; private for module-local. Use crate:: paths for cross-module imports.
Next: sharing state.