Skip to content
Documentation GitHub
Development Guides

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.md if starting fresh

Steps

1. Define the domain entity (if new)

If your command operates on a new entity, define it in crates/domain/src/.

crates/domain/src/bookmark.rs
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 a PermissionGuard proving the caller has owner capabilities.
  • Use command_span_with_id for structured tracing with an optional client-supplied operation ID.
  • Map repository errors to CommandError variants — 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:

Terminal window
pnpm generate:types

This 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 pass
  • cargo test -p core-tests — cross-layer integration tests pass
  • pnpm generate:types — TypeScript bindings generated without error
  • pnpm typecheck — TypeScript type-checks clean
  • pnpm desktop:dev — command is callable from the running app

See Also

Was this page helpful?