-
-
Notifications
You must be signed in to change notification settings - Fork 0
Best Practices en
Patterns that keep masterror-based services predictable: typed domain
errors, one stable code taxonomy, transport mapping at the edge, and public
messages that never leak internals.
Model each bounded context as an enum with #[derive(Error)] and declare the
AppError mapping inline with #[app_error(...)]. The derive generates
Display, From<...> for wrapped sources, and the conversion into
AppError/AppCode — no hand-written match at every call site.
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Debug, Error)]
pub enum UserError {
#[error("user {0} not found")]
#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)]
NotFound(u64),
#[error("email already registered")]
#[app_error(kind = AppErrorKind::Conflict, code = AppCode::Conflict, message)]
DuplicateEmail,
#[error("storage failure")]
#[app_error(kind = AppErrorKind::Database, code = AppCode::Database)]
Storage(#[from] std::io::Error)
}
let app: AppError = UserError::DuplicateEmail.into();
assert_eq!(app.kind, AppErrorKind::Conflict);Include message only on variants whose Display output is safe to show to
clients. Omit it (as on Storage) to keep the text internal — the client sees
only the kind's title. Full attribute reference:
Derive Macros.
AppCode is your public API contract; clients branch on it. Keep the set
small, documented and per-service:
- Prefer the built-in codes (
NOT_FOUND,CONFLICT,VALIDATION, ...) — they already carry canonical HTTP/gRPC/problem-type mappings. - Mint custom codes centrally, not inline at call sites:
use masterror::AppCode;
pub const CODE_PLAN_LIMIT: AppCode = AppCode::new("PLAN_LIMIT_EXCEEDED");AppCode::new is const and panics at compile time on anything that is not
SCREAMING_SNAKE_CASE; use AppCode::try_new for runtime strings. Renaming a
code is a breaking API change — treat additions like adding an enum variant.
Domain and service layers return AppResult<T> and know nothing about HTTP.
The single IntoResponse/ResponseError/Status implementation in the crate
does the mapping in the handler layer:
async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult<User> {
repo.find(id).await? // sqlx::Error -> AppError::NotFound/Database
}Never hand-construct status codes in business logic and never implement a
second response conversion — the stable AppErrorKind → status table in
Web Frameworks is the one source of truth.
Two independent knobs:
-
Message redaction —
err.redactable()(orredact(message)in#[masterror(...)]) hidesdetailfrom wire payloads while logs keep it. - Field redaction — per-field policy applied when metadata is serialized:
use masterror::{AppError, FieldRedaction, field};
let err = AppError::bad_request("Invalid credentials")
.with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash))
.with_field(field::str("card", "4111111111111111").with_redaction(FieldRedaction::Last4))
.with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact));Hash keeps correlational value (same input → same digest) without exposing
the raw string; Last4 suits card/token suffixes; Redact removes the value
entirely. Default is None. Redact anything user-identifying by default and
opt out consciously, not the other way around.
- Derive when the error type is part of your domain vocabulary: it has variants, appears in signatures, and its mapping is static.
-
Context(viaResultExt::ctx) when wrapping an infrastructure error ad hoc at a call site and the classification depends on the operation, not the type:
# #[cfg(feature = "std")] {
use masterror::{AppErrorKind, Context, ResultExt, field};
fn read_state() -> masterror::AppResult<Vec<u8>> {
std::fs::read("/var/lib/app/state.bin").ctx(|| {
Context::new(AppErrorKind::Internal)
.with(field::str("path", "/var/lib/app/state.bin"))
.track_caller()
})
}
# }ctx is lazy — the closure runs only on the error path. Use plain
.context("message") when a human-readable note is all you need. Details:
Context & Metadata.
Assert on the stable taxonomy, never on formatted messages:
use masterror::{AppCode, AppError, AppErrorKind, ProblemJson};
let err = AppError::not_found("user 42 missing");
assert_eq!(err.kind, AppErrorKind::NotFound);
assert_eq!(err.code, AppCode::NotFound);
let problem = ProblemJson::from_ref(&err);
assert_eq!(problem.status, 404);
assert_eq!(problem.code.as_str(), "NOT_FOUND");-
ProblemJson::from_reflets integration tests assert the exact wire contract without spinning up a server. -
mapping_for_code(&code)exposes the canonical HTTP status, gRPC code and problem-type URI for table-driven tests. - For redaction tests, assert
problem.detail.is_none()on aredactable()error and checkmetadata().iter_with_redaction()policies.
A useful rule for every error you construct:
| Channel | Contents |
|---|---|
message / detail
|
Human-oriented, non-sensitive, stable enough to show a user |
Metadata fields |
IDs, attempts, endpoints, durations — for logs/metrics, with redaction policies |
source chain |
Raw underlying errors — logged, never serialized to clients |
masterror enforces the last row (sources are never written to wire
payloads), but the first two are your responsibility: if a string contains
anything you would not print in a browser, put it in metadata with a
redaction policy or mark the error redactable().
See also: Derive Macros · Context & Metadata · Error Kinds & Codes · Migration
🇬🇧 English | 🇷🇺 Русский | 🇰🇷 한국어
Getting Started
Core Concepts
Integrations
Advanced
Начало работы
Основы
Интеграции
Продвинутое
시작하기
핵심 개념
통합
고급