Skip to content
AB

Pydantic Is a Boundary, Not Your Entire Domain Model

Separate API schemas, internal domain types, and database rows so each representation serves the job it was designed for.

Ashish BhakhandPublished

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.

Code
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:

Code
@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:

Code
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.

← Back to all writing