use std::error::Error as StdError;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
InvalidValue,
InvalidState,
InvalidCommand,
NotFound,
Conflict,
Unauthorized,
Internal,
}
impl ErrorKind {
#[must_use]
pub const fn http_status(self) -> u16 {
match self {
Self::InvalidValue | Self::InvalidCommand => 400,
Self::Unauthorized => 401,
Self::NotFound => 404,
Self::Conflict => 409,
Self::InvalidState => 422,
Self::Internal => 500,
}
}
#[must_use]
pub const fn default_code(self) -> &'static str {
match self {
Self::InvalidValue => "INVALID_VALUE",
Self::InvalidState => "INVALID_STATE",
Self::InvalidCommand => "INVALID_COMMAND",
Self::NotFound => "NOT_FOUND",
Self::Conflict => "CONFLICT",
Self::Unauthorized => "UNAUTHORIZED",
Self::Internal => "INTERNAL_ERROR",
}
}
#[must_use]
pub const fn is_retryable(self) -> bool {
matches!(self, Self::Conflict)
}
#[must_use]
pub const fn default_message(self) -> &'static str {
match self {
Self::InvalidValue => "the provided value is invalid",
Self::InvalidState => "the current state does not allow this operation",
Self::InvalidCommand => "the command cannot be executed",
Self::NotFound => "the requested resource was not found",
Self::Conflict => "a version conflict occurred, please retry",
Self::Unauthorized => "access denied",
Self::Internal => "an internal error occurred",
}
}
}
impl fmt::Display for ErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.default_message())
}
}
pub trait ErrorCode: StdError + Send + Sync + 'static {
fn kind(&self) -> ErrorKind;
fn code(&self) -> &str {
self.kind().default_code()
}
fn http_status(&self) -> u16 {
self.kind().http_status()
}
fn is_retryable(&self) -> bool {
self.kind().is_retryable()
}
}
pub struct DomainError {
kind: ErrorKind,
code: Option<&'static str>,
repr: Repr,
}
enum Repr {
Simple,
Message(Box<str>),
Custom(Box<dyn StdError + Send + Sync>),
}
impl DomainError {
#[must_use]
pub const fn from_kind(kind: ErrorKind) -> Self {
Self {
kind,
code: None,
repr: Repr::Simple,
}
}
#[must_use]
pub fn new(kind: ErrorKind, message: impl Into<Box<str>>) -> Self {
Self {
kind,
code: None,
repr: Repr::Message(message.into()),
}
}
#[must_use]
pub fn custom<E>(kind: ErrorKind, error: E) -> Self
where
E: StdError + Send + Sync + 'static,
{
Self {
kind,
code: None,
repr: Repr::Custom(Box::new(error)),
}
}
#[must_use]
pub fn with_code(mut self, code: &'static str) -> Self {
self.code = Some(code);
self
}
#[must_use]
pub fn invalid_value(msg: impl Into<Box<str>>) -> Self {
Self::new(ErrorKind::InvalidValue, msg)
}
#[must_use]
pub fn invalid_state(msg: impl Into<Box<str>>) -> Self {
Self::new(ErrorKind::InvalidState, msg)
}
#[must_use]
pub fn invalid_command(msg: impl Into<Box<str>>) -> Self {
Self::new(ErrorKind::InvalidCommand, msg)
}
#[must_use]
pub fn not_found(msg: impl Into<Box<str>>) -> Self {
Self::new(ErrorKind::NotFound, msg)
}
#[must_use]
pub fn conflict(expected: impl fmt::Display, actual: impl fmt::Display) -> Self {
Self::new(
ErrorKind::Conflict,
format!("version conflict: expected={expected}, actual={actual}"),
)
}
#[must_use]
pub fn internal(msg: impl Into<Box<str>>) -> Self {
Self::new(ErrorKind::Internal, msg)
}
#[must_use]
pub fn upcast_failed(
event_type: impl Into<Box<str>>,
from_version: usize,
stage: Option<&'static str>,
reason: impl Into<Box<str>>,
) -> Self {
let event_type = event_type.into();
let reason = reason.into();
let msg = match stage {
Some(s) => format!(
"upcast failed: type={event_type}, from_version={from_version}, stage={s}, reason={reason}"
),
None => format!(
"upcast failed: type={event_type}, from_version={from_version}, reason={reason}"
),
};
Self::new(ErrorKind::Internal, msg).with_code("UPCAST_FAILED")
}
#[must_use]
pub fn type_mismatch(expected: impl Into<Box<str>>, found: impl Into<Box<str>>) -> Self {
let expected = expected.into();
let found = found.into();
Self::new(
ErrorKind::Internal,
format!("type mismatch: expected={expected}, found={found}"),
)
.with_code("TYPE_MISMATCH")
}
#[must_use]
pub fn event_bus(reason: impl Into<Box<str>>) -> Self {
Self::new(ErrorKind::Internal, reason).with_code("EVENT_BUS_ERROR")
}
#[must_use]
pub fn kind(&self) -> ErrorKind {
self.kind
}
#[must_use]
pub fn downcast_ref<E: StdError + 'static>(&self) -> Option<&E> {
match &self.repr {
Repr::Custom(error) => error.downcast_ref(),
_ => None,
}
}
#[must_use]
pub fn get_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
match &self.repr {
Repr::Custom(error) => Some(error.as_ref()),
_ => None,
}
}
#[must_use]
pub fn static_code(&self) -> &'static str {
self.code.unwrap_or_else(|| self.kind.default_code())
}
#[must_use]
pub fn matches(&self, kind: ErrorKind, code: &str) -> bool {
self.kind == kind && self.static_code() == code
}
}
impl ErrorCode for DomainError {
fn kind(&self) -> ErrorKind {
self.kind
}
fn code(&self) -> &str {
self.static_code()
}
}
impl fmt::Debug for DomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut d = f.debug_struct("DomainError");
d.field("kind", &self.kind);
if let Some(code) = self.code {
d.field("code", &code);
}
match &self.repr {
Repr::Simple => {
d.field("message", &self.kind.default_message());
}
Repr::Message(msg) => {
d.field("message", msg);
}
Repr::Custom(err) => {
d.field("source", err);
}
}
d.finish()
}
}
impl fmt::Display for DomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.repr {
Repr::Simple => write!(f, "{}", self.kind.default_message()),
Repr::Message(msg) => write!(f, "{msg}"),
Repr::Custom(err) => write!(f, "{err}"),
}
}
}
impl StdError for DomainError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match &self.repr {
Repr::Custom(err) => Some(err.as_ref()),
_ => None,
}
}
}
impl From<ErrorKind> for DomainError {
fn from(kind: ErrorKind) -> Self {
Self::from_kind(kind)
}
}
impl From<serde_json::Error> for DomainError {
fn from(err: serde_json::Error) -> Self {
Self::custom(ErrorKind::Internal, err).with_code("SERIALIZATION_ERROR")
}
}
impl From<uuid::Error> for DomainError {
fn from(err: uuid::Error) -> Self {
Self::custom(ErrorKind::InvalidValue, err).with_code("INVALID_UUID")
}
}
impl From<std::num::ParseIntError> for DomainError {
fn from(err: std::num::ParseIntError) -> Self {
Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_INT_ERROR")
}
}
impl From<std::num::ParseFloatError> for DomainError {
fn from(err: std::num::ParseFloatError) -> Self {
Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_FLOAT_ERROR")
}
}
impl From<std::str::ParseBoolError> for DomainError {
fn from(err: std::str::ParseBoolError) -> Self {
Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_BOOL_ERROR")
}
}
impl From<chrono::ParseError> for DomainError {
fn from(err: chrono::ParseError) -> Self {
Self::custom(ErrorKind::InvalidValue, err).with_code("PARSE_DATE_ERROR")
}
}
impl From<anyhow::Error> for DomainError {
fn from(err: anyhow::Error) -> Self {
Self::new(ErrorKind::Internal, format!("{err:#}"))
}
}
#[cfg(feature = "infra-sqlx")]
impl From<sqlx::Error> for DomainError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => {
Self::new(ErrorKind::NotFound, "database row not found").with_code("ROW_NOT_FOUND")
}
other => Self::custom(ErrorKind::Internal, other).with_code("DATABASE_ERROR"),
}
}
}
pub type DomainResult<T> = Result<T, DomainError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_kind_http_status() {
assert_eq!(ErrorKind::InvalidValue.http_status(), 400);
assert_eq!(ErrorKind::InvalidCommand.http_status(), 400);
assert_eq!(ErrorKind::Unauthorized.http_status(), 401);
assert_eq!(ErrorKind::NotFound.http_status(), 404);
assert_eq!(ErrorKind::Conflict.http_status(), 409);
assert_eq!(ErrorKind::InvalidState.http_status(), 422);
assert_eq!(ErrorKind::Internal.http_status(), 500);
}
#[test]
fn test_error_kind_default_code() {
assert_eq!(ErrorKind::InvalidValue.default_code(), "INVALID_VALUE");
assert_eq!(ErrorKind::NotFound.default_code(), "NOT_FOUND");
assert_eq!(ErrorKind::Conflict.default_code(), "CONFLICT");
}
#[test]
fn test_error_kind_retryable() {
assert!(!ErrorKind::InvalidValue.is_retryable());
assert!(!ErrorKind::NotFound.is_retryable());
assert!(ErrorKind::Conflict.is_retryable());
}
#[test]
fn test_domain_error_convenience_methods() {
let err = DomainError::invalid_value("test");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
assert_eq!(err.to_string(), "test");
let err = DomainError::not_found("user 123");
assert_eq!(err.kind(), ErrorKind::NotFound);
assert_eq!(err.code(), "NOT_FOUND");
}
#[test]
fn test_domain_error_custom_code() {
let err = DomainError::not_found("user").with_code("USER_NOT_FOUND");
assert_eq!(err.code(), "USER_NOT_FOUND");
assert_eq!(err.kind(), ErrorKind::NotFound);
}
#[test]
fn test_domain_error_custom_error() {
use std::io;
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let err = DomainError::custom(ErrorKind::Internal, io_err);
assert!(err.downcast_ref::<io::Error>().is_some());
assert!(err.source().is_some());
}
#[test]
fn test_domain_error_implements_error_code() {
let err = DomainError::invalid_state("order closed");
assert_eq!(err.kind(), ErrorKind::InvalidState);
assert_eq!(err.code(), "INVALID_STATE");
assert_eq!(err.http_status(), 422);
assert!(!err.is_retryable());
}
#[test]
fn test_from_error_kind() {
let err: DomainError = ErrorKind::NotFound.into();
assert_eq!(err.kind(), ErrorKind::NotFound);
}
#[test]
fn test_user_custom_error() {
#[derive(Debug)]
struct MyError;
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "my error")
}
}
impl StdError for MyError {}
impl ErrorCode for MyError {
fn kind(&self) -> ErrorKind {
ErrorKind::InvalidValue
}
fn code(&self) -> &str {
"MY_ERROR"
}
}
let err = MyError;
assert_eq!(err.kind(), ErrorKind::InvalidValue);
assert_eq!(err.code(), "MY_ERROR");
assert_eq!(err.http_status(), 400);
}
#[test]
fn test_error_kind_default_message() {
assert_eq!(
ErrorKind::InvalidValue.default_message(),
"the provided value is invalid"
);
assert_eq!(
ErrorKind::NotFound.default_message(),
"the requested resource was not found"
);
assert_eq!(
ErrorKind::Conflict.default_message(),
"a version conflict occurred, please retry"
);
}
#[test]
fn test_simple_error_display() {
let err = DomainError::from_kind(ErrorKind::NotFound);
assert_eq!(err.to_string(), "the requested resource was not found");
let err = DomainError::from_kind(ErrorKind::Internal);
assert_eq!(err.to_string(), "an internal error occurred");
}
#[test]
fn test_matches() {
let err = DomainError::not_found("user").with_code("USER_NOT_FOUND");
assert!(err.matches(ErrorKind::NotFound, "USER_NOT_FOUND"));
assert!(!err.matches(ErrorKind::NotFound, "NOT_FOUND"));
assert!(!err.matches(ErrorKind::Internal, "USER_NOT_FOUND"));
let err = DomainError::invalid_value("bad input");
assert!(err.matches(ErrorKind::InvalidValue, "INVALID_VALUE"));
}
#[test]
fn test_from_anyhow_preserves_error_chain() {
use std::io;
let root = io::Error::new(io::ErrorKind::NotFound, "file not found");
let anyhow_err = anyhow::Error::new(root).context("failed to load config");
let domain_err: DomainError = anyhow_err.into();
let msg = domain_err.to_string();
assert!(msg.contains("failed to load config"), "msg: {msg}");
assert!(msg.contains("file not found"), "msg: {msg}");
assert_eq!(domain_err.kind(), ErrorKind::Internal);
}
}