Skip to main content
Ironclad implements a layered error handling system that separates domain errors from infrastructure errors, providing clear error messages and proper HTTP status codes.

Error Architecture

Domain Errors

Domain errors represent business logic violations and validation failures:
src/errors/mod.rs
#[derive(Error, Debug)]
pub enum DomainError {
    #[error("Domain Validation Error: {0}")]
    Validation(String),
}
Use cases:
  • Invalid email format
  • Username too short
  • Business rule violations
  • Value object validation failures

API Errors

API errors map to HTTP status codes and provide structured responses:
src/errors/mod.rs
#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Not Found")]
    NotFound(String),

    #[error("Unauthorized")]
    Unauthorized,

    #[error("Forbidden")]
    Forbidden(String),

    #[error("Internal Server Error")]
    InternalServerError(String),

    #[error("Validation Error: {0}")]
    ValidationError(String),

    #[error("Database Error: {0}")]
    DatabaseError(String),

    #[error("JWT Error: {0}")]
    JwtError(String),

    #[error("Conflict: {0}")]
    Conflict(String),
}

Error Conversion

Automatic Domain to API Error Conversion

Domain errors automatically convert to API validation errors:
src/errors/mod.rs
impl From<DomainError> for ApiError {
    fn from(err: DomainError) -> Self {
        ApiError::ValidationError(err.to_string())
    }
}
This allows you to use ? operator throughout your application:
// Domain layer
fn validate_email(email: &str) -> Result<(), DomainError> {
    if !email.contains('@') {
        return Err(DomainError::Validation("Invalid email format".to_string()));
    }
    Ok(())
}

// Service layer - domain error automatically converts to ApiError
fn register_user(email: &str) -> Result<User, ApiError> {
    validate_email(email)?;  // DomainError -> ApiError::ValidationError
    // ... create user
}

Error Responses

Response Structure

All errors return a consistent JSON structure:
src/errors/mod.rs
#[derive(Serialize)]
pub struct ErrorResponse {
    pub error: String,
    pub message: String,
    pub status: u16,
}

HTTP Status Code Mapping

src/errors/mod.rs
impl ResponseError for ApiError {
    fn error_response(&self) -> HttpResponse {
        let (status, message) = match self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
            ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
            ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
            ApiError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            ApiError::DatabaseError(msg) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Database error: {}", msg),
            ),
            ApiError::JwtError(msg) => (StatusCode::UNAUTHORIZED, format!("JWT error: {}", msg)),
            ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
            ApiError::InternalServerError(msg) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Internal server error: {}", msg),
            ),
        };

        let error_response = ErrorResponse {
            error: status.canonical_reason().unwrap_or("Error").to_string(),
            message,
            status: status.as_u16(),
        };

        HttpResponse::build(status).json(error_response)
    }
}

Example Responses

404 Not Found
{
  "error": "Not Found",
  "message": "User with id 550e8400-e29b-41d4-a716-446655440000 not found",
  "status": 404
}
400 Validation Error
{
  "error": "Bad Request",
  "message": "Domain Validation Error: Invalid email format",
  "status": 400
}
401 Unauthorized
{
  "error": "Unauthorized",
  "message": "JWT error: Invalid token",
  "status": 401
}
409 Conflict
{
  "error": "Conflict",
  "message": "User with email [email protected] already exists",
  "status": 409
}

Error Handling Patterns

Type Alias for Results

src/errors/mod.rs
pub type ApiResult<T> = Result<T, ApiError>;
Usage:
use crate::errors::ApiResult;

pub async fn get_user(id: Uuid) -> ApiResult<User> {
    let user = repository.find_by_id(id)
        .await
        .map_err(|e| ApiError::DatabaseError(e.to_string()))?
        .ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))?;
    
    Ok(user)
}

Controller Error Handling

Controllers can return Result<HttpResponse, ApiError> directly:
use actix_web::{web, HttpResponse};
use crate::errors::{ApiError, ApiResult};

pub async fn get_user_handler(
    user_id: web::Path<Uuid>,
) -> Result<HttpResponse, ApiError> {
    let user = get_user(*user_id).await?;
    Ok(HttpResponse::Ok().json(user))
}

Database Error Mapping

use sqlx::Error as SqlxError;
use crate::errors::ApiError;

impl From<SqlxError> for ApiError {
    fn from(err: SqlxError) -> Self {
        match err {
            SqlxError::RowNotFound => 
                ApiError::NotFound("Resource not found".to_string()),
            SqlxError::Database(db_err) if db_err.is_unique_violation() => 
                ApiError::Conflict("Resource already exists".to_string()),
            _ => 
                ApiError::DatabaseError(err.to_string()),
        }
    }
}

