Skip to content

DTOs

Data Transfer Objects (DTOs) define the shape of data that flows in and out of the API. They separate your internal database models from your public API contract, allowing you to evolve your schema without breaking clients. FORGE generates DTOs with built-in validation, pagination support, and OpenAPI schema derivation.

Naming Convention

DTOs follow a consistent naming pattern:

PatternPurposeExample
Create{Resource}RequestPOST body for creating a resourceCreateUserRequest
Update{Resource}RequestPUT/PATCH body for updatingUpdateUserRequest
{Resource}ResponseSingle resource responseUserResponse
{Resource}ListResponseList item (may be lighter)UserListResponse
Incoming Request                    Outgoing Response
─────────────────                   ─────────────────
CreateUserRequest  ──▶ Handler ──▶  UserResponse
UpdateUserRequest  ──▶ Handler ──▶  UserResponse
PaginatedRequest   ──▶ Handler ──▶  PaginatedResponse<UserResponse>

Request DTOs

CreateUserRequest

rust
use serde::Deserialize;
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;

#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateUserRequest {
    /// Full name of the user
    #[schema(example = "Jane Doe")]
    #[validate(length(min = 1, max = 255, message = "Name is required"))]
    pub name: String,

    /// Email address (must be unique)
    #[schema(example = "jane@example.com")]
    #[validate(email(message = "Invalid email format"))]
    pub email: String,

    /// Password (minimum 8 characters)
    #[schema(example = "securepassword123")]
    #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
    pub password: String,

    /// Mobile number (optional)
    #[schema(example = "+966500000000")]
    #[validate(length(min = 8, max = 15))]
    pub mobile: Option<String>,

    /// Whether the user account is active
    #[schema(example = true)]
    pub is_active: Option<bool>,

    /// Role IDs to assign to the user
    pub role_ids: Option<Vec<Uuid>>,
}

UpdateUserRequest

Update DTOs use Option for every field to support partial updates:

rust
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateUserRequest {
    #[schema(example = "Jane Updated")]
    #[validate(length(min = 1, max = 255))]
    pub name: Option<String>,

    #[schema(example = "jane.updated@example.com")]
    #[validate(email)]
    pub email: Option<String>,

    #[validate(length(min = 8))]
    pub password: Option<String>,

    #[validate(length(min = 8, max = 15))]
    pub mobile: Option<String>,

    pub is_active: Option<bool>,

    pub role_ids: Option<Vec<Uuid>>,
}

TIP

Using Option<T> for all update fields allows clients to send only the fields they want to change. A field set to null in JSON maps to None in Rust, meaning "don't update this field."

Response DTOs

UserResponse

rust
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};

#[derive(Debug, Serialize, ToSchema)]
pub struct UserResponse {
    pub id: Uuid,
    pub name: String,
    pub email: String,
    pub mobile: Option<String>,
    pub avatar: Option<String>,
    pub is_active: bool,
    pub roles: Vec<RoleResponse>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct RoleResponse {
    pub id: Uuid,
    pub name: String,
    pub display_name: Option<String>,
}

WARNING

Response DTOs must never include sensitive fields like passwords, internal IDs, or soft-delete timestamps. Always map from your model to a response DTO before returning data to the client.

Converting Models to Responses

Implement From traits for clean model-to-DTO conversion:

rust
impl From<User> for UserResponse {
    fn from(user: User) -> Self {
        Self {
            id: user.id,
            name: user.name,
            email: user.email,
            mobile: user.mobile,
            avatar: user.avatar,
            is_active: user.is_active,
            roles: vec![], // Loaded separately
            created_at: user.created_at,
            updated_at: user.updated_at,
        }
    }
}

impl From<UserWithRoles> for UserResponse {
    fn from(data: UserWithRoles) -> Self {
        Self {
            id: data.user.id,
            name: data.user.name,
            email: data.user.email,
            mobile: data.user.mobile,
            avatar: data.user.avatar,
            is_active: data.user.is_active,
            roles: data.roles.into_iter().map(RoleResponse::from).collect(),
            created_at: data.user.created_at,
            updated_at: data.user.updated_at,
        }
    }
}

Common DTOs

ApiResponse

The standard wrapper for all successful responses:

rust
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiResponse<T: Serialize> {
    pub success: bool,
    pub data: T,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

impl<T: Serialize> ApiResponse<T> {
    pub fn success(data: T) -> Self {
        Self {
            success: true,
            data,
            message: None,
        }
    }

    pub fn with_message(data: T, message: impl Into<String>) -> Self {
        Self {
            success: true,
            data,
            message: Some(message.into()),
        }
    }
}

ErrorResponse

The standard wrapper for error responses:

rust
#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorResponse {
    pub success: bool,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub errors: Option<serde_json::Value>,
}

PaginatedRequest

Incoming pagination parameters:

rust
#[derive(Debug, Deserialize, ToSchema)]
pub struct PaginatedRequest {
    /// Page number (1-indexed)
    #[serde(default = "default_page")]
    pub page: i64,

