Skip to content
Documentation GitHub
Development Guides

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

LayerError Type(s)Responsibility
DomainDomainErrorPure validation (invalid state, constraint violations)
ApplicationPageRepositoryError, TagRepoErrorRepository-specific errors with technical detail
CommandsCommandErrorUser-facing, sanitized, transport-agnostic
FrontendParsed stringsHuman-readable messages for toast/dialog display

Domain-to-Application Conversion

DomainError variants map to repository errors via From implementations:

crates/application/src/page/services.rs
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.

VariantJSON ShapeWhen 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 → 404
Validation → 400
Internal → 500
NoWorkspace → 409 (Conflict)
Permission → 403
Unauthenticated → 401

Direct 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 at warn! 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 fallback
sanitize_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

ConditionLevelExample
Expected, normal flowwarn!Page not found, workspace already exists
Unexpected, bug/infraerror!IO failure, database corruption, parse failure
Operation startedinfo!tracing::info!(title = %title, "page_create_started")
Side-effect failurewarn!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 -> Extracts data.message or defaults
  • Validation / Internal -> Extracts data.message
  • NotFound -> 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 failure
tracing::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 disappears
let _ = use_case.execute(&guard, &slug);
// RIGHT: For best-effort operations, log the failure
if 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 details
return 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::Internal

Using String(err) in Frontend

// WRONG: Produces "[object Object]" for CommandError variants
toast.error(String(err));
// WRONG: CommandError is not an Error instance
toast.error(err instanceof Error ? err.message : "Unknown error");
// RIGHT: Use parseCommandError which handles all shapes
toast.error(parseCommandError(err));

Missing Boundary Validation

// WRONG: Slug goes straight to repository without validation
let page = state.page_repo().get_by_slug(&slug)?;
// RIGHT: Validate at the system boundary first
validate_slug_input(&slug, "slug")?;
let page = state.page_repo().get_by_slug(&slug)
.map_err(|e| sanitize_error(e, "get_page"))?;

Key Source Files

FilePurpose
crates/domain/src/error.rsDomainError enum (InvalidState, Validation)
crates/application/src/page/services.rsPageRepositoryError + DomainError conversion
crates/commands/src/error.rsCommandError enum + HTTP/From impls
crates/commands/src/sanitize.rsUserFacingError trait + sanitization functions
crates/commands/src/validation.rsBoundary validation (title, slug, UUID, path)
crates/commands/src/context.rsTracing span factory for operation correlation
apps/desktop/src-tauri/src/commands/page.rsExample command implementations
apps/desktop/src-react/lib/errors.tsFrontend error parsing
apps/desktop/src-react/lib/errors.test.tsFrontend error parsing tests

Was this page helpful?