Back to Blog

Rust Modules in Tauri: Organize Your Code Like a Pro! ๐Ÿฆ€

Sandy LaneSandy Lane
โ€ข

Video: Rust Modules in Tauri: Organize Your Code Like a Pro! ๐Ÿฆ€ by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read ยท interactive walkthrough
โ†’

One lib.rs is 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.

Ready? Take the quiz on the full lesson page โ†’
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.
โ†’