AW Dev Rethought

βœ‚οΈ Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away - Antoine de Saint-ExupΓ©ry

βš™οΈ Python Backend Patterns – πŸ“¦ DTO & Data Validation 🐍


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()

Link copied!

Comments

Add Your Comment

Comment Added!