featured image

Stop Copying Response Boilerplate Across Every Endpoint: Build a Typed Generic Envelope in Pydantic v2

Learn how to use Python's `Generic[T]` protocol with Pydantic v2's `BaseModel` to create a single, typed response envelope that propagates concrete type information through to OpenAPI schema generation, provides consistent operational metadata across all endpoints without repetition, and enforces field-level constraints on both inbound and outbound data.

Published

Fri Oct 10 2025

Technologies Used

Python Pydantic FastAPI
Beginner 6 minutes

The Problem With Ten Endpoints That All Look the Same

You have ten API endpoints. Every one of them needs to return a request_id, a success flag, an inference timestamp, a disclaimer, and a data source list — in addition to the actual prediction payload. The naive move is to copy-paste that boilerplate into every response schema. Three months later, a field name changes and you’re hunting down ten files.

The more experienced instinct is to create a BaseResponse class and inherit from it — but inheritance breaks down when the prediction field changes type for every route. You end up reaching for Any, which throws away all type safety and kills the precision of your auto-generated OpenAPI documentation.

The real answer is a Generic response envelope: one schema definition that wraps any payload type while preserving strict typing end-to-end. VitalCheck uses exactly this pattern, defined in app/schemas/common.py. It’s the structural backbone of all fourteen API responses.

What You Need Before You Start: Python Generics and Pydantic Fundamentals

Knowledge Base:

  • Familiarity with Python classes and type annotations
  • Basic understanding of what an API response body is
  • You’ve seen Pydantic’s BaseModel before, even if only briefly
  • Optional but helpful: awareness of Python’s typing module (TypeVar, Generic)

Environment (from pyproject.toml):

Python        >= 3.11, < 3.13
pydantic      >= 2.10.0
fastapi       >= 0.115.0

Pydantic v2 is a complete rewrite of v1 with a Rust-powered core. The Generic[T] support in v2 is first-class and directly influences OpenAPI schema generation — the two systems are aware of each other. This tutorial requires v2 specifically.

Python’s Generic[T] isn’t magic. When you subclass Generic[T], Python’s type system registers T as a placeholder that gets resolved at the point of use — for example, VitalCheckResponse[DiabetesPrediction]. Pydantic v2 reads this resolved type at model construction time (via __class_getitem__) and correctly generates the prediction field’s JSON schema from the concrete type, not from the abstract T.

A Russian Nesting Doll: How the Envelope Architecture Works

The response structure is layered like a matryoshka doll. The outermost shell — VitalCheckResponse — is identical on every endpoint. It contains operational metadata: request tracking, timing, disclaimer. Inside sits the prediction field, which holds a domain-specific payload that differs per endpoint. Inside that payload, shared sub-components like RiskScore may appear again.

This nesting gives any client a single parsing contract: always unwrap the outer shell first, then deserialize the inner prediction based on the endpoint you called.

Think of VitalCheckResponse[T] as a standardized shipping box. The box always has the same label format, tracking number, and sender information on the outside. What’s inside the box — the cargo — changes every time. Generics let us describe both the box and its contents in a single specification without losing information about what’s inside.

Walking Through common.py: Four Blocks, One Coherent Pattern

Block 1 — The Risk Level Taxonomy (Lines 12–26)

Before any model can return a prediction, we need a controlled vocabulary for risk. An uncontrolled string like "kinda risky" is useless to a client. An enum with four members is a contract.

The str mixin on the enum is subtle but important: it makes RiskLevel.HIGH == "high" evaluate to True. Without it, you get False, which breaks JSON comparison in client code and causes silent bugs in tests.

class RiskLevel(str, Enum):
    LOW      = "low"       # probability < 0.30
    MODERATE = "moderate"  # probability 0.30–0.60
    HIGH     = "high"      # probability 0.60–0.80
    CRITICAL = "critical"  # probability >= 0.80

The conversion function that maps a raw float to this enum lives directly below. I made it a pure function with no side effects — it takes a float, returns an enum value, touches no external state. That makes it trivially testable and reusable across all five risk endpoints without any coupling.

def probability_to_risk_level(prob: float) -> RiskLevel:
    if prob >= 0.80:
        return RiskLevel.CRITICAL
    elif prob >= 0.60:
        return RiskLevel.HIGH
    elif prob >= 0.30:
        return RiskLevel.MODERATE
    return RiskLevel.LOW

Block 2 — The Shared Risk Score Sub-Model (Lines 29–32)

RiskScore is the most-reused sub-component in the codebase. Every classification endpoint embeds it. Defining it once in common.py means that when the OpenAPI spec is generated, Pydantic registers it as a single named schema component — $ref: '#/components/schemas/RiskScore' — rather than inlining it redundantly into every endpoint response body.