    /// Number of items per page
    #[serde(default = "default_per_page")]
    pub per_page: i64,

    /// Optional search query
    pub search: Option<String>,

    /// Sort field
    pub sort_by: Option<String>,

    /// Sort direction (asc or desc)
    pub sort_order: Option<String>,
}

fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 15 }

PaginatedResponse

Paginated response wrapper:

rust
#[derive(Debug, Serialize, ToSchema)]
pub struct PaginatedResponse<T: Serialize> {
    pub data: Vec<T>,
    pub meta: PaginationMeta,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct PaginationMeta {
    pub current_page: i64,
    pub per_page: i64,
    pub total: i64,
    pub total_pages: i64,
}

impl<T: Serialize> PaginatedResponse<T> {
    pub fn new(data: Vec<T>, total: i64, page: i64, per_page: i64) -> Self {
        Self {
            data,
            meta: PaginationMeta {
                current_page: page,
                per_page,
                total,
                total_pages: (total as f64 / per_page as f64).ceil() as i64,
            },
        }
    }
}

Validation

FORGE uses the validator crate for request validation. Validation runs automatically when a request DTO is deserialized.

Available Validators

rust
#[derive(Deserialize, Validate)]
pub struct ExampleRequest {
    // String length
    #[validate(length(min = 1, max = 255))]
    pub name: String,

    // Email format
    #[validate(email)]
    pub email: String,

    // URL format
    #[validate(url)]
    pub website: Option<String>,

    // Numeric range
    #[validate(range(min = 0, max = 100))]
    pub score: i32,

    // Regex pattern
    #[validate(regex(path = "SLUG_REGEX"))]
    pub slug: String,

    // Custom validation
    #[validate(custom(function = "validate_password_strength"))]
    pub password: String,

    // Nested validation
    #[validate(nested)]
    pub address: AddressRequest,
}

lazy_static! {
    static ref SLUG_REGEX: regex::Regex =
        regex::Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").unwrap();
}

Validation in Handlers

Validate incoming requests using the validate() method:

rust
pub async fn create_user(
    State(pool): State<PgPool>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<impl IntoResponse, AppError> {
    // Validate the request
    payload.validate().map_err(|e| {
        AppError::ValidationError(format_validation_errors(e))
    })?;

    // Proceed with business logic
    let user = UserService::create(&pool, payload).await?;
    Ok(Json(ApiResponse::success(UserResponse::from(user))))
}

Validation Error Format

Validation errors return a structured JSON response:

json
{
  "success": false,
  "message": "Validation failed",
  "errors": {
    "email": ["Invalid email format"],
    "password": ["Password must be at least 8 characters"],
    "name": ["Name is required"]
  }
}

Content DTOs with Translations

For translatable content, DTOs handle the JSONB translations structure:

rust
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateContentRequest {
    #[validate(regex(path = "SLUG_REGEX"))]
    pub slug: String,

    pub content_type: String,

    /// Translations keyed by locale
    /// Example: { "en": { "title": "Hello", "body": "..." }, "ar": { "title": "مرحبا" } }
    pub translations: serde_json::Value,

    pub metadata: Option<serde_json::Value>,
    pub is_active: Option<bool>,
    pub sort_order: Option<i32>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ContentResponse {
    pub id: Uuid,
    pub slug: String,
    pub content_type: String,
    pub translations: serde_json::Value,
    pub metadata: Option<serde_json::Value>,
    pub is_active: bool,
    pub sort_order: i32,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

See Also

  • Handlers — Where DTOs are used to parse requests and build responses
  • Models — Database models that DTOs transform to and from
  • API Documentation — How ToSchema generates OpenAPI schemas
  • Error Handling — Validation error response format

Released under the MIT License.