🚀
4. Pydantic Boundary
++++
Engineering
Apr 2026×13 min read

How Pydantic V2 is used to enforce strict input validation, normalize strings before they reach business logic, and map domain objects to stable API contracts.

4. Pydantic Boundary

Driptanil Datta
Driptanil DattaSoftware Developer

Why the Boundary Matters

Your API is a contract. Clients send data in, you send data out. If that contract is loose — untyped inputs, no normalization, internal models leaking into responses — bugs accumulate in subtle ways: whitespace-only titles get stored, unknown fields silently pass through, or a database column name gets exposed in a response by accident.

Pydantic V2 is the enforcement mechanism for both sides of that contract. This template uses it for request validation on the way in and response mapping on the way out, with clear rules for both.

Hardening the Request Schema

The CreateTodoRequest model in server/shared/src/schemas/todo.py demonstrates three things working together: type-constrained fields, extra="forbid", and a field_validator for normalization.

# server/shared/src/schemas/todo.py
TitleStr = Annotated[
    str,
    StringConstraints(
        strip_whitespace=True,
        min_length=1,
        max_length=200,
    ),
]
 
DescriptionStr = Annotated[str, StringConstraints(max_length=2000)]
 
 
class CreateTodoRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")
 
    title: TitleStr
    description: DescriptionStr | None = None
 
    @field_validator("description", mode="before")
    @classmethod
    def normalize_description(cls, value: str | None) -> str | None:
        if value is None:
            return None
        stripped = value.strip()
        return stripped if stripped else None

ConfigDict(extra="forbid") means any payload that contains a field not declared in this model will be rejected with a 422 before your handler even runs. This blocks payload pollution attacks and prevents clients from accidentally passing fields they shouldn't.

StringConstraints applies trimming and length limits directly on the type. When strip_whitespace=True, a title like " Buy milk " becomes "Buy milk" before validation runs. min_length=1 means an empty string after stripping raises a validation error — no more storing blank titles.

The field_validator on description handles the whitespace-only case that StringConstraints can't: " " strips to "", and the validator converts that to None rather than storing an empty string in the database.

Leaky Types Are a Real Risk

The simplest way to introduce data quality bugs is to accept dict as your input type:

# What not to do
@router.post("/")
async def bad_create(body: dict, service: TodoServiceDep):
    # accepts unknown fields, no validation, no normalization
    return await service.create_todo(body.get("title"), body.get("description"))

A client can send {"title": "", "description": " ", "internal_flag": true} and none of it gets caught. title is an empty string. description is whitespace. internal_flag passes through to the service, where it does nothing — or worse, causes a subtle downstream issue if something ever changes.

The Response Schema: Mapping Domain to Contract

The outbound side is equally important. TodoResponse defines exactly what the client receives:

class TodoResponse(BaseModel):
    model_config = ConfigDict(extra="forbid")
 
    id: UUID
    title: str
    description: str | None
    status: TodoStatus
    created_at: datetime
    updated_at: datetime
 
    @classmethod
    def from_domain(cls, todo: Todo) -> TodoResponse:
        return cls(
            id=todo.id,
            title=todo.title,
            description=todo.description,
            status=todo.status,
            created_at=_normalize_utc_datetime(todo.created_at),
            updated_at=_normalize_utc_datetime(todo.updated_at),
        )

The from_domain class method is the explicit mapping between the internal Todo dataclass and the API shape. Routers never pass the domain object directly to FastAPI's serializer — they always call from_domain first. This means you can add internal fields to the domain model (audit logs, internal flags, raw database metadata) without any risk of those fields showing up in API responses.

The _normalize_utc_datetime helper ensures all timestamps in responses are consistently UTC-aware, regardless of how they were stored.

What This Looks Like in Practice

In the router, the full cycle looks like this:

# server/api/src/routers/todo_router.py
@router.post("/", response_model=TodoResponse, status_code=status.HTTP_201_CREATED)
async def create_todo(body: CreateTodoRequest, service: TodoServiceDep):
    todo = await service.create_todo(body.title, body.description)
    return TodoResponse.from_domain(todo)

By the time body.title reaches the service, it has already been trimmed, length-checked, and validated. The service receives clean inputs and operates on domain types. The response passes through from_domain, so only the declared fields make it to the wire.

Every layer only deals with data that has already been validated by the layer above it. That's what makes the whole system predictable.

Drip

Driptanil Datta

Software Developer

Building full-stack systems, one commit at a time. This blog is a centralized learning archive for developers.

Legal Notes
Disclaimer

The content provided on this blog is for educational and informational purposes only. While I strive for accuracy, all information is provided "as is" without any warranties of completeness, reliability, or accuracy. Any action you take upon the information found on this website is strictly at your own risk.

Copyright & IP

Certain technical content, interview questions, and datasets are curated from external educational sources to provide a centralized learning resource. Respect for original authorship is maintained; no copyright infringement is intended. All trademarks, logos, and brand names are the property of their respective owners.

System Operational

© 2026 Driptanil Datta. All rights reserved.