Error Handling Patterns Using the `rootcause` Crate in the Macro Codebase

The Macro repository implements a layered error handling strategy centered on rootcause::Report to wrap errors with contextual stack traces, enabling rich internal diagnostics while exposing stable public error codes to API consumers.

The macro-inc/macro codebase adopts a consistent, domain-driven approach to failure management built around the rootcause crate. This pattern leverages the Report type to accumulate context messages at each architectural boundary, allowing services to transform low-level failures into sanitized external responses without losing the detailed trace required for production debugging.

Core Error Handling Concepts

The Report Type as the Root Error

At the foundation of the pattern, functions that may fail return Result<T, rootcause::Report>. This type carries the original error plus a stack of context messages that accumulate at each conversion point. In services/document_storage_service/src/service/entity_mutation.rs, public error variants accept a Report directly:

fn access_failure(error: AccessError) -> EntityMutationErrorCode {
    EntityMutationErrorCode::forbidden(rootcause::report!(error))
}

This approach provides a single, rich error representation that can be transformed into public-facing error codes while preserving the full failure trace for internal logging and tracing systems.

Converting Errors with rootcause::report!

Internal domain errors—such as AccessError, FavoritesError, or LifecycleError—are wrapped using the rootcause::report! macro. This macro creates a Report from any Error type, automatically capturing Debug information and the source location. The conversion functions access_failure, favorites_failure, and lifecycle_failure in entity_mutation.rs demonstrate this pattern:

rootcause::report!(error)

Enriching Context with .attach

After creating a Report, higher-level operations annotate failures with human-readable context using the .attach method. This allows each layer to add its own description without losing the original cause. In crates/entity_mutation/src/ports.rs and the unsupported helper, the pattern appears as:

fn unsupported(entity: Entity<'static>, operation: &'static str) -> EntityMutationResult {
    Err(EntityMutationErrorCode::unsupported(
        rootcause::report!(entity).attach(operation),
    ))
}

The .attach(operation) call adds the operation name to the error stack, enabling clear error messages and precise tracing in distributed logs.

Early Exit with rootcause::bail!

For code paths that must abort immediately, the bail! macro provides an ergonomic early return. Mirroring anyhow::bail!, this macro produces a Report and maintains the crate-wide convention. The notification sandbox in tooling/notification_sandbox/src/interactive/push_attempt.rs uses this to simulate failures:

async fn simulate_push_failure(endpoint_name: &str) -> Result<(), rootcause::Report> {
    rootcause::bail!("simulated push failure for {}", endpoint_name);
}

Ergonomic Conversion via ResultExt

Many service crates import ResultExt from rootcause::prelude to extend standard Result types with conversion helpers. The import pattern appears as:

use rootcause::prelude::{Report, ResultExt as _};

This trait adds methods like .map_err(|e| rootcause::Report::new(e)) and .inspect_err, allowing concise conversion of external errors (such as std::io::Error) into Report while preserving the ergonomics of the ? operator.

Dynamic Error Interop with into_dynamic

To hide implementation details from public APIs while retaining diagnostic capability, some error enums store boxed dynamic errors. In crates/reminders/src/domain/models.rs, the ReminderError type converts a Report into a dynamic trait object:

impl From<rootcause::Report> for ReminderError {
    fn from(report: rootcause::Report) -> Self {
        ReminderError::Internal(report.into_dynamic())
    }
}

The .into_dynamic() method keeps the public API stable—exposing only a generic Internal variant—while still permitting access to the underlying Report for internal diagnostics.

The Layered Error Flow

The repository follows a consistent four-stage propagation model:

  1. Domain-level operations return specific error types (AccessError, FavoritesError, etc.) that represent the immediate failure mode.

  2. Adapter layers map domain errors to public EntityMutationErrorCode variants using rootcause::report! and optional .attach calls to record the operation context.

  3. Higher-level services further annotate the error with operation-specific context (e.g., .attach(operation)) as the failure bubbles toward the system boundary.

  4. HTTP/router layers convert the EntityMutationErrorCode into an HTTP response, preserving the original Report for structured logging and distributed tracing.

This flow ensures full stack traces at every conversion step, clear public contracts that hide internal details, and consistent ergonomics via the ? operator across asynchronous and synchronous boundaries.

Key Implementation Files

The following source files demonstrate the repository-wide conventions:

Summary

  • rootcause::Report serves as the universal error carrier, accumulating context through the call stack while preserving original failure details.
  • The report! macro converts domain-specific errors into Report instances, capturing source locations and debug representations automatically.
  • .attach() enriches errors with operational context at each architectural layer without breaking the error chain.
  • bail! provides ergonomic early returns that maintain the Report type convention across the codebase.
  • ResultExt enables seamless integration with Rust's ? operator, converting external errors into the internal Report format.
  • .into_dynamic() allows public APIs to remain stable by boxing internal error details while retaining diagnostic access for operators.

Frequently Asked Questions

How does rootcause differ from anyhow in this codebase?

While both crates provide ergonomic error handling, rootcause is used specifically for its Report type and .attach() context accumulation. Unlike anyhow, which focuses on application-level error reporting, rootcause in the Macro repository supports layered domain-to-API error mapping with explicit context attachment at each boundary, as seen in the entity mutation service adapters.

When should I use rootcause::report! versus Report::new()?

Use rootcause::report!(error) when converting an existing error type into a report, as it automatically captures source location and debug information. Use rootcause::Report::new(error) (often via ResultExt) when working with generic error types in trait implementations or when you need explicit control over the conversion logic in function chains.

What is the purpose of .attach() versus standard error wrapping?

The .attach() method adds a context message to the Report's internal stack without creating a new error type. This differs from source() chains in standard errors because it preserves a human-readable trace of operations (e.g., "processing document", "validating access") that is separate from the causal error chain, making production debugging significantly faster.

Can rootcause::Report be used across async boundaries?

Yes. The Report type is Send and Sync where the underlying error permits, making it safe to propagate across async boundaries in Tokio tasks. The search processing service's Kafka consumer demonstrates this pattern by converting stream errors into Report types that traverse async runtime boundaries while maintaining full context.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →