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:
| Pattern | Purpose | Example |
|---|---|---|
Create{Resource}Request | POST body for creating a resource | CreateUserRequest |
Update{Resource}Request | PUT/PATCH body for updating | UpdateUserRequest |
{Resource}Response | Single resource response | UserResponse |
{Resource}ListResponse | List item (may be lighter) | UserListResponse |
Incoming Request Outgoing Response
───────────────── ─────────────────
CreateUserRequest ──▶ Handler ──▶ UserResponse
UpdateUserRequest ──▶ Handler ──▶ UserResponse
PaginatedRequest ──▶ Handler ──▶ PaginatedResponse<UserResponse>Request DTOs
CreateUserRequest
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:
#[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
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:
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:
#[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:
#[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:
#[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:
#[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
#[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:
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:
{
"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:
#[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
ToSchemagenerates OpenAPI schemas - Error Handling — Validation error response format