ZB Field Notes

FastAPI for Spring developers: the boilerplate that isn't there

FastAPI for Spring developers: the boilerplate that isn't there

A comment I left in my own code

I’ve been poking at FastAPI in a throwaway main.py, coming at it as what I actually am: a Spring Boot engineer who has spent years wiring controllers, DTOs, mappers and validators. Sixty lines in, I noticed I’d left myself a comment above a pair of models:

# no mapstruct here, so smooth.

That throwaway line is the whole story of this post. Here is what provoked it:

class UserIn(BaseModel):
    username: str
    password: str
    email: str

class UserOut(BaseModel):
    username: str
    email: str

@app.post("/users", response_model=UserOut)
def create_user(user: UserIn):
    return user   # the password never leaves the building

I return the full user — password and all — and the client gets back only username and email. In Spring, going from an inbound type to a different outbound type is a small ritual: an entity, a response DTO, and a MapStruct mapper with its @Mapping config and a generated impl class to shuttle the fields across. Here, response_model=UserOut is the mapper. FastAPI does not copy my object into a new one — it re-validates my return value against UserOut’s schema and serialises only the fields that shape declares. The projection is the type.

The type hint is the framework

Once I saw that, the rest of the file rhymed. In Spring, the metadata that drives a request lives in annotations: @RequestBody, @PathVariable, @RequestParam, @Valid. In FastAPI it lives in the type signature. The same jobs get done; there is just far less scaffolding standing between me and them.

Table mapping six Spring/Java mechanisms to their FastAPI equivalents: response DTO plus MapStruct mapper becomes response_model=UserOut; @Positive plus @Valid becomes Field(gt=0); @PathVariable becomes item_id: int; @RequestParam default becomes limit: int = 10; @RequestBody plus Jackson becomes item: Item; a @Component bean becomes Annotated[dict, Depends(...)].
Concept for concept, the same request-handling job — but the type signature already carries the metadata Spring spreads across annotations.

Reading a route the way FastAPI does

Take four routes straight out of the file. Not one of them is annotated for binding; the parameter’s type is what decides its role.

Dark cheat sheet reading five FastAPI parameter shapes: item_id: int is a path variable coerced to int (422 on a non-numeric id); q: str is a required query param; limit: int = 10 is an optional query param defaulting to 10; item: Item is parsed from the JSON body; pagination: CommonPagination is an injected dependency. FastAPI decides each role from the parameter's type.
A model type means the JSON body; a plain scalar means a query param; a name that matches the path means a path variable; a Depends alias means a dependency. One rule, read off the signature.

The validation story folds into the same idea. My Item model constrains a field inline:

class Item(BaseModel):
    name: str
    price: float = Field(gt=0)
    description: str | None = None
    in_stock: bool = True

Field(gt=0) is Bean Validation’s @Positive — except I do not also have to remember to hang @Valid on the argument to arm it. A body that violates the constraint returns a 422 with a precise error on its own. And description: str | None = None is an optional field with a default, no Optional<String> gymnastics to declare it.

Dependency injection, the part that made me grin

This is the one that looked most alien at a glance and turned out to be the most familiar:

def pagination_params(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit}

CommonPagination = Annotated[dict, Depends(pagination_params)]

@app.get("/products")
def read_items(pagination: CommonPagination):
    return {"pagination": pagination, "data": ["a", "b", "c"]}

pagination_params is just a function. Wrapping it in Depends and aliasing that as CommonPagination gives me a reusable, injectable unit I drop into any route by declaring the type. And because the dependency is itself a typed function, its skip and limit become query parameters on every route that uses it — the resolution is recursive. That is my @Component plus a request-scoped argument resolver, minus the container: no @Autowired, no bean definition, no component scan. A function and a type alias.

Where it actually costs you

I am not going to pretend it is free. Spring’s annotations are checked at compile time inside a mature, boringly reliable ecosystem; FastAPI’s guarantees are runtime, enforced by Pydantic when the request lands. A wrong type is a Spring build failure and a FastAPI bug you meet in production. Refactoring across a large Java codebase leans on the compiler in a way duck-typed Python cannot match, and the JVM’s operational story — profilers, thread dumps, libraries that have been hardened for a decade — runs deeper than the Python web stack. What I buy with FastAPI is speed of writing and a signature that documents itself; what I give up is the compiler holding my hand.

And then it hands you the docs

The detail that closed the deal: because every route’s shape is declared in types, FastAPI already holds the full schema — so it generates an OpenAPI document and a live Swagger UI at /docs with no extra code. In Spring I reach for springdoc, annotate the edge cases, and keep it in sync. Here it simply exists, and it is correct by construction, because it is derived from the same signatures that serve the requests. That is the “so smooth” I had grumbled about in a code comment. The boilerplate is not hidden, and it is not generated for me behind the scenes. It just is not there.