Skip to content
Documentation GitHub

Error Handling

Errors flow outward through the Clean Architecture layers. Each layer converts errors from the layer below. The key invariant: internal details never reach the frontend.

Domain Application Commands Frontend
───────────── ──────────────────── ────────────────── ──────────────────
DomainError → PageRepositoryError → CommandError → parseCommandError()
WorkspaceRepoError (sanitized) (TypeScript)
TagRepositoryError
...
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

DomainError (in crates/domain/src/error.rs) has two variants — InvalidState and Validation — derived via thiserror. Application-layer From implementations convert them into repository errors:

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 (crates/commands/src/error.rs) is serialized as a tagged enum (#[serde(tag = "type", content = "data")]) so all transports — Tauri IPC, HTTP bridge, MCP — produce the same JSON shape.

VariantWhen to use
NotFoundEntity lookup returned nothing
ValidationUser-correctable input error
InternalSystem or infrastructure failure
NoWorkspaceCommand requires an open workspace but none set
PermissionCaller lacks the required capability
UnauthenticatedUser must sign in

HTTP bridge variant-to-status mapping: NotFound → 404, Validation → 400, Internal → 500, NoWorkspace → 409, Permission → 403, Unauthenticated → 401.

crates/commands/src/sanitize.rs provides the bridge between technical errors and CommandError.

UserFacingError trait — every repository error type implements this:

  • user_message() — returns a human-readable string (never leaks paths, SQL, or Rust internals)
  • is_validation_error()true when the user can fix the input and retry
  • is_expected()true for normal-flow errors (NotFound, AlreadyExists); controls log level

Sanitization functions used at command boundaries:

  • sanitize_error(e, "context") — always produces CommandError::Internal; logs at warn! for expected errors, error! for unexpected ones
  • sanitize_validation_error(e, "context") — routes to CommandError::Validation or CommandError::Internal based on is_validation_error()
  • sanitize_pass_through(message, fallback) — passes through safe messages; replaces messages containing paths, SQL errors, or Rust internals with the fallback string

apps/desktop/src-react/lib/errors.ts provides two functions:

parseCommandError(err: unknown): string — extracts a human-readable message from any error shape returned by invoke(). Handles all CommandError variants by type tag. Falls back to "An unexpected error occurred" for unrecognized shapes.

isAuthRequiredError(err: unknown): boolean — returns true for Permission and Unauthenticated variants; used to trigger the sign-in dialog instead of a toast.

try {
await invoke("create_page", { title, parentSlug });
} catch (err) {
if (isAuthRequiredError(err)) {
openSignInDialog();
} else {
toast.error(parseCommandError(err));
}
}
FilePurpose
crates/domain/src/error.rsDomainError enum
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)
apps/desktop/src-react/lib/errors.tsFrontend error parsing
  • Overview — Architecture layers and dependencies
  • Domain Rules — Business invariants enforced in Rust

Was this page helpful?