Skip to main content

cqrs_rust_lib/
errors.rs

1//! Unified Error Handling for CQRS
2//!
3//! This module provides a structured error system where:
4//! - Each domain defines its own error codes via the `CqrsErrorCode` trait
5//! - All errors are serialized to a unified `CqrsError` format for API responses
6//! - Technical/infrastructure errors are mapped to a dedicated prefix
7//!
8//! # Domain Prefixes
9//!
10//!
11//! Internal codes are formatted as: `prefix * 1000 + error_index`
12//! Example: Tenant NotFound = 4001
13
14use http::StatusCode;
15
16use crate::{MaybeSend, MaybeSync};
17use serde::{Deserialize, Serialize};
18use std::fmt::{Debug, Display};
19use thiserror::Error;
20
21#[cfg(feature = "utoipa")]
22use utoipa::ToSchema;
23
24/// Trait that all domain error codes must implement.
25///
26/// Each domain defines its own enum implementing this trait.
27/// The trait provides the contract for error code metadata.
28///
29/// # Example
30///
31/// ```rust,ignore
32/// use cqrs_rust_lib::define_domain_errors;
33///
34/// define_domain_errors! {
35///     domain: "plan",
36///     prefix: 4,
37///     errors: {
38///         NotFound => (1, StatusCode::NOT_FOUND, "NOT_FOUND"),
39///         SlugExists => (2, StatusCode::CONFLICT, "SLUG_EXISTS"),
40///     }
41/// }
42/// ```
43pub trait CqrsErrorCode: Debug + Display + Clone + MaybeSend + MaybeSync + 'static {
44    /// The domain this error belongs to (e.g., "tenant", "license")
45    fn domain() -> &'static str;
46
47    /// Domain prefix for internal codes (0-9)
48    /// Each domain gets a unique prefix.
49    fn domain_prefix() -> u16;
50
51    /// Unique error index within the domain (0-999)
52    fn error_index(&self) -> u16;
53
54    /// HTTP status code for this error
55    fn http_status(&self) -> StatusCode;
56
57    /// Full internal code: domain_prefix * 1000 + error_index
58    /// Example: Tenant (4) + NotFound (1) = 4001
59    fn internal_code(&self) -> u16 {
60        Self::domain_prefix() * 1000 + self.error_index()
61    }
62
63    /// String representation of the error code for JSON serialization
64    /// Format: DOMAIN_ERROR_NAME (e.g., "PLAN_NOT_FOUND")
65    fn code_string(&self) -> String {
66        format!("{}_{}", Self::domain().to_uppercase(), self)
67    }
68
69    /// Create a CqrsError from this code with a message
70    fn error(&self, message: impl Into<String>) -> CqrsError
71    where
72        Self: Sized,
73    {
74        CqrsError::from_code(self, message)
75    }
76}
77
78// ============================================
79// CqrsError - Unified Error Struct
80// ============================================
81
82/// Internal data for `CqrsError`. Access fields via `Deref` on `CqrsError`.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "utoipa", derive(ToSchema))]
85#[serde(rename_all = "camelCase")]
86pub struct CqrsErrorData {
87    /// Domain this error originated from (e.g., "plan", "user")
88    pub domain: String,
89
90    /// Error code as string (e.g., "PLAN_NOT_FOUND")
91    pub code: String,
92
93    /// Unique internal code for support/debugging (e.g., 4001)
94    pub internal_code: u16,
95
96    /// HTTP status code (not serialized, used for response)
97    #[serde(skip)]
98    pub status: u16,
99
100    /// Human-readable error message
101    pub message: String,
102
103    /// Additional context (optional)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub details: Option<serde_json::Value>,
106
107    /// Request ID for tracing (optional)
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub request_id: Option<String>,
110}
111
112/// Unified error for API responses.
113///
114/// This is a thin wrapper around `Box<CqrsErrorData>` to keep `Result<_, CqrsError>`
115/// small on the stack. Access fields via `Deref` (e.g. `err.domain`, `err.code`).
116///
117/// # JSON Format
118///
119/// ```json
120/// {
121///   "domain": "plan",
122///   "code": "PLAN_NOT_FOUND",
123///   "internalCode": 4001,
124///   "message": "Tenant with ID 'abc' not found",
125///   "details": { "id": "abc" },
126///   "requestId": "req-123"
127/// }
128/// ```
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(transparent)]
131pub struct CqrsError(Box<CqrsErrorData>);
132
133impl std::ops::Deref for CqrsError {
134    type Target = CqrsErrorData;
135    fn deref(&self) -> &CqrsErrorData {
136        &self.0
137    }
138}
139
140impl std::ops::DerefMut for CqrsError {
141    fn deref_mut(&mut self) -> &mut CqrsErrorData {
142        &mut self.0
143    }
144}
145
146#[cfg(feature = "utoipa")]
147impl utoipa::PartialSchema for CqrsError {
148    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
149        CqrsErrorData::schema()
150    }
151}
152
153#[cfg(feature = "utoipa")]
154impl utoipa::ToSchema for CqrsError {
155    fn name() -> std::borrow::Cow<'static, str> {
156        std::borrow::Cow::Borrowed("CqrsError")
157    }
158}
159
160impl CqrsError {
161    /// Create a CqrsError from any domain error code.
162    pub fn from_code<C: CqrsErrorCode>(code: &C, message: impl Into<String>) -> Self {
163        Self(Box::new(CqrsErrorData {
164            domain: C::domain().to_string(),
165            code: code.code_string(),
166            internal_code: code.internal_code(),
167            status: code.http_status().as_u16(),
168            message: message.into(),
169            details: None,
170            request_id: None,
171        }))
172    }
173
174    /// Add additional details to the error.
175    pub fn with_details(mut self, details: serde_json::Value) -> Self {
176        self.details = Some(details);
177        self
178    }
179
180    /// Add a request ID for tracing.
181    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
182        self.request_id = Some(request_id.into());
183        self
184    }
185
186    /// Get the HTTP status code for this error.
187    pub fn http_status(&self) -> StatusCode {
188        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
189    }
190
191    // ============================================
192    // Convenience constructors for common errors
193    // ============================================
194
195    /// Create a generic not found error.
196    pub fn not_found(message: impl Into<String>) -> Self {
197        GenericErrorCode::NotFound.error(message)
198    }
199
200    /// Create a generic validation error.
201    pub fn validation(message: impl Into<String>) -> Self {
202        GenericErrorCode::ValidationFailed.error(message)
203    }
204
205    /// Create a generic internal error.
206    pub fn internal(message: impl Into<String>) -> Self {
207        GenericErrorCode::InternalError.error(message)
208    }
209
210    /// Create a generic conflict error.
211    pub fn conflict(message: impl Into<String>) -> Self {
212        GenericErrorCode::Conflict.error(message)
213    }
214
215    /// Create a generic unauthorized error.
216    pub fn unauthorized(message: impl Into<String>) -> Self {
217        GenericErrorCode::Unauthorized.error(message)
218    }
219
220    /// Create a generic forbidden error.
221    pub fn forbidden(message: impl Into<String>) -> Self {
222        GenericErrorCode::Forbidden.error(message)
223    }
224
225    // ============================================
226    // Migration helpers (mirror AggregateError variants)
227    // ============================================
228
229    /// Create a user/domain error.
230    pub fn user_error(e: impl std::fmt::Display) -> Self {
231        InfrastructureErrorCode::DomainError.error(e.to_string())
232    }
233
234    /// Create a database error.
235    pub fn database_error(e: impl std::fmt::Display) -> Self {
236        InfrastructureErrorCode::DatabaseError.error(e.to_string())
237    }
238
239    /// Create a serialization error.
240    pub fn serialization_error(e: impl std::fmt::Display) -> Self {
241        InfrastructureErrorCode::SerializationError.error(e.to_string())
242    }
243
244    /// Create a concurrency/version conflict error.
245    pub fn concurrency_error() -> Self {
246        InfrastructureErrorCode::ConcurrencyError.error("Version conflict")
247    }
248
249    /// Create an aggregate not found error.
250    pub fn aggregate_not_found(id: &str) -> Self {
251        InfrastructureErrorCode::AggregateNotFound.error(format!("Aggregate '{}' not found", id))
252    }
253
254    /// Create an aggregate already exists error.
255    pub fn aggregate_already_exists(id: &str) -> Self {
256        InfrastructureErrorCode::Conflict.error(format!("Aggregate '{}' already exists", id))
257    }
258
259    /// Create an error from an HTTP status code and message.
260    pub fn from_status(status: StatusCode, message: impl Into<String>) -> Self {
261        GenericErrorCode::from(status).error(message)
262    }
263}
264
265impl Display for CqrsError {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        write!(
268            f,
269            "[{}] {}: {}",
270            self.internal_code, self.code, self.message
271        )
272    }
273}
274
275impl std::error::Error for CqrsError {}
276
277impl From<std::io::Error> for CqrsError {
278    fn from(e: std::io::Error) -> Self {
279        CqrsError::user_error(e)
280    }
281}
282
283// ============================================
284// Infrastructure Error Codes (prefix = 0)
285// ============================================
286
287/// Error codes for infrastructure/technical errors.
288///
289/// These are used when converting from low-level errors.
290/// Domain-specific errors should use their own error codes instead.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
292pub enum InfrastructureErrorCode {
293    #[error("INTERNAL_ERROR")]
294    InternalError,
295    #[error("VALIDATION_FAILED")]
296    ValidationFailed,
297    #[error("NOT_FOUND")]
298    NotFound,
299    #[error("CONFLICT")]
300    Conflict,
301    #[error("UNAUTHORIZED")]
302    Unauthorized,
303    #[error("FORBIDDEN")]
304    Forbidden,
305    #[error("GONE")]
306    Gone,
307    #[error("DATABASE_ERROR")]
308    DatabaseError,
309    #[error("SERIALIZATION_ERROR")]
310    SerializationError,
311    #[error("AGGREGATE_NOT_FOUND")]
312    AggregateNotFound,
313    #[error("CONCURRENCY_ERROR")]
314    ConcurrencyError,
315    #[error("DOMAIN_ERROR")]
316    DomainError,
317    #[error("CQRS_ERROR")]
318    CqrsInternalError,
319    #[error("CONFIGURATION_ERROR")]
320    ConfigurationError,
321    #[error("UNKNOWN")]
322    Unknown,
323}
324
325impl CqrsErrorCode for InfrastructureErrorCode {
326    fn domain() -> &'static str {
327        "infrastructure"
328    }
329    fn domain_prefix() -> u16 {
330        0
331    }
332
333    fn error_index(&self) -> u16 {
334        match self {
335            Self::InternalError => 0,
336            Self::ValidationFailed => 1,
337            Self::NotFound => 2,
338            Self::Conflict => 3,
339            Self::Unauthorized => 4,
340            Self::Forbidden => 5,
341            Self::Gone => 6,
342            Self::DatabaseError => 10,
343            Self::SerializationError => 11,
344            Self::AggregateNotFound => 12,
345            Self::ConcurrencyError => 13,
346            Self::DomainError => 14,
347            Self::CqrsInternalError => 15,
348            Self::ConfigurationError => 16,
349            Self::Unknown => 99,
350        }
351    }
352
353    fn http_status(&self) -> StatusCode {
354        match self {
355            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
356            Self::ValidationFailed => StatusCode::BAD_REQUEST,
357            Self::NotFound | Self::AggregateNotFound => StatusCode::NOT_FOUND,
358            Self::Conflict | Self::ConcurrencyError => StatusCode::CONFLICT,
359            Self::Unauthorized => StatusCode::UNAUTHORIZED,
360            Self::Forbidden => StatusCode::FORBIDDEN,
361            Self::Gone => StatusCode::GONE,
362            Self::DatabaseError
363            | Self::SerializationError
364            | Self::CqrsInternalError
365            | Self::ConfigurationError
366            | Self::Unknown => StatusCode::INTERNAL_SERVER_ERROR,
367            Self::DomainError => StatusCode::BAD_REQUEST,
368        }
369    }
370}
371
372// ============================================
373// Generic Error Codes (prefix = 1)
374// ============================================
375
376/// Generic error codes for common scenarios.
377///
378/// Use these when a domain-specific error code is not available.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
380pub enum GenericErrorCode {
381    #[error("INTERNAL_ERROR")]
382    InternalError,
383    #[error("VALIDATION_FAILED")]
384    ValidationFailed,
385    #[error("NOT_FOUND")]
386    NotFound,
387    #[error("CONFLICT")]
388    Conflict,
389    #[error("UNAUTHORIZED")]
390    Unauthorized,
391    #[error("FORBIDDEN")]
392    Forbidden,
393    #[error("GONE")]
394    Gone,
395}
396
397impl CqrsErrorCode for GenericErrorCode {
398    fn domain() -> &'static str {
399        "generic"
400    }
401    fn domain_prefix() -> u16 {
402        1
403    }
404
405    fn error_index(&self) -> u16 {
406        match self {
407            Self::InternalError => 0,
408            Self::ValidationFailed => 1,
409            Self::NotFound => 2,
410            Self::Conflict => 3,
411            Self::Unauthorized => 4,
412            Self::Forbidden => 5,
413            Self::Gone => 6,
414        }
415    }
416
417    fn http_status(&self) -> StatusCode {
418        match self {
419            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
420            Self::ValidationFailed => StatusCode::BAD_REQUEST,
421            Self::NotFound => StatusCode::NOT_FOUND,
422            Self::Conflict => StatusCode::CONFLICT,
423            Self::Unauthorized => StatusCode::UNAUTHORIZED,
424            Self::Forbidden => StatusCode::FORBIDDEN,
425            Self::Gone => StatusCode::GONE,
426        }
427    }
428}
429
430impl From<StatusCode> for GenericErrorCode {
431    fn from(status: StatusCode) -> Self {
432        match status.as_u16() {
433            400 => GenericErrorCode::ValidationFailed,
434            401 => GenericErrorCode::Unauthorized,
435            403 => GenericErrorCode::Forbidden,
436            404 => GenericErrorCode::NotFound,
437            409 => GenericErrorCode::Conflict,
438            410 => GenericErrorCode::Gone,
439            _ => GenericErrorCode::InternalError,
440        }
441    }
442}
443
444// ============================================
445// Backward Compatibility
446// ============================================
447
448#[deprecated(since = "0.2.0", note = "Use CqrsError instead")]
449pub type AggregateError = CqrsError;
450
451// ============================================
452// Domain Error Code Macro
453// ============================================
454
455/// Macro to define domain-specific error codes with minimal boilerplate.
456///
457/// # Example
458///
459/// ```rust,ignore
460/// use cqrs_rust_lib::define_domain_errors;
461/// use http::StatusCode;
462///
463/// define_domain_errors! {
464///     domain: "tenant",
465///     prefix: 4,
466///     errors: {
467///         NotFound => (1, StatusCode::NOT_FOUND, "NOT_FOUND"),
468///         Suspended => (2, StatusCode::BAD_REQUEST, "SUSPENDED"),
469///         Deleted => (3, StatusCode::GONE, "DELETED"),
470///         SlugExists => (4, StatusCode::CONFLICT, "SLUG_EXISTS"),
471///     }
472/// }
473///
474/// // Usage:
475/// let err = ErrorCode::NotFound.error("Tenant 'abc' not found");
476/// // -> CqrsError { domain: "tenant", code: "TENANT_NOT_FOUND", internal_code: 4001, ... }
477/// ```
478#[macro_export]
479macro_rules! define_domain_errors {
480    (
481        domain: $domain:literal,
482        prefix: $prefix:expr,
483        errors: {
484            $( $variant:ident => ($index:expr, $status:expr, $display:literal) ),* $(,)?
485        }
486    ) => {
487        /// Domain-specific error codes.
488        #[derive(Debug, Clone, Copy, PartialEq, Eq, ::thiserror::Error)]
489        pub enum ErrorCode {
490            $(
491                #[error($display)]
492                $variant,
493            )*
494        }
495
496        impl $crate::CqrsErrorCode for ErrorCode {
497            fn domain() -> &'static str { $domain }
498            fn domain_prefix() -> u16 { $prefix }
499
500            fn error_index(&self) -> u16 {
501                match self {
502                    $( Self::$variant => $index, )*
503                }
504            }
505
506            fn http_status(&self) -> ::http::StatusCode {
507                match self {
508                    $( Self::$variant => $status, )*
509                }
510            }
511        }
512    };
513}
514
515// ============================================
516// Tests
517// ============================================
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_generic_error_code() {
525        let err = GenericErrorCode::NotFound.error("Resource not found");
526        assert_eq!(err.domain, "generic");
527        assert_eq!(err.code, "GENERIC_NOT_FOUND");
528        assert_eq!(err.internal_code, 1002);
529        assert_eq!(err.status, 404);
530    }
531
532    #[test]
533    fn test_infrastructure_error_code() {
534        let err = InfrastructureErrorCode::DatabaseError.error("Connection failed");
535        assert_eq!(err.domain, "infrastructure");
536        assert_eq!(err.code, "INFRASTRUCTURE_DATABASE_ERROR");
537        assert_eq!(err.internal_code, 10); // 0 * 1000 + 10
538        assert_eq!(err.status, 500);
539    }
540
541    #[test]
542    fn test_convenience_constructors() {
543        let err = CqrsError::not_found("User not found");
544        assert_eq!(err.code, "GENERIC_NOT_FOUND");
545
546        let err = CqrsError::validation("Invalid email");
547        assert_eq!(err.code, "GENERIC_VALIDATION_FAILED");
548    }
549
550    #[test]
551    fn test_migration_constructors() {
552        let err = CqrsError::user_error("bad input");
553        assert_eq!(err.code, "INFRASTRUCTURE_DOMAIN_ERROR");
554        assert_eq!(err.status, 400);
555
556        let err = CqrsError::database_error("connection lost");
557        assert_eq!(err.code, "INFRASTRUCTURE_DATABASE_ERROR");
558        assert_eq!(err.status, 500);
559
560        let err = CqrsError::serialization_error("invalid json");
561        assert_eq!(err.code, "INFRASTRUCTURE_SERIALIZATION_ERROR");
562        assert_eq!(err.status, 500);
563
564        let err = CqrsError::concurrency_error();
565        assert_eq!(err.code, "INFRASTRUCTURE_CONCURRENCY_ERROR");
566        assert_eq!(err.status, 409);
567
568        let err = CqrsError::aggregate_not_found("abc");
569        assert_eq!(err.code, "INFRASTRUCTURE_AGGREGATE_NOT_FOUND");
570        assert_eq!(err.status, 404);
571        assert!(err.message.contains("abc"));
572
573        let err = CqrsError::aggregate_already_exists("xyz");
574        assert_eq!(err.code, "INFRASTRUCTURE_CONFLICT");
575        assert_eq!(err.status, 409);
576        assert!(err.message.contains("xyz"));
577    }
578
579    #[test]
580    fn test_with_details() {
581        let err = GenericErrorCode::NotFound
582            .error("User not found")
583            .with_details(serde_json::json!({"user_id": "123"}));
584
585        assert!(err.details.is_some());
586        assert_eq!(err.details.as_ref().unwrap()["user_id"], "123");
587    }
588
589    #[test]
590    fn test_serialization() {
591        let err = GenericErrorCode::Conflict.error("Already exists");
592        let json = serde_json::to_string(&err).unwrap();
593
594        assert!(json.contains("\"domain\":\"generic\""));
595        assert!(json.contains("\"code\":\"GENERIC_CONFLICT\""));
596        assert!(json.contains("\"internalCode\":1003"));
597        assert!(json.contains("\"message\":\"Already exists\""));
598        // status should not be serialized
599        assert!(!json.contains("\"status\""));
600    }
601}