βοΈ Python Backend Patterns β π¦ DTO & Data Validation π
Posted on: October 26, 2026
Description:
In backend applications, data travels from requests to controllers, services, and other components. Passing a raw dictionary through these layers is convenient, but it leaves each component to interpret the expected fields and values.
Consider these two requests:
{"product_id": 42, "quantity": "2"}
{"product_id": 42, "quantity": 2}
The quantities look similar, but one is a string and the other is an integer. If the application expects an integer, it needs an explicit policy: convert compatible values or reject incorrect types.
A Data Transfer Object (DTO) defines the structure of data passed between layers. A DTO does not inherently provide validation; in this example, Pydantic v2 supplies the runtime checks.
Our flow becomes:
Incoming dictionary
β
Controller: validate with OrderDTO
βββ Invalid β return field errors
β
Validated OrderDTO
β
Service: check permissions and stock
Define the Data Contract
Install Pydantic v2:
python -m pip install "pydantic>=2,<3"
Define the request structure:
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class OrderDTO(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
product_id: int = Field(gt=0)
quantity: int = Field(ge=1, le=10)
Both fields are required because neither has a default. The product ID must be greater than zero, and quantity must be between one and ten, inclusive.
strict=True rejects a string such as "2" for these integer fields. Without strict mode, Pydantic can convert compatible inputs to the declared types. Choose the policy that matches your interface. Pydantic strict mode documentation
extra="forbid" rejects fields outside the declared contract, such as an unexpected admin flag. Pydantic validation errors documentation
Validate at the Boundary
Validate the incoming dictionary before calling the service:
try:
dto = OrderDTO.model_validate(data)
except ValidationError as error:
errors = error.errors(include_input=False, include_url=False)
else:
order = service.create_order(dto, can_order=can_order)
model_validate() returns an OrderDTO when validation succeeds and raises ValidationError when it fails. The complete script converts those errors into a structured response containing the field, message, and error type.
The service call sits on the successful path, so a rejected payload never reaches it.
| Input | Result |
|---|---|
product_id=42, quantity=2 |
Passes DTO validation |
quantity="2" |
Rejected: incorrect type |
quantity=0 |
Rejected: below minimum |
quantity=11 |
Rejected: above maximum |
product_id=0 |
Rejected: ID must be positive |
Missing quantity |
Rejected: required field |
Additional admin=True |
Rejected: unexpected field |
Transfer the DTO to the Service
The service receives an object with named attributes:
def create_order(self, dto: OrderDTO, *, can_order: bool) -> dict:
if not can_order:
raise PermissionError("You do not have permission to place orders")
if dto.product_id not in self.stock:
raise ValueError("Product not found")
if dto.quantity > self.stock[dto.product_id]:
raise ValueError("Insufficient stock")
return dto.model_dump()
The DTO checks the request's structure and declared constraints. The service handles checks that depend on application state or the caller's permissions.
For example, quantity 8 satisfies the DTO's range of 1β10. If only five units are available, the service still rejects the order. Similarly, a correctly structured request does not grant permission to place an order. The example receives can_order separately from trusted application context.
model_dump() converts the DTO back into a dictionary for the response. This demonstration does not persist an order or reserve inventory; a real ordering service would perform those operations transactionally.
Verify the Behaviour
The complete script includes successful input, incorrect types, range violations, missing and unexpected fields, insufficient stock, and denied permission. Assertions verify the expected results.
Run it with:
python dto_data_validation.py
A successful run ends with:
DTO & Data Validation Tests Passed!
Key Takeaways
- A DTO makes the structure of transferred data explicit.
- Pydantic supplies runtime validation for this DTO implementation.
- Strict mode and forbidden extra fields make the accepted input policy deliberate.
- Validate incoming data before handing it to the service.
- Keep stock, product existence, and permission checks in the service.
Validate at the boundary. Transfer a clear contract between layers.
Code Snippet:
# Import Pydantic tools for defining and validating the data contract
from pydantic import BaseModel, ConfigDict, Field, ValidationError
# -----------------------------------------
# Step 1: Define the data transfer object
# -----------------------------------------
class OrderDTO(BaseModel):
"""Defines the accepted structure of an incoming order request."""
# Reject type coercion and unexpected fields
model_config = ConfigDict(strict=True, extra="forbid")
product_id: int = Field(gt=0)
quantity: int = Field(ge=1, le=10)
# -----------------------------------------
# Step 2: Create the service layer
# -----------------------------------------
class OrderService:
"""Checks business rules after the request passes DTO validation."""
def __init__(self, stock: dict[int, int]):
self.stock = stock
def create_order(self, dto: OrderDTO, *, can_order: bool) -> dict:
"""Accept a demo order after permission and inventory checks."""
# Permission comes from trusted application context, not the payload
if not can_order:
raise PermissionError("You do not have permission to place orders")
if dto.product_id not in self.stock:
raise ValueError("Product not found")
if dto.quantity > self.stock[dto.product_id]:
raise ValueError("Insufficient stock")
# Demonstration only: no database write or inventory reservation
return dto.model_dump()
# -----------------------------------------
# Step 3: Validate at the application boundary
# -----------------------------------------
class OrderController:
"""Validates input before calling the service."""
def __init__(self, service: OrderService):
self.service = service
def create_order(self, data: dict, *, can_order: bool) -> dict:
"""Handle an order request and return a structured result."""
try:
dto = OrderDTO.model_validate(data)
except ValidationError as error:
# Report useful field errors without echoing the submitted payload
return {
"success": False,
"error_type": "validation",
"errors": [
{
"field": ".".join(str(part) for part in item["loc"]),
"message": item["msg"],
"type": item["type"],
}
for item in error.errors(include_input=False, include_url=False)
],
}
try:
order = self.service.create_order(dto, can_order=can_order)
except PermissionError as error:
return {"success": False, "error_type": "permission", "message": str(error)}
except ValueError as error:
return {"success": False, "error_type": "business_rule", "message": str(error)}
return {"success": True, "message": "Demo order accepted", "order": order}
# -----------------------------------------
# Step 4: Configure and demonstrate the flow
# -----------------------------------------
def main() -> None:
service = OrderService(stock={42: 5})
controller = OrderController(service)
# Simulate a permission determined by an authentication/authorization layer
can_order = True
cases = [
("Valid request", {"product_id": 42, "quantity": 2}, None),
("String instead of integer", {"product_id": 42, "quantity": "2"}, "int_type"),
("Quantity below minimum", {"product_id": 42, "quantity": 0}, "greater_than_equal"),
("Quantity above maximum", {"product_id": 42, "quantity": 11}, "less_than_equal"),
("Invalid product ID", {"product_id": 0, "quantity": 2}, "greater_than"),
("Missing quantity", {"product_id": 42}, "missing"),
("Unexpected field", {"product_id": 42, "quantity": 2, "admin": True}, "extra_forbidden"),
]
# -----------------------------------------
# Step 5: Verify the validation contract
# -----------------------------------------
for label, payload, expected_error in cases:
result = controller.create_order(payload, can_order=can_order)
print(f"\n{label}:\n{result}")
if expected_error is None:
assert result["success"] is True
assert result["order"] == payload
else:
assert result["success"] is False
assert result["error_type"] == "validation"
assert result["errors"][0]["type"] == expected_error
# -----------------------------------------
# Step 6: Verify separate business rules
# -----------------------------------------
# Eight satisfies the DTO's 1β10 range, but exceeds available stock (5)
insufficient_stock = controller.create_order(
{"product_id": 42, "quantity": 8}, can_order=True
)
assert insufficient_stock["error_type"] == "business_rule"
assert insufficient_stock["message"] == "Insufficient stock"
print(f"\nValid DTO, insufficient stock:\n{insufficient_stock}")
denied = controller.create_order(
{"product_id": 42, "quantity": 2}, can_order=False
)
assert denied["error_type"] == "permission"
print(f"\nValid DTO, permission denied:\n{denied}")
print("\nDTO & Data Validation Tests Passed!")
if __name__ == "__main__":
main()
No comments yet. Be the first to comment!