class RiskScore(BaseModel):
    # ge=0.0, le=1.0 are Pydantic field-level validators.
    # If a model returns 1.0001 due to floating point error, Pydantic raises
    # a ValidationError before the response ever leaves the server.
    probability: float = Field(ge=0.0, le=1.0, description="Predicted probability (0–1)")

    risk_level: RiskLevel = Field(description="Categorical risk tier")

    # Not all endpoints have a reference population to compute percentiles.
    # Using 'float | None' with a default of None is idiomatic Python 3.10+ syntax.
    percentile: float | None = Field(default=None, description="Percentile vs. reference population")

Block 3 — The Operational Metadata Carrier (Lines 35–44)

ResponseMetadata separates domain data from operational data. The prediction tells you what the model thinks. The metadata tells you how long it took, where the data came from, and what legal disclaimer applies. Keeping these concerns in separate fields makes it easy for monitoring infrastructure to extract inference_ms without knowing anything about the domain.

class ResponseMetadata(BaseModel):
    inference_ms: float = Field(description="Wall-clock inference time in milliseconds")

    # The disclaimer has a default value — it ships on every response automatically.
    # An endpoint can override it, but they all opt-in to the correct default.
    disclaimer: str = Field(
        default=(
            "This API provides estimates for informational and research purposes only. "
            "It is not a medical device and must not be used for clinical diagnosis or "
            "treatment decisions. Always consult a qualified healthcare professional."
        )
    )

    # list[str] with a factory default — each endpoint populates this with
    # the specific datasets its model was trained on.
    data_sources: list[str] = Field(default_factory=list)

Block 4 — The Generic Envelope (Lines 47–52)

This is where it comes together. Two imports enable the pattern: Generic and TypeVar from Python’s standard typing module.

from typing import Generic, TypeVar

# T is an unconstrained type variable. It will be substituted at point-of-use.
T = TypeVar("T")

# The key is the multiple inheritance: BaseModel gives us Pydantic behavior,
# Generic[T] registers T as a substitutable parameter.
class VitalCheckResponse(BaseModel, Generic[T]):
    success: bool = True

    # uuid.uuid4() generates a new UUID per instance, never per class.
    # The lambda wrapper is critical: without it, Field would call uuid4()
    # once at class definition time and every response would share the same ID.
    request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))

    model_version: str = "1.0.0"

    # T is resolved when you write VitalCheckResponse[DiabetesPrediction].
    # At that point, Pydantic knows prediction must be a DiabetesPrediction.
    prediction: T

    metadata: ResponseMetadata

Here’s how a route handler uses it — the type parameter makes prediction concrete:

# In app/routers/risk.py — the [DiabetesPrediction] resolves T.
# FastAPI reads this annotation to generate the OpenAPI response schema.
@router.post("/diabetes", response_model=VitalCheckResponse[DiabetesPrediction])
async def predict_diabetes(req: DiabetesRequest, ...) -> VitalCheckResponse[DiabetesPrediction]:
    ...
    return VitalCheckResponse(
        prediction=DiabetesPrediction(risk=risk, ...),
        metadata=ResponseMetadata(inference_ms=elapsed, data_sources=DIABETES_SOURCES),
    )

Notice that success, request_id, and model_version are never set in the route handler. They have defaults. The handler only provides what’s unique per request.

Two Things That Look Fine But Will Burn You

The default_factory Lambda

This one trips up almost everyone migrating from Pydantic v1:

# WRONG — uuid4() is called once at class definition time.
# Every single response shares the same request_id forever.
request_id: str = Field(default=str(uuid.uuid4()))

# CORRECT — the lambda is called once per model instantiation.
# Each response gets a fresh UUID.
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))

The default_factory parameter exists precisely because mutable or side-effecting defaults must be produced fresh on each call.

Generic Type Information Is Erased at Runtime

Generic[T] type information doesn’t survive to runtime in Python. That means isinstance(response, VitalCheckResponse[DiabetesPrediction]) raises a TypeError. You can’t use parameterized generics in isinstance checks. At runtime, the object is simply an instance of VitalCheckResponse. Reserve type parameter checks for static analysis tools (mypy, pyright) and IDE tooling only.

What the OpenAPI spec actually looks like

When FastAPI builds the OpenAPI specification at startup, it inspects the response_model annotation on each route. For VitalCheckResponse[DiabetesPrediction], Pydantic v2 creates a parameterized model — essentially a dynamically generated subclass with prediction bound to DiabetesPrediction. The generated schema for /diabetes shows the full nested structure of a diabetes prediction with all its fields and types.

If you used Any instead of T, the OpenAPI schema for prediction would be {} — a schema that matches any JSON value. Every endpoint’s documentation would say “prediction can be anything.” That’s useless for API consumers and can’t be used to auto-generate client SDKs.

The generic envelope pattern is the correct abstraction whenever you have a stable “container” shape and a variable “contents” shape. You’ll encounter this in pagination wrappers, event envelopes, API gateways, and anywhere responses share structure but differ in payload. The mechanics are always the same: TypeVar, Generic[T], concrete parameterization at the point of use.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!