Error Handling Patterns
This document describes how errors flow from domain logic to the user’s screen. Follow these patterns when adding new commands, use cases, or repository implementations.
Error Hierarchy
Errors flow inward-to-outward through the Clean Architecture layers:
Domain Application Commands Frontend───────────── ──────────────────── ────────────────── ──────────────────DomainError → PageRepositoryError → CommandError → parseCommandError() WorkspaceRepoError (sanitized) (TypeScript) TagRepositoryError AttachmentRepoError LayoutRepositoryError ...Each layer converts errors from the layer below. The key principle: internal details must never reach the frontend.
Layer Responsibilities
| Layer | Error Type(s) | Responsibility |
|---|---|---|
| Domain | DomainError | Pure validation (invalid state, constraint violations) |
| Application | PageRepositoryError, TagRepoError… | Repository-specific errors with technical detail |
| Commands | CommandError | User-facing, sanitized, transport-agnostic |
| Frontend | Parsed strings | Human-readable messages for toast/dialog display |
Domain-to-Application Conversion
DomainError variants map to repository errors via From implementations:
impl From<domain::DomainError> for PageRepositoryError { fn from(err: domain::DomainError) -> Self { match err { domain::DomainError::InvalidState(msg) => PageRepositoryError::InvalidData(msg), domain::DomainError::Validation(msg) => PageRepositoryError::Validation(msg), } }}CommandError Variants
CommandError is defined in crates/commands/src/error.rs. It is serialized as a tagged enum
(#[serde(tag = "type", content = "data")]) so all transports (Tauri IPC, HTTP, MCP) produce the same JSON shape.
| Variant | JSON Shape | When to Use |
|---|---|---|
NotFound | { type: "NotFound", data: { entity, id } } | Entity lookup returned nothing |
Validation | { type: "Validation", data: { message } } | User-correctable input error |
Internal | { type: "Internal", data: { message } } | System/infrastructure failure |
NoWorkspace | { type: "NoWorkspace" } | Command requires an open workspace but none set |
Permission | { type: "Permission", data: { message } } | Caller lacks required capability |
Unauthenticated | { type: "Unauthenticated", data: { message } } | User must sign in |
HTTP Status Code Mapping
When served via the HTTP bridge (feature = "http"), variants map to status codes:
NotFound → 404Validation → 400Internal → 500NoWorkspace → 409 (Conflict)Permission → 403Unauthenticated → 401Direct Construction Patterns
NoWorkspace is produced directly by AppState::require_workspace():
pub fn require_workspace(&self) -> Result<Workspace, CommandError> { self.workspace.current_workspace.lock().clone() .ok_or(CommandError::NoWorkspace)}Validation is produced directly by boundary validation functions (validate_title, validate_slug_input,
validate_uuid, etc.) and inline checks:
if slugs.len() > MAX_RESOLUTION_SLUGS { return Err(CommandError::Validation { message: format!("Too many slugs (max {})", MAX_RESOLUTION_SLUGS), });}Permission and Unauthenticated are produced via From impls on PermissionError and AuthError:
guard.require(Capability::PagesRead) .map_err(CommandError::from)?;The UserFacingError Trait
Defined in crates/commands/src/sanitize.rs, this trait is the bridge between technical repository errors and
user-friendly messages:
pub trait UserFacingError { /// Returns a user-friendly error message. fn user_message(&self) -> String;
/// Returns true if this is a user-correctable validation error. fn is_validation_error(&self) -> bool { false }
/// Returns true if this is an expected error (logged at warn, not error). fn is_expected(&self) -> bool { false }}Implementing UserFacingError for a New Repository Error
Every repository error type must implement this trait. Follow this pattern:
impl UserFacingError for MyRepositoryError { fn user_message(&self) -> String { match self { // User-provided data in the message is OK (slugs, names) MyRepositoryError::NotFound(name) => { format!("Item \"{name}\" could not be found.") } // Generic message hides paths, SQL, stack traces MyRepositoryError::Io(_) => { "Unable to save changes. Please check folder permissions.".to_string() } // Use sanitize_pass_through for messages that MIGHT be safe MyRepositoryError::InvalidData(reason) => { sanitize_pass_through(reason, "Invalid data. Please check your input.") } } }
fn is_validation_error(&self) -> bool { matches!(self, MyRepositoryError::InvalidData(_) | MyRepositoryError::AlreadyExists(_) ) }
fn is_expected(&self) -> bool { matches!(self, MyRepositoryError::NotFound(_) | MyRepositoryError::AlreadyExists(_) | MyRepositoryError::InvalidData(_) ) }}Classification Rules
is_validation_error() = true: The user can fix the input and retry (bad name, duplicate, constraint violation).is_expected() = true: Normal operation, not a system failure (NotFound, AlreadyExists, Cancelled). These are logged atwarn!level.- Both false (the default): Infrastructure failure (IO, database corruption, serialization). These are logged at
error!level.
Error Sanitization
Two functions in crates/commands/src/sanitize.rs convert repository errors to CommandError:
sanitize_error(error, context) — For System Errors
Always produces CommandError::Internal. Use this when calling use cases where all errors are system-level:
let use_case = GetPageUseCase::new(state.page_repo());use_case.execute(&guard, &slug) .map_err(|e| sanitize_error(e, "get_page"))Logging behavior:
is_expected() = true(e.g., NotFound) ->tracing::warn!is_expected() = false(e.g., IO error) ->tracing::error!with debug representation
sanitize_validation_error(error, context) — For Mixed Errors
Routes to either CommandError::Validation or CommandError::Internal based on is_validation_error():
use_case.execute(&guard, &slug, &new_name) .map_err(|e| sanitize_validation_error(e, "rename_workspace"))Use this when the error could be either user-correctable (bad input) or a system failure.
sanitize_pass_through(message, fallback) — For Conditional Messages
Checks whether a message contains technical details (file paths, SQL errors, Rust internals) and replaces them with a generic fallback:
// If reason is "name too long" → passes through// If reason is "SQLITE_CONSTRAINT: UNIQUE..." → returns the fallbacksanitize_pass_through(reason, "Invalid data. Please check your input.")The detection covers: file paths (/Users/, C:\), SQL (SQLITE_, rusqlite, UNIQUE constraint), Rust internals
(panicked at, .rs:, RUST_BACKTRACE), serialization (serde, JSON), network (reqwest, ECONNREFUSED).
Logging Conventions
Log Levels for Errors
| Condition | Level | Example |
|---|---|---|
| Expected, normal flow | warn! | Page not found, workspace already exists |
| Unexpected, bug/infra | error! | IO failure, database corruption, parse failure |
| Operation started | info! | tracing::info!(title = %title, "page_create_started") |
| Side-effect failure | warn! | Reference index update failed (best-effort) |
Structured Logging Fields
Commands use tracing spans with operation IDs for log correlation:
let _span = command_span_with_id("create_page", op_id).entered();tracing::info!(title = %title, parent_slug = ?parent_slug, "page_create_started");// ... on success:tracing::info!(slug = %response.slug, "page_create_success");The sanitize_error / sanitize_validation_error functions log the full technical error (via %error and
?error_debug) while returning only the sanitized user_message() to the caller.
Frontend Error Handling
apps/desktop/src-react/lib/errors.ts provides two functions:
parseCommandError(err: unknown): string
Extracts a human-readable message from any error shape:
import { parseCommandError } from "@/lib/errors";
try { await invoke("create_page", { title, parentSlug });} catch (err) { toast.error(parseCommandError(err));}Handling by variant:
NoWorkspace->"No workspace is currently open"Permission-> Fixed message (never leaks internal reason)Unauthenticated-> Extractsdata.messageor defaultsValidation/Internal-> Extractsdata.messageNotFound-> Formats"${entity} not found: ${id}"- Unrecognized ->
"An unexpected error occurred"
isAuthRequiredError(err: unknown): boolean
Checks if an error should trigger the sign-in dialog:
catch (err) { if (isAuthRequiredError(err)) { openSignInDialog(); } else { toast.error(parseCommandError(err)); }}Returns true for Permission and Unauthenticated variants.
Command Implementation Checklist
When adding a new Tauri command, follow this sequence:
#[tauri::command]#[specta::specta]pub fn my_command( state: State<AppState>, slug: String, op_id: Option<String>,) -> Result<MyResult, CommandError> { // 1. Tracing span let _span = command_span_with_id("my_command", op_id).entered(); tracing::info!(slug = %slug, "my_command_started");
// 2. Boundary validation (produces CommandError::Validation directly) validate_slug_input(&slug, "slug")?;
// 3. Require workspace (produces CommandError::NoWorkspace) let _workspace = state.require_workspace()?;
// 4. Permission guard (produces CommandError::Permission via From impl) let guard = state.resolve_owner_guard()?;
// 5. Execute use case with sanitized errors let use_case = MyUseCase::new(state.my_repo()); let result = use_case .execute(&guard, &slug) .map_err(|e| sanitize_error(e, "my_command"))?;
// 6. Best-effort side effects (warn on failure, never block) WriteEffectCoordinator::on_something(&state, result.id);
// 7. Success log tracing::info!(id = %result.id, "my_command_success");
Ok(result)}Anti-Patterns
Leaking Technical Details
// WRONG: Raw error message sent to frontend.map_err(|e| CommandError::Internal { message: e.to_string() })?
// RIGHT: Use sanitize_error which calls user_message().map_err(|e| sanitize_error(e, "my_command"))?Using error! for Expected Conditions
// WRONG: NotFound is expected, not a system failuretracing::error!(slug = %slug, "page not found");
// RIGHT: Handled by sanitize_error via is_expected()// Or if logging manually:tracing::warn!(slug = %slug, "page not found");Swallowing Errors Silently
// WRONG: Error disappearslet _ = use_case.execute(&guard, &slug);
// RIGHT: For best-effort operations, log the failureif let Err(e) = update_refs.execute(page.id) { tracing::warn!(error = %e, slug = %slug, "reference_index_update_failed");}Constructing CommandError::Internal Directly
// WRONG: Bypasses sanitization, may leak detailsreturn Err(CommandError::Internal { message: format!("Failed to read {}: {}", path.display(), io_err),});
// RIGHT: Implement UserFacingError for the error type,// then use sanitize_error() to produce CommandError::InternalUsing String(err) in Frontend
// WRONG: Produces "[object Object]" for CommandError variantstoast.error(String(err));
// WRONG: CommandError is not an Error instancetoast.error(err instanceof Error ? err.message : "Unknown error");
// RIGHT: Use parseCommandError which handles all shapestoast.error(parseCommandError(err));Missing Boundary Validation
// WRONG: Slug goes straight to repository without validationlet page = state.page_repo().get_by_slug(&slug)?;
// RIGHT: Validate at the system boundary firstvalidate_slug_input(&slug, "slug")?;let page = state.page_repo().get_by_slug(&slug) .map_err(|e| sanitize_error(e, "get_page"))?;Key Source Files
| File | Purpose |
|---|---|
crates/domain/src/error.rs | DomainError enum (InvalidState, Validation) |
crates/application/src/page/services.rs | PageRepositoryError + DomainError conversion |
crates/commands/src/error.rs | CommandError enum + HTTP/From impls |
crates/commands/src/sanitize.rs | UserFacingError trait + sanitization functions |
crates/commands/src/validation.rs | Boundary validation (title, slug, UUID, path) |
crates/commands/src/context.rs | Tracing span factory for operation correlation |
apps/desktop/src-tauri/src/commands/page.rs | Example command implementations |
apps/desktop/src-react/lib/errors.ts | Frontend error parsing |
apps/desktop/src-react/lib/errors.test.ts | Frontend error parsing tests |
Was this page helpful?
Thanks for your feedback!