eventide_application/error.rs
1//! # Application-layer unified error type
2//!
3//! This module defines [`AppError`], the canonical error type returned by
4//! command/query handlers and the surrounding application-layer plumbing.
5//! It integrates seamlessly with the [`ErrorCode`] trait from
6//! `eventide-domain` so that the same `code()` / `kind()` / `http_status()`
7//! contract used in the domain layer can be re-exported all the way up to
8//! the HTTP/gRPC boundary.
9//!
10//! ## Architecture
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────────────────────────┐
14//! │ eventide-domain │
15//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
16//! │ │ ErrorKind │ │ ErrorCode │ │ DomainError │ │
17//! │ │ (category) │ │ (trait) │ │ impl ErrorCode ✓ │ │
18//! │ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
19//! └─────────────────────────────────────────────────────────────────┘
20//! │
21//! ▼
22//! ┌─────────────────────────────────────────────────────────────────┐
23//! │ eventide-application (this module) │
24//! │ ┌───────────────────────────────────────────────────────────┐ │
25//! │ │ AppError │ │
26//! │ │ impl ErrorCode ✓ │ │
27//! │ │ impl From<DomainError> ✓ │ │
28//! │ └───────────────────────────────────────────────────────────┘ │
29//! └─────────────────────────────────────────────────────────────────┘
30//! ```
31//!
32//! ## Quick start
33//!
34//! ### Using AppError
35//!
36//! ```rust
37//! use eventide_application::error::{AppError, AppResult};
38//! use eventide_domain::error::DomainError;
39//!
40//! fn process_command() -> AppResult<String> {
41//! // Domain errors are converted into AppError automatically.
42//! let domain_result: Result<String, DomainError> =
43//! Err(DomainError::not_found("user 123"));
44//! domain_result?;
45//!
46//! Ok("success".to_string())
47//! }
48//!
49//! fn validate_input(input: &str) -> AppResult<()> {
50//! if input.is_empty() {
51//! return Err(AppError::validation("input cannot be empty"));
52//! }
53//! Ok(())
54//! }
55//! ```
56//!
57//! ### Mapping to an API response
58//!
59//! ```rust,ignore
60//! use axum::response::{IntoResponse, Response};
61//! use axum::http::StatusCode;
62//! use axum::Json;
63//! use eventide_domain::error::ErrorCode;
64//! use serde::Serialize;
65//!
66//! #[derive(Serialize)]
67//! pub struct ApiErrorResponse {
68//! pub code: String,
69//! pub message: String,
70//! }
71//!
72//! pub struct ApiError<E>(pub E);
73//!
74//! impl<E: ErrorCode> IntoResponse for ApiError<E> {
75//! fn into_response(self) -> Response {
76//! let status = StatusCode::from_u16(self.0.http_status())
77//! .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
78//!
79//! let body = ApiErrorResponse {
80//! code: self.0.code().to_string(),
81//! message: self.0.to_string(),
82//! };
83//!
84//! (status, Json(body)).into_response()
85//! }
86//! }
87//! ```
88
89use eventide_domain::error::{DomainError, ErrorCode, ErrorKind};
90use std::error::Error as StdError;
91use std::fmt;
92
93/// Unified application-layer error type.
94///
95/// `AppError` is the single error type produced by application-layer
96/// handlers. It can wrap a [`DomainError`] (with the domain's `kind` and
97/// `code` preserved) or carry an application-specific failure such as
98/// validation, authorization, or a handler routing problem.
99///
100/// # Features
101///
102/// - Implements [`ErrorCode`], so it can be turned directly into an HTTP /
103/// API response without further mapping.
104/// - Provides `From<DomainError>` so that `?` lifts domain errors
105/// transparently.
106/// - Offers application-specific constructors for common failure modes
107/// (validation, authorization, handler routing, type coercion).
108///
109/// # Examples
110///
111/// ```rust
112/// use eventide_application::error::AppError;
113/// use eventide_domain::error::{ErrorCode, ErrorKind};
114///
115/// let err = AppError::validation("email format invalid");
116/// assert_eq!(err.kind(), ErrorKind::InvalidValue);
117/// assert_eq!(err.code(), "VALIDATION_ERROR");
118///
119/// let err = AppError::handler_not_found("CreateUserHandler");
120/// assert_eq!(err.kind(), ErrorKind::Internal);
121/// assert_eq!(err.code(), "HANDLER_NOT_FOUND");
122/// ```
123pub struct AppError {
124 kind: ErrorKind,
125 code: &'static str,
126 message: Box<str>,
127 source: Option<Source>,
128}
129
130enum Source {
131 Domain(DomainError),
132 Other(Box<dyn StdError + Send + Sync>),
133}
134
135impl AppError {
136 /// Construct a new application error with the given kind, stable code
137 /// string, and human-readable message.
138 fn new(kind: ErrorKind, code: &'static str, message: impl Into<Box<str>>) -> Self {
139 Self {
140 kind,
141 code,
142 message: message.into(),
143 source: None,
144 }
145 }
146
147 // ==================== Convenience constructors ====================
148
149 /// Build a validation error.
150 ///
151 /// Use this for application-layer input validation failures (shape,
152 /// format, length) that occur before reaching domain logic.
153 ///
154 /// # Examples
155 ///
156 /// ```rust
157 /// use eventide_application::error::AppError;
158 /// use eventide_domain::error::ErrorCode;
159 ///
160 /// let err = AppError::validation("email format invalid");
161 /// assert_eq!(err.code(), "VALIDATION_ERROR");
162 /// assert_eq!(err.http_status(), 400);
163 /// ```
164 #[must_use]
165 pub fn validation(msg: impl Into<Box<str>>) -> Self {
166 Self::new(ErrorKind::InvalidValue, "VALIDATION_ERROR", msg)
167 }
168
169 /// Build an unauthorized error.
170 ///
171 /// Use this when the caller's identity cannot be verified or the
172 /// presented credentials are insufficient.
173 ///
174 /// # Examples
175 ///
176 /// ```rust
177 /// use eventide_application::error::AppError;
178 /// use eventide_domain::error::ErrorCode;
179 ///
180 /// let err = AppError::unauthorized("invalid token");
181 /// assert_eq!(err.code(), "UNAUTHORIZED");
182 /// assert_eq!(err.http_status(), 401);
183 /// ```
184 #[must_use]
185 pub fn unauthorized(msg: impl Into<Box<str>>) -> Self {
186 Self::new(ErrorKind::Unauthorized, "UNAUTHORIZED", msg)
187 }
188
189 /// Build a "handler not found" error.
190 ///
191 /// Returned by buses (such as [`crate::InMemoryCommandBus`] /
192 /// [`crate::InMemoryQueryBus`]) when no handler is registered for the
193 /// dispatched type. The provided `handler_name` is included in the
194 /// error message for diagnostics.
195 ///
196 /// # Examples
197 ///
198 /// ```rust
199 /// use eventide_application::error::AppError;
200 /// use eventide_domain::error::ErrorCode;
201 ///
202 /// let err = AppError::handler_not_found("CreateUserHandler");
203 /// assert_eq!(err.code(), "HANDLER_NOT_FOUND");
204 /// ```
205 #[must_use]
206 pub fn handler_not_found(handler_name: &str) -> Self {
207 Self::new(
208 ErrorKind::Internal,
209 "HANDLER_NOT_FOUND",
210 format!("handler not found: {handler_name}"),
211 )
212 }
213
214 /// Build an "aggregate not found" error.
215 ///
216 /// Use this in command/query handlers that need to load an aggregate
217 /// by id and find that the underlying repository returned no record.
218 /// The aggregate type and id are interpolated into the message.
219 ///
220 /// # Examples
221 ///
222 /// ```rust
223 /// use eventide_application::error::AppError;
224 /// use eventide_domain::error::ErrorCode;
225 ///
226 /// let err = AppError::aggregate_not_found("User", "user-123");
227 /// assert_eq!(err.code(), "AGGREGATE_NOT_FOUND");
228 /// assert_eq!(err.http_status(), 404);
229 /// ```
230 #[must_use]
231 pub fn aggregate_not_found(aggregate_type: &str, aggregate_id: &str) -> Self {
232 Self::new(
233 ErrorKind::NotFound,
234 "AGGREGATE_NOT_FOUND",
235 format!("{aggregate_type} not found: {aggregate_id}"),
236 )
237 }
238
239 /// Build a "handler already registered" error.
240 ///
241 /// Returned by the in-memory buses when the caller attempts to register
242 /// a second handler for a key that is already populated. Registration is
243 /// intentionally exclusive to keep the dispatch deterministic.
244 ///
245 /// # Examples
246 ///
247 /// ```rust
248 /// use eventide_application::error::AppError;
249 /// use eventide_domain::error::ErrorCode;
250 ///
251 /// let err = AppError::handler_already_registered("CreateUserHandler");
252 /// assert_eq!(err.code(), "HANDLER_ALREADY_REGISTERED");
253 /// ```
254 #[must_use]
255 pub fn handler_already_registered(handler_name: &str) -> Self {
256 Self::new(
257 ErrorKind::Internal,
258 "HANDLER_ALREADY_REGISTERED",
259 format!("handler already registered: {handler_name}"),
260 )
261 }
262
263 /// Build a "type mismatch" error.
264 ///
265 /// Used by the type-erased buses when a dynamically dispatched value
266 /// cannot be downcast back to its expected concrete type. This usually
267 /// indicates a registry corruption or a programming error rather than a
268 /// runtime user-facing condition.
269 ///
270 /// # Examples
271 ///
272 /// ```rust
273 /// use eventide_application::error::AppError;
274 /// use eventide_domain::error::ErrorCode;
275 ///
276 /// let err = AppError::type_mismatch("String", "i32");
277 /// assert_eq!(err.code(), "TYPE_MISMATCH");
278 /// ```
279 #[must_use]
280 pub fn type_mismatch(expected: &str, found: &str) -> Self {
281 Self::new(
282 ErrorKind::Internal,
283 "TYPE_MISMATCH",
284 format!("type mismatch: expected={expected}, found={found}"),
285 )
286 }
287
288 /// Build a generic internal error.
289 ///
290 /// Use this as a last resort when the failure cannot be expressed by a
291 /// more specific constructor. Maps to HTTP 500.
292 ///
293 /// # Examples
294 ///
295 /// ```rust
296 /// use eventide_application::error::AppError;
297 /// use eventide_domain::error::ErrorCode;
298 ///
299 /// let err = AppError::internal("unexpected state");
300 /// assert_eq!(err.code(), "INTERNAL_ERROR");
301 /// assert_eq!(err.http_status(), 500);
302 /// ```
303 #[must_use]
304 pub fn internal(msg: impl Into<Box<str>>) -> Self {
305 Self::new(ErrorKind::Internal, "INTERNAL_ERROR", msg)
306 }
307
308 // ==================== Inspection methods ====================
309
310 /// Return the [`ErrorKind`] category of this error.
311 #[must_use]
312 pub fn kind(&self) -> ErrorKind {
313 self.kind
314 }
315
316 /// Return a reference to the wrapped [`DomainError`], if this `AppError`
317 /// originated from a domain-layer failure via `From<DomainError>`.
318 #[must_use]
319 pub fn domain_error(&self) -> Option<&DomainError> {
320 match &self.source {
321 Some(Source::Domain(e)) => Some(e),
322 _ => None,
323 }
324 }
325
326 /// Return a reference to the inner source error, regardless of whether
327 /// it originated as a [`DomainError`] or as an arbitrary error wrapped
328 /// via [`AppError::wrap`].
329 #[must_use]
330 pub fn get_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
331 match &self.source {
332 Some(Source::Domain(e)) => Some(e),
333 Some(Source::Other(e)) => Some(e.as_ref()),
334 None => None,
335 }
336 }
337
338 /// Attempt to downcast the inner source to a specific error type.
339 ///
340 /// Supports retrieval of the original concrete error from sources
341 /// created with [`DomainError::custom`] or [`AppError::wrap`].
342 #[must_use]
343 pub fn downcast_ref<E: StdError + 'static>(&self) -> Option<&E> {
344 match &self.source {
345 Some(Source::Domain(e)) => e.downcast_ref(),
346 Some(Source::Other(e)) => e.downcast_ref(),
347 None => None,
348 }
349 }
350
351 /// Wrap an arbitrary error into an `AppError`.
352 ///
353 /// The wrapped error's type information is preserved and can be
354 /// recovered later with [`AppError::downcast_ref`]. The error's
355 /// `Display` value is used as the human-readable message.
356 ///
357 /// # Examples
358 ///
359 /// ```rust
360 /// use eventide_application::error::AppError;
361 /// use eventide_domain::error::ErrorKind;
362 /// use std::io;
363 ///
364 /// let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
365 /// let err = AppError::wrap(ErrorKind::Internal, "IO_ERROR", io_err);
366 ///
367 /// assert!(err.downcast_ref::<io::Error>().is_some());
368 /// ```
369 #[must_use]
370 pub fn wrap<E: StdError + Send + Sync + 'static>(
371 kind: ErrorKind,
372 code: &'static str,
373 error: E,
374 ) -> Self {
375 Self {
376 kind,
377 code,
378 message: error.to_string().into(),
379 source: Some(Source::Other(Box::new(error))),
380 }
381 }
382
383 /// Test whether this error matches the given kind/code pair.
384 ///
385 /// Useful in tests and conditional handling code that needs to react
386 /// to a specific error variant without inspecting the message text.
387 ///
388 /// # Examples
389 ///
390 /// ```rust
391 /// use eventide_application::error::AppError;
392 /// use eventide_domain::error::ErrorKind;
393 ///
394 /// let err = AppError::validation("invalid email");
395 ///
396 /// assert!(err.matches(ErrorKind::InvalidValue, "VALIDATION_ERROR"));
397 /// assert!(!err.matches(ErrorKind::NotFound, "VALIDATION_ERROR"));
398 /// ```
399 #[must_use]
400 pub fn matches(&self, kind: ErrorKind, code: &str) -> bool {
401 self.kind == kind && self.code == code
402 }
403}
404
405// ==================== Trait implementations ====================
406
407impl ErrorCode for AppError {
408 fn kind(&self) -> ErrorKind {
409 self.kind
410 }
411
412 fn code(&self) -> &str {
413 self.code
414 }
415}
416
417impl fmt::Debug for AppError {
418 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419 f.debug_struct("AppError")
420 .field("kind", &self.kind)
421 .field("code", &self.code)
422 .field("message", &self.message)
423 .field("source", &self.source.as_ref().map(|_| "..."))
424 .finish()
425 }
426}
427
428impl fmt::Display for AppError {
429 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430 write!(f, "{}", self.message)
431 }
432}
433
434impl StdError for AppError {
435 fn source(&self) -> Option<&(dyn StdError + 'static)> {
436 match &self.source {
437 Some(Source::Domain(e)) => Some(e),
438 Some(Source::Other(e)) => Some(e.as_ref()),
439 None => None,
440 }
441 }
442}
443
444impl From<DomainError> for AppError {
445 fn from(e: DomainError) -> Self {
446 // Use static_code() so that any custom code attached to the
447 // DomainError is propagated verbatim into the AppError.
448 let code = e.static_code();
449 Self {
450 kind: e.kind(),
451 code,
452 message: e.to_string().into(),
453 source: Some(Source::Domain(e)),
454 }
455 }
456}
457
458// ==================== Result alias ====================
459
460/// Application-layer `Result` alias using [`AppError`] as the error type.
461pub type AppResult<T> = Result<T, AppError>;
462
463// ==================== Tests ====================
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 // Verify that the convenience constructors produce errors with the
470 // expected kind/code/message tuple.
471 #[test]
472 fn test_app_error_convenience_methods() {
473 let err = AppError::validation("test");
474 assert_eq!(err.kind(), ErrorKind::InvalidValue);
475 assert_eq!(err.code(), "VALIDATION_ERROR");
476 assert_eq!(err.to_string(), "test");
477
478 let err = AppError::unauthorized("no token");
479 assert_eq!(err.kind(), ErrorKind::Unauthorized);
480 assert_eq!(err.code(), "UNAUTHORIZED");
481
482 let err = AppError::handler_not_found("TestHandler");
483 assert_eq!(err.kind(), ErrorKind::Internal);
484 assert_eq!(err.code(), "HANDLER_NOT_FOUND");
485 }
486
487 // Verify that `From<DomainError>` preserves both the kind and the
488 // ability to recover the original DomainError reference.
489 #[test]
490 fn test_from_domain_error() {
491 let domain_err = DomainError::not_found("user 123");
492 let app_err: AppError = domain_err.into();
493
494 assert_eq!(app_err.kind(), ErrorKind::NotFound);
495 assert!(app_err.domain_error().is_some());
496 }
497
498 // Verify that AppError implements ErrorCode and exposes the expected
499 // HTTP status / retryability semantics.
500 #[test]
501 fn test_app_error_implements_error_code() {
502 let err = AppError::validation("invalid input");
503
504 assert_eq!(err.kind(), ErrorKind::InvalidValue);
505 assert_eq!(err.code(), "VALIDATION_ERROR");
506 assert_eq!(err.http_status(), 400);
507 assert!(!err.is_retryable());
508 }
509
510 // Verify the `aggregate_not_found` convenience constructor formats the
511 // message correctly and exposes the NotFound / 404 mapping.
512 #[test]
513 fn test_aggregate_not_found() {
514 let err = AppError::aggregate_not_found("User", "user-123");
515 assert_eq!(err.kind(), ErrorKind::NotFound);
516 assert_eq!(err.code(), "AGGREGATE_NOT_FOUND");
517 assert_eq!(err.http_status(), 404);
518 assert!(err.to_string().contains("User"));
519 assert!(err.to_string().contains("user-123"));
520 }
521
522 // Verify that `From<DomainError>` keeps a custom code attached via
523 // `with_code`, instead of overriding it with a generic kind-based code.
524 #[test]
525 fn test_from_domain_error_preserves_custom_code() {
526 let domain_err = DomainError::not_found("user 123").with_code("USER_NOT_FOUND");
527 let app_err: AppError = domain_err.into();
528
529 assert_eq!(app_err.kind(), ErrorKind::NotFound);
530 assert_eq!(app_err.code(), "USER_NOT_FOUND"); // custom code is preserved
531 }
532
533 // Verify `wrap` preserves the underlying error type so it can be
534 // recovered later through downcast_ref / get_ref.
535 #[test]
536 fn test_wrap_preserves_error() {
537 use std::io;
538
539 let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
540 let err = AppError::wrap(ErrorKind::Internal, "IO_ERROR", io_err);
541
542 assert_eq!(err.code(), "IO_ERROR");
543 assert!(err.downcast_ref::<io::Error>().is_some());
544 assert!(err.get_ref().is_some());
545 }
546
547 // Verify that errors smuggled through DomainError::custom into an
548 // AppError can still be downcast back to their original concrete type.
549 #[test]
550 fn test_downcast_ref_through_domain_error() {
551 use std::io;
552
553 let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
554 let domain_err = DomainError::custom(ErrorKind::Internal, io_err);
555 let app_err: AppError = domain_err.into();
556
557 // Recover the original io::Error through the AppError surface.
558 assert!(app_err.downcast_ref::<io::Error>().is_some());
559 }
560
561 // Verify the `matches` helper performs strict (kind, code) equality.
562 #[test]
563 fn test_matches() {
564 let err = AppError::validation("invalid email");
565 assert!(err.matches(ErrorKind::InvalidValue, "VALIDATION_ERROR"));
566 assert!(!err.matches(ErrorKind::NotFound, "VALIDATION_ERROR"));
567 assert!(!err.matches(ErrorKind::InvalidValue, "WRONG_CODE"));
568
569 let err = AppError::handler_not_found("TestHandler");
570 assert!(err.matches(ErrorKind::Internal, "HANDLER_NOT_FOUND"));
571 }
572}