++++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
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 NoneConfigDict(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.