Skip to main content

eventide_domain/
error.rs

1//! # Unified domain-layer error definitions.
2//!
3//! This module provides a flexible, extensible error-handling toolkit
4//! for DDD projects. It is built around four ideas:
5//!
6//! - **Unified error contract** — every error type opts in via the
7//!   [`ErrorCode`] trait, which gives a consistent API regardless of the
8//!   concrete type.
9//! - **Semantic categorisation** — the [`ErrorKind`] enum classifies every
10//!   error so cross-cutting concerns (HTTP mapping, retry decisions,
11//!   logging) can act on the category instead of pattern-matching every
12//!   variant.
13//! - **Drop-in middle layer** — [`DomainError`] is a ready-to-use error
14//!   type for the domain layer, modelled after [`std::io::Error`] so it
15//!   can carry a category, an optional code, and either a message or a
16//!   wrapped custom error without losing type information.
17//! - **Seamless conversion** — any error implementing [`ErrorCode`] can be
18//!   turned into an HTTP response or higher-layer error type with no
19//!   bespoke glue.
20//!
21//! ## Architecture
22//!
23//! ```text
24//! ┌─────────────────────────────────────────────────────────────────┐
25//! │  eventide-domain                                                 │
26//! │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐ │
27//! │  │ ErrorKind   │  │ ErrorCode   │  │ DomainError             │ │
28//! │  │ (categories)│  │ (trait)     │  │ impl ErrorCode ✓        │ │
29//! │  └─────────────┘  └─────────────┘  └─────────────────────────┘ │
30//! └─────────────────────────────────────────────────────────────────┘
31//!                               │
32//!                               ▼
33//! ┌─────────────────────────────────────────────────────────────────┐
34//! │  eventide-application                                            │
35//! │  ┌───────────────────────────────────────────────────────────┐ │
36//! │  │ AppError                                                  │ │
37//! │  │ impl ErrorCode ✓                                          │ │
38//! │  │ impl From<DomainError> ✓                                  │ │
39//! │  └───────────────────────────────────────────────────────────┘ │
40//! └─────────────────────────────────────────────────────────────────┘
41//!                               │
42//!                               ▼
43//! ┌─────────────────────────────────────────────────────────────────┐
44//! │  user business layer                                            │
45//! │  ┌───────────────────────────────────────────────────────────┐ │
46//! │  │ PayrollError / OrderError / ...                           │ │
47//! │  │ impl ErrorCode ✓                                          │ │
48//! │  └───────────────────────────────────────────────────────────┘ │
49//! └─────────────────────────────────────────────────────────────────┘
50//!                               │
51//!                               ▼
52//! ┌─────────────────────────────────────────────────────────────────┐
53//! │  API layer                                                      │
54//! │  ┌───────────────────────────────────────────────────────────┐ │
55//! │  │ impl<E: ErrorCode> IntoResponse for ApiError<E>           │ │
56//! │  │ any layer's error becomes an HTTP response                │ │
57//! │  └───────────────────────────────────────────────────────────┘ │
58//! └─────────────────────────────────────────────────────────────────┘
59//! ```
60//!
61//! ## Quick start
62//!
63//! ### 1. Use the built-in `DomainError`
64//!
65//! ```rust
66//! use eventide_domain::error::{DomainError, DomainResult};
67//!
68//! fn validate_amount(amount: i64) -> DomainResult<()> {
69//!     if amount < 0 {
70//!         return Err(DomainError::invalid_value("amount must be non-negative"));
71//!     }
72//!     Ok(())
73//! }
74//!
75//! fn find_user(id: &str) -> DomainResult<String> {
76//!     // simulate a lookup
77//!     if id == "not_found" {
78//!         return Err(DomainError::not_found(format!("user {id}")));
79//!     }
80//!     Ok(format!("User: {id}"))
81//! }
82//! ```
83//!
84//! ### 2. Define your own business error
85//!
86//! ```rust
87//! use eventide_domain::error::{ErrorCode, ErrorKind, DomainError};
88//! use thiserror::Error;
89//!
90//! #[derive(Debug, Error)]
91//! pub enum PayrollError {
92//!     #[error("employee not found: {0}")]
93//!     EmployeeNotFound(String),
94//!
95//!     #[error("payslip is locked")]
96//!     PayslipLocked,
97//!
98//!     #[error("invalid amount: {0}")]
99//!     InvalidAmount(String),
100//! }
101//!
102//! impl ErrorCode for PayrollError {
103//!     fn kind(&self) -> ErrorKind {
104//!         match self {
105//!             Self::EmployeeNotFound(_) => ErrorKind::NotFound,
106//!             Self::PayslipLocked => ErrorKind::InvalidState,
107//!             Self::InvalidAmount(_) => ErrorKind::InvalidValue,
108//!         }
109//!     }
110//!
111//!     fn code(&self) -> &str {
112//!         match self {
113//!             Self::EmployeeNotFound(_) => "EMPLOYEE_NOT_FOUND",
114//!             Self::PayslipLocked => "PAYSLIP_LOCKED",
115//!             Self::InvalidAmount(_) => "INVALID_AMOUNT",
116//!         }
117//!     }
118//! }
119//!
120//! // Optional: convert into a `DomainError`.
121//! impl From<PayrollError> for DomainError {
122//!     fn from(e: PayrollError) -> Self {
123//!         DomainError::custom(e.kind(), e)
124//!     }
125//! }
126//! ```
127//!
128//! ### 3. Convert into an HTTP response (Axum example)
129//!
130//! ```rust,ignore
131//! use axum::response::{IntoResponse, Response};
132//! use axum::http::StatusCode;
133//! use axum::Json;
134//! use eventide_domain::error::ErrorCode;
135//! use serde::Serialize;
136//!
137//! #[derive(Serialize)]
138//! pub struct ApiErrorResponse {
139//!     pub code: String,
140//!     pub message: String,
141//! }
142//!
143//! /// Wrapper turning any `ErrorCode` into an HTTP response.
144//! pub struct ApiError<E>(pub E);
145//!
146//! impl<E: ErrorCode> IntoResponse for ApiError<E> {
147//!     fn into_response(self) -> Response {
148//!         let status = StatusCode::from_u16(self.0.http_status())
149//!             .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
150//!
151//!         let body = ApiErrorResponse {
152//!             code: self.0.code().to_string(),
153//!             message: self.0.to_string(),
154//!         };
155//!
156//!         (status, Json(body)).into_response()
157//!     }
158//! }
159//! ```
160//!
161//! ## Design principles
162//!
163//! 1. **Begin with the end in mind** — every error eventually becomes an
164//!    API response, so the design is shaped around that target.
165//! 2. **One contract** — [`ErrorCode`] is the single interface every error
166//!    type implements.
167//! 3. **Open for extension** — users can define any error type they like as
168//!    long as it implements [`ErrorCode`].
169//! 4. **A useful middle layer** — [`DomainError`] removes boilerplate by
170//!    providing an out-of-the-box error for the domain layer.
171//! 5. **Type-safe** — the original error type can be retrieved with
172//!    `downcast_ref` whenever it is needed.
173
174use std::error::Error as StdError;
175use std::fmt;
176
177// ==================== Error categories ====================
178
179/// Error category enum.
180///
181/// Used for unified handling: mapping to HTTP status codes, deciding
182/// whether to retry, choosing log levels, and so on.
183///
184/// # Examples
185///
186/// ```rust
187/// use eventide_domain::error::ErrorKind;
188///
189/// let kind = ErrorKind::NotFound;
190/// assert_eq!(kind.http_status(), 404);
191/// assert_eq!(kind.default_code(), "NOT_FOUND");
192/// assert!(!kind.is_retryable());
193///
194/// let conflict = ErrorKind::Conflict;
195/// assert!(conflict.is_retryable());
196/// ```
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198#[non_exhaustive]
199pub enum ErrorKind {
200    /// Value-object validation failed (for example: a negative amount, a
201    /// malformed email).
202    InvalidValue,
203    /// The aggregate's current state forbids the operation (for example:
204    /// trying to modify a closed order).
205    InvalidState,
206    /// Command parameters or preconditions are not satisfied (for example:
207    /// insufficient stock).
208    InvalidCommand,
209    /// The requested resource does not exist (for example: user / order
210    /// not found).
211    NotFound,
212    /// Optimistic-locking / version conflict (callers may retry).
213    Conflict,
214    /// Unauthorised access.
215    Unauthorized,
216    /// Internal error (database, serialization, and other infrastructure
217    /// failures).
218    Internal,
219}
220
221impl ErrorKind {
222    /// Map the error kind to its HTTP status code.
223    ///
224    /// | ErrorKind       | HTTP Status |
225    /// |-----------------|-------------|
226    /// | InvalidValue    | 400         |
227    /// | InvalidCommand  | 400         |
228    /// | Unauthorized    | 401         |
229    /// | NotFound        | 404         |
230    /// | Conflict        | 409         |
231    /// | InvalidState    | 422         |
232    /// | Internal        | 500         |
233    #[must_use]
234    pub const fn http_status(self) -> u16 {
235        match self {
236            Self::InvalidValue | Self::InvalidCommand => 400,
237            Self::Unauthorized => 401,
238            Self::NotFound => 404,
239            Self::Conflict => 409,
240            Self::InvalidState => 422,
241            Self::Internal => 500,
242        }
243    }
244
245    /// Return the default error code for this category.
246    ///
247    /// Codes are upper-snake-case strings such as `"NOT_FOUND"` or
248    /// `"INVALID_VALUE"`.
249    #[must_use]
250    pub const fn default_code(self) -> &'static str {
251        match self {
252            Self::InvalidValue => "INVALID_VALUE",
253            Self::InvalidState => "INVALID_STATE",
254            Self::InvalidCommand => "INVALID_COMMAND",
255            Self::NotFound => "NOT_FOUND",
256            Self::Conflict => "CONFLICT",
257            Self::Unauthorized => "UNAUTHORIZED",
258            Self::Internal => "INTERNAL_ERROR",
259        }
260    }
261
262    /// Whether the error is safe to retry.
263    ///
264    /// Currently only [`ErrorKind::Conflict`] returns `true`, indicating
265    /// that an optimistic-locking conflict can be retried.
266    #[must_use]
267    pub const fn is_retryable(self) -> bool {
268        matches!(self, Self::Conflict)
269    }
270
271    /// Default user-facing message used by [`DomainError`] when no
272    /// explicit message has been attached.
273    #[must_use]
274    pub const fn default_message(self) -> &'static str {
275        match self {
276            Self::InvalidValue => "the provided value is invalid",
277            Self::InvalidState => "the current state does not allow this operation",
278            Self::InvalidCommand => "the command cannot be executed",
279            Self::NotFound => "the requested resource was not found",
280            Self::Conflict => "a version conflict occurred, please retry",
281            Self::Unauthorized => "access denied",
282            Self::Internal => "an internal error occurred",
283        }
284    }
285}
286
287impl fmt::Display for ErrorKind {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        write!(f, "{}", self.default_message())
290    }
291}
292
293// ==================== Error contract ====================
294
295/// Error-contract trait.
296///
297/// Implement this trait once and any error type gains the ability to:
298///
299/// - report its category via [`ErrorCode::kind`];
300/// - report its error code via [`ErrorCode::code`];
301/// - map to an HTTP status via [`ErrorCode::http_status`];
302/// - report whether it is retryable via [`ErrorCode::is_retryable`].
303///
304/// # Examples
305///
306/// ```rust
307/// use eventide_domain::error::{ErrorCode, ErrorKind};
308/// use thiserror::Error;
309///
310/// #[derive(Debug, Error)]
311/// #[error("order has been cancelled")]
312/// struct OrderCancelled;
313///
314/// impl ErrorCode for OrderCancelled {
315///     fn kind(&self) -> ErrorKind {
316///         ErrorKind::InvalidState
317///     }
318///
319///     fn code(&self) -> &str {
320///         "ORDER_CANCELLED"
321///     }
322/// }
323///
324/// let err = OrderCancelled;
325/// assert_eq!(err.kind(), ErrorKind::InvalidState);
326/// assert_eq!(err.code(), "ORDER_CANCELLED");
327/// assert_eq!(err.http_status(), 422);
328/// ```
329pub trait ErrorCode: StdError + Send + Sync + 'static {
330    /// Return the error's category.
331    fn kind(&self) -> ErrorKind;
332
333    /// Return the error code (defaults to [`ErrorKind::default_code`]).
334    fn code(&self) -> &str {
335        self.kind().default_code()
336    }
337
338    /// Return the HTTP status code (defaults to [`ErrorKind::http_status`]).
339    fn http_status(&self) -> u16 {
340        self.kind().http_status()
341    }
342
343    /// Whether the error is retryable (defaults to [`ErrorKind::is_retryable`]).
344    fn is_retryable(&self) -> bool {
345        self.kind().is_retryable()
346    }
347}
348
349// ==================== DomainError ====================
350
351/// Unified error type for the domain layer.
352///
353/// Modelled after [`std::io::Error`], [`DomainError`] supports three
354/// representations:
355///
356/// - a simple error that only carries a category;
357/// - an error with a custom message;
358/// - an error wrapping another error type, preserving the original
359///   concrete type so it can be downcast later.
360///
361/// # Construction
362///
363/// ## Convenience constructors
364///
365/// ```rust
366/// use eventide_domain::error::DomainError;
367///
368/// // Invalid value
369/// let err = DomainError::invalid_value("amount must be non-negative");
370///
371/// // Invalid state
372/// let err = DomainError::invalid_state("order is closed and cannot be modified");
373///
374/// // Resource not found
375/// let err = DomainError::not_found("user 123");
376///
377/// // Version conflict
378/// let err = DomainError::conflict(1, 2);
379/// ```
380///
381/// ## Generic constructor
382///
383/// ```rust
384/// use eventide_domain::error::{DomainError, ErrorKind};
385///
386/// // Specify both category and message
387/// let err = DomainError::new(ErrorKind::InvalidCommand, "insufficient stock");
388///
389/// // Custom error code
390/// let err = DomainError::new(ErrorKind::NotFound, "user not found")
391///     .with_code("USER_NOT_FOUND");
392/// ```
393///
394/// ## Wrapping a custom error
395///
396/// ```rust
397/// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
398/// use thiserror::Error;
399///
400/// #[derive(Debug, Error)]
401/// #[error("custom error")]
402/// struct MyError;
403///
404/// let err = DomainError::custom(ErrorKind::Internal, MyError);
405///
406/// // The original error can be retrieved.
407/// assert!(err.downcast_ref::<MyError>().is_some());
408/// ```
409pub struct DomainError {
410    kind: ErrorKind,
411    code: Option<&'static str>,
412    repr: Repr,
413}
414
415enum Repr {
416    /// Simple error: category only, no extra payload.
417    Simple,
418    /// Error with an attached human-readable message.
419    Message(Box<str>),
420    /// Error wrapping a custom error type.
421    Custom(Box<dyn StdError + Send + Sync>),
422}
423
424impl DomainError {
425    // ==================== Basic constructors ====================
426
427    /// Build a simple error from a category alone.
428    ///
429    /// # Examples
430    ///
431    /// ```rust
432    /// use eventide_domain::error::{DomainError, ErrorKind};
433    ///
434    /// let err = DomainError::from_kind(ErrorKind::NotFound);
435    /// assert_eq!(err.kind(), ErrorKind::NotFound);
436    /// ```
437    #[must_use]
438    pub const fn from_kind(kind: ErrorKind) -> Self {
439        Self {
440            kind,
441            code: None,
442            repr: Repr::Simple,
443        }
444    }
445
446    /// Build an error with a human-readable message.
447    ///
448    /// # Examples
449    ///
450    /// ```rust
451    /// use eventide_domain::error::{DomainError, ErrorKind};
452    ///
453    /// let err = DomainError::new(ErrorKind::InvalidValue, "amount must be positive");
454    /// assert_eq!(err.to_string(), "amount must be positive");
455    /// ```
456    #[must_use]
457    pub fn new(kind: ErrorKind, message: impl Into<Box<str>>) -> Self {
458        Self {
459            kind,
460            code: None,
461            repr: Repr::Message(message.into()),
462        }
463    }
464
465    /// Wrap an existing custom error.
466    ///
467    /// The original error's concrete type is preserved and can be
468    /// retrieved via [`DomainError::downcast_ref`].
469    ///
470    /// # Examples
471    ///
472    /// ```rust
473    /// use eventide_domain::error::{DomainError, ErrorKind};
474    /// use std::io;
475    ///
476    /// let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
477    /// let err = DomainError::custom(ErrorKind::Internal, io_err);
478    ///
479    /// // Retrieve the original error.
480    /// let inner = err.downcast_ref::<io::Error>().unwrap();
481    /// assert_eq!(inner.kind(), io::ErrorKind::NotFound);
482    /// ```
483    #[must_use]
484    pub fn custom<E>(kind: ErrorKind, error: E) -> Self
485    where
486        E: StdError + Send + Sync + 'static,
487    {
488        Self {
489            kind,
490            code: None,
491            repr: Repr::Custom(Box::new(error)),
492        }
493    }
494
495    /// Attach a custom error code.
496    ///
497    /// # Examples
498    ///
499    /// ```rust
500    /// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
501    ///
502    /// let err = DomainError::not_found("user 123")
503    ///     .with_code("USER_NOT_FOUND");
504    ///
505    /// assert_eq!(err.code(), "USER_NOT_FOUND");
506    /// ```
507    #[must_use]
508    pub fn with_code(mut self, code: &'static str) -> Self {
509        self.code = Some(code);
510        self
511    }
512
513    // ==================== Convenience constructors ====================
514
515    /// Create an [`ErrorKind::InvalidValue`] error.
516    ///
517    /// # Examples
518    ///
519    /// ```rust
520    /// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
521    ///
522    /// let err = DomainError::invalid_value("amount must be positive");
523    /// assert_eq!(err.kind(), ErrorKind::InvalidValue);
524    /// assert_eq!(err.http_status(), 400);
525    /// ```
526    #[must_use]
527    pub fn invalid_value(msg: impl Into<Box<str>>) -> Self {
528        Self::new(ErrorKind::InvalidValue, msg)
529    }
530
531    /// Create an [`ErrorKind::InvalidState`] error.
532    ///
533    /// # Examples
534    ///
535    /// ```rust
536    /// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
537    ///
538    /// let err = DomainError::invalid_state("order is closed");
539    /// assert_eq!(err.kind(), ErrorKind::InvalidState);
540    /// assert_eq!(err.http_status(), 422);
541    /// ```
542    #[must_use]
543    pub fn invalid_state(msg: impl Into<Box<str>>) -> Self {
544        Self::new(ErrorKind::InvalidState, msg)
545    }
546
547    /// Create an [`ErrorKind::InvalidCommand`] error.
548    ///
549    /// # Examples
550    ///
551    /// ```rust
552    /// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
553    ///
554    /// let err = DomainError::invalid_command("insufficient stock");
555    /// assert_eq!(err.kind(), ErrorKind::InvalidCommand);
556    /// assert_eq!(err.http_status(), 400);
557    /// ```
558    #[must_use]
559    pub fn invalid_command(msg: impl Into<Box<str>>) -> Self {
560        Self::new(ErrorKind::InvalidCommand, msg)
561    }
562
563    /// Create an [`ErrorKind::NotFound`] error.
564    ///
565    /// # Examples
566    ///
567    /// ```rust
568    /// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
569    ///
570    /// let err = DomainError::not_found("user 123");
571    /// assert_eq!(err.kind(), ErrorKind::NotFound);
572    /// assert_eq!(err.http_status(), 404);
573    /// ```
574    #[must_use]
575    pub fn not_found(msg: impl Into<Box<str>>) -> Self {
576        Self::new(ErrorKind::NotFound, msg)
577    }
578
579    /// Create an [`ErrorKind::Conflict`] error.
580    ///
581    /// # Examples
582    ///
583    /// ```rust
584    /// use eventide_domain::error::DomainError;
585    ///
586    /// // Accepts any `Display` types.
587    /// let err = DomainError::conflict(1_u64, 2_u64);
588    /// let err = DomainError::conflict(1_usize, 2_usize);
589    /// let err = DomainError::conflict("v1", "v2");
590    /// ```
591    #[must_use]
592    pub fn conflict(expected: impl fmt::Display, actual: impl fmt::Display) -> Self {
593        Self::new(
594            ErrorKind::Conflict,
595            format!("version conflict: expected={expected}, actual={actual}"),
596        )
597    }
598
599    /// Create an [`ErrorKind::Internal`] error.
600    ///
601    /// # Examples
602    ///
603    /// ```rust
604    /// use eventide_domain::error::{DomainError, ErrorKind, ErrorCode};
605    ///
606    /// let err = DomainError::internal("database connection failed");
607    /// assert_eq!(err.kind(), ErrorKind::Internal);
608    /// assert_eq!(err.http_status(), 500);
609    /// ```
610    #[must_use]
611    pub fn internal(msg: impl Into<Box<str>>) -> Self {
612        Self::new(ErrorKind::Internal, msg)
613    }
614
615    /// Create an "event upcast failed" error.
616    #[must_use]
617    pub fn upcast_failed(
618        event_type: impl Into<Box<str>>,
619        from_version: usize,
620        stage: Option<&'static str>,
621        reason: impl Into<Box<str>>,
622    ) -> Self {
623        let event_type = event_type.into();
624        let reason = reason.into();
625        let msg = match stage {
626            Some(s) => format!(
627                "upcast failed: type={event_type}, from_version={from_version}, stage={s}, reason={reason}"
628            ),
629            None => format!(
630                "upcast failed: type={event_type}, from_version={from_version}, reason={reason}"
631            ),
632        };
633        Self::new(ErrorKind::Internal, msg).with_code("UPCAST_FAILED")
634    }
635
636    /// Create a "type mismatch" error.
637    #[must_use]
638    pub fn type_mismatch(expected: impl Into<Box<str>>, found: impl Into<Box<str>>) -> Self {
639        let expected = expected.into();
640        let found = found.into();
641        Self::new(
642            ErrorKind::Internal,
643            format!("type mismatch: expected={expected}, found={found}"),
644        )
645        .with_code("TYPE_MISMATCH")
646    }
647
648    /// Create an "event bus" error.
649    #[must_use]
650    pub fn event_bus(reason: impl Into<Box<str>>) -> Self {
651        Self::new(ErrorKind::Internal, reason).with_code("EVENT_BUS_ERROR")
652    }
653
654    // ==================== Inspection helpers ====================
655
656    /// Return the error category.
657    #[must_use]
658    pub fn kind(&self) -> ErrorKind {
659        self.kind
660    }
661
662    /// Try to downcast this error into a concrete type.
663    ///
664    /// Only succeeds when the error was constructed via
665    /// [`DomainError::custom`].
666    #[must_use]
667    pub fn downcast_ref<E: StdError + 'static>(&self) -> Option<&E> {
668        match &self.repr {
669            Repr::Custom(error) => error.downcast_ref(),
670            _ => None,
671        }
672    }
673
674    /// Return a reference to the wrapped inner error, if any.
675    #[must_use]
676    pub fn get_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
677        match &self.repr {
678            Repr::Custom(error) => Some(error.as_ref()),
679            _ => None,
680        }
681    }
682
683    /// Return the error code as a `&'static str`.
684    ///
685    /// Unlike [`ErrorCode::code`] this method returns `&'static str`,
686    /// which is convenient when the code needs to be stored elsewhere
687    /// without lifetime gymnastics.
688    #[must_use]
689    pub fn static_code(&self) -> &'static str {
690        self.code.unwrap_or_else(|| self.kind.default_code())
691    }
692
693    /// Check whether this error matches the given category and code.
694    ///
695    /// Useful in tests and conditional logic.
696    ///
697    /// # Examples
698    ///
699    /// ```rust
700    /// use eventide_domain::error::{DomainError, ErrorKind};
701    ///
702    /// let err = DomainError::not_found("user").with_code("USER_NOT_FOUND");
703    ///
704    /// assert!(err.matches(ErrorKind::NotFound, "USER_NOT_FOUND"));
705    /// assert!(!err.matches(ErrorKind::NotFound, "NOT_FOUND"));
706    /// assert!(!err.matches(ErrorKind::Internal, "USER_NOT_FOUND"));
707    /// ```
708    #[must_use]
709    pub fn matches(&self, kind: ErrorKind, code: &str) -> bool {
710        self.kind == kind && self.static_code() == code
711    }
712}
713
714// ==================== Trait implementations ====================
715
716impl ErrorCode for DomainError {
717    fn kind(&self) -> ErrorKind {
718        self.kind
719    }
720
721    fn code(&self) -> &str {
722        self.static_code()
723    }
724}
725
726impl fmt::Debug for DomainError {
727    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728        let mut d = f.debug_struct("DomainError");
729        d.field("kind", &self.kind);
730        if let Some(code) = self.code {
731            d.field("code", &code);
732        }
733        match &self.repr {
734            Repr::Simple => {
735                d.field("message", &self.kind.default_message());
736            }
737            Repr::Message(msg) => {
738                d.field("message", msg);
739            }
740            Repr::Custom(err) => {
741                d.field("source", err);
742            }
743        }
744        d.finish()
745    }
746}
747
748impl fmt::Display for DomainError {
749    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
750        match &self.repr {
751            Repr::Simple => write!(f, "{}", self.kind.default_message()),
752            Repr::Message(msg) => write!(f, "{msg}"),
753            Repr::Custom(err) => write!(f, "{err}"),
754        }
755    }
756}
757
758impl StdError for DomainError {
759    fn source(&self) -> Option<&(dyn StdError + 'static)> {
760        match &self.repr {
761            Repr::Custom(err) => Some(err.as_ref()),
762            _ => None,
763        }
764    }
765}
766
767impl From<ErrorKind> for DomainError {
768    fn from(kind: ErrorKind) -> Self {
769        Self::from_kind(kind)
770    }
771}
772
773// ==================== Common type conversions ====================
774
775impl From<serde_json::Error> for DomainError {
776    fn from(err: serde_json::Error) -> Self {
777        Self::custom(ErrorKind::Internal, err).with_code("SERIALIZATION_ERROR")
778    }
779}
780
781impl From<uuid::Error> for DomainError {
782    fn from(err: uuid::Error) -> Self {
783        Self::custom(ErrorKind::InvalidValue, err).with_code("INVALID_UUID")
784    }
785}
786
787impl From<std::num::ParseIntError> for DomainError {
788    fn from(err: std::num::ParseIntError) -> Self {
789        Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_INT_ERROR")
790    }
791}
792
793impl From<std::num::ParseFloatError> for DomainError {
794    fn from(err: std::num::ParseFloatError) -> Self {
795        Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_FLOAT_ERROR")
796    }
797}
798
799impl From<std::str::ParseBoolError> for DomainError {
800    fn from(err: std::str::ParseBoolError) -> Self {
801        Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_BOOL_ERROR")
802    }
803}
804
805impl From<chrono::ParseError> for DomainError {
806    fn from(err: chrono::ParseError) -> Self {
807        Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_DATE_ERROR")
808    }
809}
810
811impl From<anyhow::Error> for DomainError {
812    fn from(err: anyhow::Error) -> Self {
813        // Use the `{:#}` format specifier to preserve the full error chain.
814        Self::new(ErrorKind::Internal, format!("{err:#}"))
815    }
816}
817
818#[cfg(feature = "infra-sqlx")]
819impl From<sqlx::Error> for DomainError {
820    fn from(err: sqlx::Error) -> Self {
821        match err {
822            sqlx::Error::RowNotFound => {
823                Self::new(ErrorKind::NotFound, "database row not found").with_code("ROW_NOT_FOUND")
824            }
825            other => Self::custom(ErrorKind::Internal, other).with_code("DATABASE_ERROR"),
826        }
827    }
828}
829
830// ==================== Result type alias ====================
831
832/// Unified `Result` alias for the domain layer.
833pub type DomainResult<T> = Result<T, DomainError>;
834
835// ==================== Tests ====================
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    // Verify the HTTP-status mapping for every `ErrorKind` variant.
842    #[test]
843    fn test_error_kind_http_status() {
844        assert_eq!(ErrorKind::InvalidValue.http_status(), 400);
845        assert_eq!(ErrorKind::InvalidCommand.http_status(), 400);
846        assert_eq!(ErrorKind::Unauthorized.http_status(), 401);
847        assert_eq!(ErrorKind::NotFound.http_status(), 404);
848        assert_eq!(ErrorKind::Conflict.http_status(), 409);
849        assert_eq!(ErrorKind::InvalidState.http_status(), 422);
850        assert_eq!(ErrorKind::Internal.http_status(), 500);
851    }
852
853    // Verify the default error-code strings.
854    #[test]
855    fn test_error_kind_default_code() {
856        assert_eq!(ErrorKind::InvalidValue.default_code(), "INVALID_VALUE");
857        assert_eq!(ErrorKind::NotFound.default_code(), "NOT_FOUND");
858        assert_eq!(ErrorKind::Conflict.default_code(), "CONFLICT");
859    }
860
861    // Verify the retryable flag — only `Conflict` should be retryable.
862    #[test]
863    fn test_error_kind_retryable() {
864        assert!(!ErrorKind::InvalidValue.is_retryable());
865        assert!(!ErrorKind::NotFound.is_retryable());
866        assert!(ErrorKind::Conflict.is_retryable());
867    }
868
869    // Verify the convenience constructors set the right kind and message.
870    #[test]
871    fn test_domain_error_convenience_methods() {
872        let err = DomainError::invalid_value("test");
873        assert_eq!(err.kind(), ErrorKind::InvalidValue);
874        assert_eq!(err.to_string(), "test");
875
876        let err = DomainError::not_found("user 123");
877        assert_eq!(err.kind(), ErrorKind::NotFound);
878        assert_eq!(err.code(), "NOT_FOUND");
879    }
880
881    // Verify that `with_code` overrides the default code.
882    #[test]
883    fn test_domain_error_custom_code() {
884        let err = DomainError::not_found("user").with_code("USER_NOT_FOUND");
885        assert_eq!(err.code(), "USER_NOT_FOUND");
886        assert_eq!(err.kind(), ErrorKind::NotFound);
887    }
888
889    // Verify that wrapping a custom error preserves both the inner type
890    // (for downcasting) and the standard `Error::source` chain.
891    #[test]
892    fn test_domain_error_custom_error() {
893        use std::io;
894
895        let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
896        let err = DomainError::custom(ErrorKind::Internal, io_err);
897
898        assert!(err.downcast_ref::<io::Error>().is_some());
899        assert!(err.source().is_some());
900    }
901
902    // Verify that `DomainError` itself implements `ErrorCode` and that the
903    // default trait methods delegate correctly.
904    #[test]
905    fn test_domain_error_implements_error_code() {
906        let err = DomainError::invalid_state("order closed");
907
908        // Methods coming from the `ErrorCode` trait.
909        assert_eq!(err.kind(), ErrorKind::InvalidState);
910        assert_eq!(err.code(), "INVALID_STATE");
911        assert_eq!(err.http_status(), 422);
912        assert!(!err.is_retryable());
913    }
914
915    // Verify the `From<ErrorKind>` conversion produces a simple error.
916    #[test]
917    fn test_from_error_kind() {
918        let err: DomainError = ErrorKind::NotFound.into();
919        assert_eq!(err.kind(), ErrorKind::NotFound);
920    }
921
922    // Verify that user-defined error types can implement `ErrorCode` and
923    // automatically gain the default trait methods.
924    #[test]
925    fn test_user_custom_error() {
926        #[derive(Debug)]
927        struct MyError;
928
929        impl fmt::Display for MyError {
930            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
931                write!(f, "my error")
932            }
933        }
934
935        impl StdError for MyError {}
936
937        impl ErrorCode for MyError {
938            fn kind(&self) -> ErrorKind {
939                ErrorKind::InvalidValue
940            }
941
942            fn code(&self) -> &str {
943                "MY_ERROR"
944            }
945        }
946
947        let err = MyError;
948        assert_eq!(err.kind(), ErrorKind::InvalidValue);
949        assert_eq!(err.code(), "MY_ERROR");
950        assert_eq!(err.http_status(), 400);
951    }
952
953    // Verify the user-friendly default messages exposed by `ErrorKind`.
954    #[test]
955    fn test_error_kind_default_message() {
956        assert_eq!(
957            ErrorKind::InvalidValue.default_message(),
958            "the provided value is invalid"
959        );
960        assert_eq!(
961            ErrorKind::NotFound.default_message(),
962            "the requested resource was not found"
963        );
964        assert_eq!(
965            ErrorKind::Conflict.default_message(),
966            "a version conflict occurred, please retry"
967        );
968    }
969
970    // A `Repr::Simple` error should render the kind's default message.
971    #[test]
972    fn test_simple_error_display() {
973        let err = DomainError::from_kind(ErrorKind::NotFound);
974        assert_eq!(err.to_string(), "the requested resource was not found");
975
976        let err = DomainError::from_kind(ErrorKind::Internal);
977        assert_eq!(err.to_string(), "an internal error occurred");
978    }
979
980    // Verify the `matches` helper distinguishes between kind and code.
981    #[test]
982    fn test_matches() {
983        let err = DomainError::not_found("user").with_code("USER_NOT_FOUND");
984        assert!(err.matches(ErrorKind::NotFound, "USER_NOT_FOUND"));
985        assert!(!err.matches(ErrorKind::NotFound, "NOT_FOUND"));
986        assert!(!err.matches(ErrorKind::Internal, "USER_NOT_FOUND"));
987
988        // When no custom code is set, the default kind code is used.
989        let err = DomainError::invalid_value("bad input");
990        assert!(err.matches(ErrorKind::InvalidValue, "INVALID_VALUE"));
991    }
992
993    // Verify that converting `anyhow::Error` keeps the full error chain in
994    // the rendered message.
995    #[test]
996    fn test_from_anyhow_preserves_error_chain() {
997        use std::io;
998
999        let root = io::Error::new(io::ErrorKind::NotFound, "file not found");
1000        let anyhow_err = anyhow::Error::new(root).context("failed to load config");
1001        let domain_err: DomainError = anyhow_err.into();
1002
1003        let msg = domain_err.to_string();
1004        // `{:#}` should render the full chain.
1005        assert!(msg.contains("failed to load config"), "msg: {msg}");
1006        assert!(msg.contains("file not found"), "msg: {msg}");
1007        assert_eq!(domain_err.kind(), ErrorKind::Internal);
1008    }
1009}