Skip to main content

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}