Files
2026-06-01 21:42:05 +00:00

122 lines
4.3 KiB
Markdown

# Pydantic Boundaries
Use Pydantic models deliberately at FastAPI transport boundaries. They are excellent for parsing, validation, and serialization, but they should not automatically become your entire domain model.
## Why this convention exists
FastAPI and Pydantic fit together especially well at request and response boundaries:
- parsing external input
- validating shape and types
- coercing raw values
- serializing output
- generating OpenAPI schema
That convenience is the point. The trap is letting one transport model silently become the request model, response model, persistence model, and domain model for accidental reasons rather than deliberate ones.
## The convention
1. Use Pydantic models for request and response boundaries.
2. Validate external input explicitly.
3. Serialize explicitly at the response boundary.
4. Translate between transport models and domain or persistence objects when the concerns diverge.
5. Keep field-level validation and coercion on the schema that owns those rules.
## Follow it when data crosses the API boundary
Typical fits:
- request bodies
- response models
- field aliasing or defaulting rules
- API shapes that should stay stable even if internal objects change
## Do not split models just to satisfy a slogan
A tiny app may genuinely use the same shape all the way through.
But split models once the concerns stop matching, especially when:
- API names diverge from internal names
- persistence fields should not leak outward
- the response shape is not the storage shape
- business invariants deserve their own representation
## Preferred shapes
### Request model at the boundary, explicit translation inward
```python
@router.post("/items", response_model=ItemPublic)
def create_item(session: SessionDep, current_user: CurrentUser, item_in: ItemCreate) -> ItemPublic:
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
session.add(item)
session.commit()
session.refresh(item)
return item
```
This keeps request parsing explicit while still allowing a translation step into the stored object.
### Explicit partial-update semantics
```python
update_dict = item_in.model_dump(exclude_unset=True)
```
That is clearer than guessing which missing fields mean "leave unchanged."
## Counterexamples
### One transport schema leaked into every layer
```python
class UserPayload(BaseModel):
...
# same class used for HTTP input, DB persistence, internal domain state,
# background jobs, and every outbound response
```
Sometimes fine for a toy app; usually brittle over time.
### Ad hoc dict shaping in handlers
```python
return {"id": item.id, "name": item.name, "owner": current_user.email}
```
Bad because the response contract drifts away from any explicit schema.
### Validation rules smeared across unrelated layers
If field coercion belongs to the request schema, keep it there instead of duplicating it in routes, services, and ORM hooks.
## Source signals
### Pydantic
- `pydantic/main.py:253-264` states that model construction parses and validates input data and raises `ValidationError` when the input cannot form a valid model.
- `pydantic/docs/index.md:68-107` shows external data becoming a typed model and then being serialized with `model_dump()`.
- `pydantic/docs/index.md:109-152` shows invalid input producing structured validation errors.
- `pydantic/docs/concepts/validators.md:91-114` shows field-scoped `mode='after'` validation.
- `pydantic/docs/concepts/validators.md:160-252` shows `mode='before'` validators operating on raw input before normal parsing.
### Full-stack FastAPI Template
- `backend/app/api/routes/items.py:61-72` accepts `ItemCreate`, translates it into an `Item`, commits it, and returns the stored object.
- `backend/app/api/routes/items.py:44-45` converts query results into `ItemPublic` response models for collection responses.
- `backend/app/api/routes/items.py:91-95` uses `model_dump(exclude_unset=True)` before applying a patch-style update.
### FastAPI
- `fastapi/exception_handlers.py:20-26` turns request validation failures into a consistent 422 response instead of leaving each route to invent its own error format.
## Bottom line
Pydantic belongs at the HTTP boundary by default.
Use it there aggressively.
When transport concerns and domain concerns diverge, translate between them instead of forcing one class to own every job forever.