DTOs and Validation
Validating incoming JSON.
Learning Objectives
- Use the validator crate
##Never trust the client
Anything that arrives over the network can be wrong, malicious or just missing. A solid API validates input at the edge and keeps its internal data shapes separate from the shapes it sends and receives.
#What is a DTO?
A DTO (data transfer object) is a struct that describes data crossing the API boundary. It is usually different from your database model:
- A
CreateUserrequest DTO has a name and password, but no id. - A
UserResponseDTO has an id and name, but never the password hash. - The
Userdatabase model has everything.
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct CreateUser {
pub name: String,
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct UserResponse {
pub id: u32,
pub name: String,
pub email: String,
}Using separate types prevents accidents such as leaking a password hash or letting a client set its own id.
#Validation with the validator crate
Add the crate:
validator = { version = "0.18", features = ["derive"] }Annotate fields with rules:
use validator::Validate;
#[derive(Deserialize, Validate)]
pub struct CreateUser {
#[validate(length(min = 2, max = 50))]
pub name: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 8))]
pub password: String,
}#Validate in the handler
use actix_web::{post, web, HttpResponse};
#[post("/users")]
async fn create_user(body: web::Json<CreateUser>) -> Result<HttpResponse, ApiError> {
body.validate()
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
// safe to continue: the data is valid
Ok(HttpResponse::Created().finish())
}When validation fails, return 400 (or 422) with a clear message that lists which fields are wrong.
#Layers of validation
- Syntax: serde rejects invalid JSON and wrong types automatically with a 400.
- Rules: the validator checks lengths, formats and ranges.
- Business rules: "email already taken" needs the database, so check it in the service layer.
#Limit input size
Protect your server from huge bodies by configuring a payload limit:
web::JsonConfig::default().limit(4096)Register it with .app_data(...).
"What If?" Experiment
Learning by doing. Modify the code to see what happens.
Break a validation rule.
Send that body to your endpoint.
The API returns 400 and mentions the invalid fields.
Explanation: All three rules fail, which shows how validation reports several problems at once.
Common Mistakes
Anticipate and avoid these frequent pitfalls when working with this concept.
Returning the database model directly
It is less code, but it exposes every field, including secrets.
Explanation: Convert to a response DTO that includes only the fields clients should see.
Skipping validation because the frontend validates
Frontend checks can be bypassed with curl or a script.
Explanation: Always re-validate on the server. Client-side validation is only for user experience.
Practical Exercise
Validate a registration request
TaskAdd validation to a RegisterRequest and return 400 for invalid data.
Requirements
- username length 3 to 20
- valid email
- password at least 8 characters
Need a hint?
Try solving the exercise in your local environment first. Once you have a solution, or if you get completely stuck, reveal the answer.
Real-World Application
Registration, checkout and profile forms in real services use exactly this pattern: strict request DTOs, server-side validation and response DTOs that never contain secrets.
Key Takeaways
- Use separate request and response structs instead of exposing your database model.
- The validator crate adds declarative rules like length and email.
- Return 400 or 422 with a clear message for invalid input.
- Limit payload size with JsonConfig.
Finished reading?
Mark this lesson as done to track your progress and earn XP.