JWT Error Handling

use jsonwebtoken::errors::Error as JwtError;
use crate::errors::ApiError;

pub fn decode_token(token: &str) -> Result<Claims, ApiError> {
    jsonwebtoken::decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret.as_bytes()),
        &Validation::default(),
    )
    .map(|data| data.claims)
    .map_err(|e| match e.kind() {
        jsonwebtoken::errors::ErrorKind::ExpiredSignature => 
            ApiError::JwtError("Token has expired".to_string()),
        jsonwebtoken::errors::ErrorKind::InvalidToken => 
            ApiError::JwtError("Invalid token".to_string()),
        _ => 
            ApiError::JwtError(e.to_string()),
    })
}

Error Propagation

Layer-by-Layer Propagation

Repository Layer
// Returns database-specific errors
pub async fn find_user(pool: &PgPool, id: Uuid) -> Result<Option<User>, sqlx::Error> {
    sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
        .fetch_optional(pool)
        .await
}
Service Layer
// Converts to ApiError
pub async fn get_user_service(id: Uuid) -> ApiResult<User> {
    let user = find_user(&pool, id)
        .await
        .map_err(|e| ApiError::DatabaseError(e.to_string()))?
        .ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))?;
    
    Ok(user)
}
Controller Layer
// ApiError automatically converts to HTTP response
pub async fn user_handler(id: web::Path<Uuid>) -> Result<HttpResponse, ApiError> {
    let user = get_user_service(*id).await?;  // Propagates ApiError
    Ok(HttpResponse::Ok().json(user))
}

Custom Error Creation

Creating Domain Errors

use crate::errors::DomainError;

pub fn validate_username(username: &str) -> Result<(), DomainError> {
    if username.len() < 3 {
        return Err(DomainError::Validation(
            "Username must be at least 3 characters".to_string()
        ));
    }
    
    if !username.chars().all(|c| c.is_alphanumeric() || c == '_') {
        return Err(DomainError::Validation(
            "Username can only contain letters, numbers, and underscores".to_string()
        ));
    }
    
    Ok(())
}

Creating API Errors

use crate::errors::ApiError;

// Not found
return Err(ApiError::NotFound(format!("User {} not found", user_id)));

// Unauthorized
return Err(ApiError::Unauthorized);

// Forbidden
return Err(ApiError::Forbidden("You don't have permission to access this resource".to_string()));

// Conflict
return Err(ApiError::Conflict(format!("Email {} is already registered", email)));

// Validation
return Err(ApiError::ValidationError("Invalid input data".to_string()));

Best Practices

Error Message Guidelines
  • Be specific but don’t leak sensitive information
  • Include relevant identifiers (IDs, usernames) in messages
  • Use consistent language and formatting
  • Provide actionable information when possible
Security Considerations
  • Never expose internal implementation details
  • Don’t include stack traces in production responses
  • Sanitize user input in error messages
  • Log detailed errors server-side, return generic messages to clients

Logging Errors

use tracing::{error, warn};

pub async fn process_payment(amount: f64) -> ApiResult<Payment> {
    match payment_gateway.charge(amount).await {
        Ok(payment) => Ok(payment),
        Err(e) => {
            // Log detailed error server-side
            error!("Payment processing failed: {:?}", e);
            
            // Return generic error to client
            Err(ApiError::InternalServerError(
                "Payment processing failed. Please try again later.".to_string()
            ))
        }
    }
}

Error Recovery

pub async fn get_user_with_fallback(id: Uuid) -> ApiResult<User> {
    match primary_db.find_user(id).await {
        Ok(user) => Ok(user),
        Err(e) => {
            warn!("Primary database error, trying replica: {}", e);
            
            replica_db.find_user(id)
                .await
                .map_err(|e| ApiError::DatabaseError(e.to_string()))?
                .ok_or_else(|| ApiError::NotFound(format!("User {} not found", id)))
        }
    }
}

Testing Error Handling

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_domain_error_converts_to_api_error() {
        let domain_err = DomainError::Validation("Invalid input".to_string());
        let api_err: ApiError = domain_err.into();
        
        match api_err {
            ApiError::ValidationError(msg) => {
                assert!(msg.contains("Invalid input"));
            }
            _ => panic!("Expected ValidationError"),
        }
    }

    #[actix_web::test]
    async fn test_not_found_response() {
        let error = ApiError::NotFound("User not found".to_string());
        let response = error.error_response();
        
        assert_eq!(response.status(), 404);
    }
}

Next Steps

Middleware

Learn about request/response interception

Logging

Configure structured logging

Build docs developers (and LLMs) love