Pydantic is excellent at validating data. That does not mean one BaseModel should represent an HTTP request, a database row, a calculation state, and a response.
Those objects change for different reasons.
class CreateSessionRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str = Field(min_length=1, max_length=100)
started_at: datetime
This is a wire contract. It protects the application from malformed client input and contributes to OpenAPI documentation.
An internal calculation may need stronger semantics:
@dataclass(frozen=True, slots=True)
class TrainingLoad:
acute: float
chronic: float
ratio: float
evaluated_at: datetime
The dataclass is immutable, cheap to construct, and independent of JSON. It does not need aliases, request validation messages, or API defaults.
Keep database representation at persistence#
A PostgreSQL row may include internal columns, nullable legacy values, or denormalized fields:
def training_load_from_row(row: asyncpg.Record) -> TrainingLoad:
return TrainingLoad(
acute=float(row['acute_load']),
chronic=float(row['chronic_load']),
ratio=float(row['load_ratio']),
evaluated_at=row['computed_at'],
)
Explicit mapping prevents database names and migration history from leaking through every layer.
Reuse a schema when it truly fits#
Separating representations is not a rule to create three classes for every object. If a small response model is also a convenient internal value and has no transport-only behavior, reuse it.
Create a domain type when you need immutability, different fields, domain methods, hashing, cheaper construction, or independence from the API contract.
Validate at every external boundary#
HTTP input is one boundary. Environment variables, webhooks, queued messages, AI tool arguments, and old JSON stored in a database are boundaries too. Validate there, then pass trusted typed values inward.
Pydantic should make external data safe and public contracts explicit. Domain models should make business behavior clear. Database mappings should represent persistence honestly. Letting each type serve one purpose makes all three easier to change.