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

> Learn robust error handling patterns in the Macro codebase using the rootcause crate. Wrap errors with context and stack traces for better diagnostics and stable public error codes.

- Repository: [Macro/macro](https://github.com/macro-inc/macro)
- Tags: best-practices
- Published: 2026-08-17

---

**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`](https://github.com/macro-inc/macro/blob/main/services/document_storage_service/src/service/entity_mutation.rs), public error variants accept a `Report` directly:

```rust
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`](https://github.com/macro-inc/macro/blob/main/entity_mutation.rs) demonstrate this pattern:

```rust
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`](https://github.com/macro-inc/macro/blob/main/crates/entity_mutation/src/ports.rs) and the `unsupported` helper, the pattern appears as:

```rust
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`](https://github.com/macro-inc/macro/blob/main/tooling/notification_sandbox/src/interactive/push_attempt.rs) uses this to simulate failures:

```rust
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:

```rust
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`](https://github.com/macro-inc/macro/blob/main/crates/reminders/src/domain/models.rs), the `ReminderError` type converts a `Report` into a dynamic trait object:

```rust
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:

- **[`services/document_storage_service/src/service/entity_mutation.rs`](https://github.com/macro-inc/macro/blob/main/services/document_storage_service/src/service/entity_mutation.rs)** — Contains `LifecycleError::Internal(rootcause::Report)` definitions and mapping helpers (`access_failure`, `favorites_failure`, `unsupported`).

- **[`services/search_processing_service/src/inbound/kafka_consumer.rs`](https://github.com/macro-inc/macro/blob/main/services/search_processing_service/src/inbound/kafka_consumer.rs)** — Demonstrates the prelude import pattern and conversion of lower-level Kafka errors into `Report` types.

- **[`tooling/notification_sandbox/src/interactive/push_attempt.rs`](https://github.com/macro-inc/macro/blob/main/tooling/notification_sandbox/src/interactive/push_attempt.rs)** — Shows usage of `rootcause::bail!` for simulated failure injection.

- **[`crates/reminders/src/domain/models.rs`](https://github.com/macro-inc/macro/blob/main/crates/reminders/src/domain/models.rs)** — Illustrates dynamic error boxing via `into_dynamic()` for API stability.

- **[`crates/entity_mutation/src/ports.rs`](https://github.com/macro-inc/macro/blob/main/crates/entity_mutation/src/ports.rs)** — Uses `report!(entity).attach(operation)` to add semantic context during error transformation.

## 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.