Add a Tauri Command
End-to-end guide for adding a new command following the vertical slice pattern.
Goal
Add a new Tauri command that is callable from the React frontend, wired through all Clean Architecture layers.
Prerequisites
- Familiarity with Rust and the Clean Architecture layers
- Development environment set up (see Getting Started)
- An existing domain entity, or follow this guide alongside
adding-a-use-case.mdif starting fresh
Steps
1. Define the domain entity (if new)
If your command operates on a new entity, define it in crates/domain/src/.
use uuid::Uuid;use chrono::{DateTime, Utc};
#[derive(Debug, Clone, specta::Type, serde::Serialize, serde::Deserialize)]pub struct Bookmark { pub id: Uuid, pub name: String, pub description: Option<String>, pub timestamp: DateTime<Utc>,}
impl Bookmark { pub fn new(name: String, description: Option<String>, created_by: Option<String>) -> Result<Self, &'static str> { if name.trim().is_empty() { return Err("Bookmark name cannot be empty"); } Ok(Self { id: Uuid::new_v4(), name, description, timestamp: Utc::now(), }) }}Re-export from crates/domain/src/lib.rs:
pub mod bookmark;pub use bookmark::Bookmark;2. Create the use case in the application layer
Follow the file-per-use-case pattern. See Adding a Use Case for the full walkthrough.
The use case lives in crates/application/src/<context>/create_<entity>.rs and depends on a repository trait defined in
crates/application/src/<context>/services.rs.
3. Implement the repository in infrastructure
Add a concrete SQLite implementation in crates/infrastructure/sqlite/src/workspace/. If you need new tables, create a
migration first — see Writing a Migration.
Register the new type in crates/infrastructure/sqlite/src/lib.rs:
pub use workspace::bookmark_repository::SqliteBookmarkRepository;4. Add the Tauri command handler
Create a new file apps/desktop/src-tauri/src/commands/<context>.rs.
//! Bookmark Tauri commands.
use super::CommandError;use crate::state::AppState;use application::bookmark::{ BookmarkRepositoryError, CreateBookmarkRequest, CreateBookmarkUseCase,};use commands::command_span_with_id;use domain::Bookmark;use tauri::State;
fn map_bookmark_error(e: BookmarkRepositoryError, op: &str) -> CommandError { match e { BookmarkRepositoryError::NotFound(id) => CommandError::NotFound { entity: "Bookmark".to_string(), id, }, BookmarkRepositoryError::InvalidData(msg) => CommandError::Validation { message: msg }, BookmarkRepositoryError::Database(msg) => { tracing::error!(error = %msg, op = %op, "bookmark_repository_database_error"); CommandError::Internal { message: "A database error occurred".to_string(), } } }}
/// Create a new named bookmark.#[tauri::command]#[specta::specta]pub fn create_bookmark( state: State<AppState>, name: String, description: Option<String>, op_id: Option<String>,) -> Result<Bookmark, CommandError> { let _span = command_span_with_id("create_bookmark", op_id).entered();
let _workspace = state.require_workspace()?; let _guard = state.resolve_owner_guard()?;
let use_case = CreateBookmarkUseCase::new(state.bookmark_repo()); use_case .execute(CreateBookmarkRequest { name, description, created_by: None }) .map_err(|e| map_bookmark_error(e, "create_bookmark"))}Key conventions:
#[tauri::command]exposes the function to Tauri’s IPC layer.#[specta::specta]registers the function for TypeScript type generation.state.require_workspace()?guards against commands called before a workspace is open.state.resolve_owner_guard()?produces aPermissionGuardproving the caller has owner capabilities.- Use
command_span_with_idfor structured tracing with an optional client-supplied operation ID. - Map repository errors to
CommandErrorvariants — never leak internal error messages.
5. Wire up the state accessor (if new repository)
If your command uses a repository that is not yet exposed via AppState, add an accessor in
apps/desktop/src-tauri/src/state.rs.
Add the repository to RepositoryBundle:
pub struct RepositoryBundle { // ... existing repos ... pub bookmark_repository: parking_lot::Mutex<SqliteBookmarkRepository>,}Add a convenience accessor to impl AppState:
pub fn bookmark_repo(&self) -> SqliteBookmarkRepository { self.repos.bookmark_repository.lock().clone()}If the repository needs a live workspace database connection (like SqliteBookmarkRepository), wire it in
AppState::wire_workspace_db() after a workspace is opened.
6. Register the command module and command
Declare the module in apps/desktop/src-tauri/src/commands/mod.rs:
pub mod bookmark;Register each command function in the create_specta_builder() function in apps/desktop/src-tauri/src/main.rs:
tauri_specta::collect_commands![ // ... existing commands ... commands::bookmark::create_bookmark, commands::bookmark::get_bookmark, commands::bookmark::list_bookmarks, commands::bookmark::delete_bookmark,]The collect_commands! macro is provided by tauri-specta. Every function annotated with #[specta::specta] must be
listed here, or it will not be callable from the frontend and will not appear in the generated TypeScript bindings.
7. Generate TypeScript types
After changing any Rust types annotated with #[specta::specta] or specta::Type, regenerate the TypeScript bindings:
pnpm generate:typesThis runs the type-generation test in main.rs, which writes to packages/contracts/generated/bindings.ts. Commit the
updated bindings file alongside your Rust changes.
8. Call from the React frontend
Import the generated command binding and call it via invoke:
import { invoke } from '@tauri-apps/api/core';import type { Bookmark } from '@inklings/contracts';
async function createBookmark(name: string, description?: string): Promise<Bookmark> { return invoke<Bookmark>('create_bookmark', { name, description });}If you regenerated types, the bindings file also exports typed wrappers. Check
packages/contracts/generated/bindings.ts for the exact function signatures.
Verification
cargo test -p infrastructure-sqlite— repository tests passcargo test -p core-tests— cross-layer integration tests passpnpm generate:types— TypeScript bindings generated without errorpnpm typecheck— TypeScript type-checks cleanpnpm desktop:dev— command is callable from the running app
See Also
- Adding a Use Case — application layer walkthrough
- Writing a Migration — add tables needed by new repositories
- Development Guide
- Development Guide: File-Per-Use-Case Pattern
Was this page helpful?
Thanks for your feedback!