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}